Merge pull request #592: feat(cli-registry): GitHub Copilot CLI as a run mode

GitHub Copilot CLI (`copilot`) becomes a run mode: Run menu, welcome
screen, cron and CLI management once it is installed, tmux-backed like
the other agent CLIs, working/idle and the model read off its footer,
launch options through `copilotConfig` (model, resume, continue, the
multi-user-clamped allowAll), Custom Model Endpoints via Copilot's BYOK
variables, MCP sync of its mcp-config.json, a status probe, and the
`codeman agent` verbs (spawn waits for its composer, read returns its
last answer).

The plan-usage quota row that earlier rounds carried was split out on
review and is not part of this merge.

Merge-time commits on top: the wider COPILOT_* clamp and the zh-CN
README rows (maintainer), the Docker sign-in docs, divider rows inside
an answer kept by `agent read`, route tests for the screen-read
last-response branch, and doc drift (Past Sessions, wheelForward JSDoc,
COPILOT_/OMP_ in the env prefix lists).

Verified before merge on Copilot CLI 1.0.98: injected keystrokes,
bracketed paste and Enter reach the TUI in tmux (github/copilot-cli#4180
does not reproduce), a shell tool call completes inside tmux (#4223 does
not reproduce), and a session driven through this branch answered over
send-and-wait.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 08:17:06 +02:00
commit 8e6abf4bfd
80 files changed
+1911 -132

No files matched your search

+33 -1
View File
@@ -102,7 +102,7 @@ describe('isMuxAltScreenOnlyStripMode', () => {
expect(isMuxAltScreenOnlyStripMode(mode, false)).toBe(false);
}
// The full strip and the mouse strip already own their modes; never double-gate.
for (const mode of ['claude', 'codex', 'gemini', 'opencode'] as const) {
for (const mode of ['claude', 'codex', 'gemini', 'opencode', 'copilot'] as const) {
expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(false);
}
});
@@ -366,3 +366,35 @@ describe('strip decision table: live stream = replay, for every stock CLI', () =
}
}
});
/**
* GitHub Copilot CLI is the same shape as opencode: a full-screen TUI that turns mouse tracking
* on itself. Left in, xterm reports a plain drag to the TUI instead of selecting, so Auto Copy and
* copy-on-select did nothing (only Shift+drag selected). Same strip, same reason.
*/
describe('copilot: alt-screen + mouse DECSETs stripped, 3J kept', () => {
it('is a mouse-strip mode under tmux, and only there', () => {
expect(isMuxMouseStripMode('copilot', true)).toBe(true);
expect(isMuxMouseStripMode('copilot', false)).toBe(false);
});
it('drops mouse tracking so xterm keeps local text selection, and keeps 3J', () => {
const session = new Session({ workingDir: '/tmp', mode: 'copilot' as never, useMux: true });
handleOutput(session, '\x1b[?1003h\x1b[?1006h\x1b[3JTUI\x1b[?1006l\x1b[?1003l');
expect(session.terminalBuffer).toBe('\x1b[3JTUI');
});
it('publishes cliMouseTracking so the browser can forward the wheel and hand-encode clicks', () => {
const session = new Session({ workingDir: '/tmp', mode: 'copilot' as never, useMux: true });
handleOutput(session, '\x1b[?1002h\x1b[?1006hTUI');
expect(session.toState().cliMouseTracking).toBe(true);
handleOutput(session, '\x1b[?1002l\x1b[?1006l');
expect(session.toState().cliMouseTracking).toBeFalsy();
});
it('leaves a direct-PTY copilot pane untouched', () => {
const session = new Session({ workingDir: '/tmp', mode: 'copilot' as never, useMux: false });
handleOutput(session, '\x1b[?1049h\x1b[?1002hTUI');
expect(session.terminalBuffer).toBe('\x1b[?1049h\x1b[?1002hTUI');
});
});
+2
View File
@@ -506,6 +506,8 @@ describe('agent spawn', () => {
it('takes the readiness mark from the CLI registry, not from a mode list', () => {
expect(composerReadyMark('claude')).toBe('shift+tab');
expect(composerReadyMark('deepseek')).toBe('❯');
// Copilot's trust dialog draws `❯ 1. Yes`, so its mark is the composer's footer hint instead.
expect(composerReadyMark('copilot')).toBe('/ commands · ? help');
expect(composerReadyMark('pi')).toBeUndefined();
expect(composerReadyMark('no-such-cli')).toBeUndefined();
});
+2 -1
View File
@@ -36,6 +36,7 @@ describe('per-mode capability predicates', () => {
['grok', true, false, false],
['deepseek', true, false, true],
['omp', true, false, false],
['copilot', true, false, false],
] as Array<[SessionMode, boolean, boolean, boolean]>)(
'%s: external=%s altScreenStrip=%s hooks=%s',
(mode, external, altScreen, hooks) => {
@@ -48,7 +49,7 @@ describe('per-mode capability predicates', () => {
it('covers every enabled mode (sanity)', () => {
// If a CLI is added without a row above, this fails rather than the table silently
// describing a subset of reality.
expect(MODES.length).toBe(10);
expect(MODES.length).toBe(11);
});
it('keeps the three predicates genuinely distinct', () => {
@@ -95,6 +95,7 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
"web/routes/session-routes.ts::mode === 'antigravity'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'pi'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'grok'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'copilot'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'deepseek'": allow(2, 'legacy <Mode>Config plumbing'),
"web/server.ts::mode === 'opencode'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'codex'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
@@ -102,6 +103,7 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
"web/server.ts::mode === 'antigravity'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'pi'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'grok'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'copilot'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'deepseek'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'omp'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
+15 -2
View File
@@ -485,10 +485,23 @@ describe('capability shapes', () => {
}, 'transcriptPageKeys is a boolean');
});
it('declares which CLIs get the wheel forwarded, and whether a version gates it', () => {
// claude forwards from 2.1.187 (fullscreen renderer only); copilot forwards at any version while
// it tracks the mouse. codex ignores SGR wheel reports, so every other entry stays 'never'.
const mode = (id: string) => STOCK_CLIS.find((e) => (e.id as string) === id)?.capabilities.wheelForward;
expect(mode('copilot')).toEqual({ mode: 'always' });
expect(mode('claude')).toEqual({ mode: 'version-gated', minVersion: '2.1.187' });
const forwarding = STOCK_CLIS.filter((e) => e.capabilities.wheelForward.mode !== 'never').map(
(e) => e.id as string
);
expect(forwarding.sort()).toEqual(['claude', 'copilot']);
expect(CliEntrySchema.safeParse(baseEntry('copilot')).success).toBe(true);
});
it('declares transcriptPageKeys for exactly the CLIs measured to page on PageUp/PageDown', () => {
// claude (repaint mode), codex and opencode (both on the alternate screen) were
// claude (repaint mode), codex, opencode and copilot (all on the alternate screen) were
// measured paging their own transcript on PageUp/PageDown; no other stock entry has been.
const paging = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id as string);
expect(paging.sort()).toEqual(['claude', 'codex', 'opencode']);
expect(paging.sort()).toEqual(['claude', 'codex', 'copilot', 'opencode']);
});
});
+69
View File
@@ -320,3 +320,72 @@ describe('unsafe values are DROPPED, never escaped into the command', () => {
expect(cmd).not.toContain('..');
});
});
describe('copilot', () => {
const cp = (copilotConfig?: SpawnBridgeOptions['copilotConfig']) =>
render({ mode: 'copilot' as never, sessionId: SID, copilotConfig });
it("spawns bare by default, in Copilot's own Manual Approval mode", () => {
expect(cp()).toBe('copilot');
});
it('emits --yolo only when asked, with the model', () => {
expect(cp({ allowAll: true, model: 'claude-sonnet-5.5' })).toBe('copilot --yolo --model claude-sonnet-5.5');
expect(cp({ allowAll: false })).toBe('copilot');
});
it("pins the tab's name as --name", () => {
expect(render({ mode: 'copilot' as never, sessionId: SID, sessionName: 'w4-Codeman' } as never)).toBe(
'copilot --name "w4-Codeman"'
);
});
it('drops --name next to --resume and --continue, which copilot refuses to combine', () => {
const named = (copilotConfig: SpawnBridgeOptions['copilotConfig']) =>
render({ mode: 'copilot' as never, sessionId: SID, sessionName: 'w4-Codeman', copilotConfig } as never);
expect(named({ resumeSessionId: '0c02c30f' })).toBe('copilot --resume 0c02c30f');
expect(named({ continueSession: true })).toBe('copilot --continue');
expect(named({ allowAll: true, model: 'auto' })).toBe('copilot --yolo --name "w4-Codeman" --model auto');
});
it('prefers an explicit resume id over --continue', () => {
expect(cp({ resumeSessionId: '0c02c30f-3383-4139-8b52-b28abad2ad43' })).toBe(
'copilot --resume 0c02c30f-3383-4139-8b52-b28abad2ad43'
);
expect(cp({ continueSession: true })).toBe('copilot --continue');
expect(cp({ continueSession: true, resumeSessionId: '0c02c30f' })).toBe('copilot --resume 0c02c30f');
});
it('drops unsafe values rather than escaping them', () => {
expect(cp({ model: 'a`b' })).toBe('copilot');
expect(cp({ resumeSessionId: 'my session; rm -rf /' })).toBe('copilot');
});
// `--resume`'s value is optional, so `--resume --yolo` would make `--yolo` its own flag and get
// around the allowAll clamp. A resume value must start with an alphanumeric.
it('drops a resume value that would be read as a flag', () => {
for (const resumeSessionId of ['--yolo', '-n', '--allow-all-tools', '.', '..', '.hidden']) {
expect(cp({ resumeSessionId })).toBe('copilot');
}
expect(cp({ allowAll: false, resumeSessionId: '--yolo' })).toBe('copilot');
// A plain word is still passed through (a session NAME also resumes).
expect(cp({ resumeSessionId: 'my-feature' })).toBe('copilot --resume my-feature');
});
// `--model --yolo` would make `--yolo` its own flag and get around the allowAll clamp, so a model id
// starts with an alphanumeric too. A provider/id value (the `/`) is still accepted.
it('drops a model value that would be read as a flag', () => {
for (const model of ['--yolo', '-n', '--allow-all-tools', '.', '/etc/passwd']) {
expect(cp({ model })).toBe('copilot');
expect(cp({ allowAll: false, model })).toBe('copilot');
}
expect(cp({ model: 'claude-sonnet-5.5' })).toBe('copilot --model claude-sonnet-5.5');
expect(cp({ model: 'openai/gpt-5' })).toBe('copilot --model openai/gpt-5');
});
it('never puts a credential on the command line', () => {
const cmd = cp({ allowAll: true, model: 'auto' }) ?? '';
expect(cmd).not.toContain('token');
expect(cmd).not.toContain('key');
});
});
+132
View File
@@ -0,0 +1,132 @@
/**
* @fileoverview Tests for the GitHub Copilot CLI resolver wrapper.
*
* Copilot is a resolver with a version probe: `copilot` is a generic binary name, so a
* resolved path is only accepted once `copilot --version` prints a
* `GitHub Copilot CLI <semver>` string (e.g. `GitHub Copilot CLI 1.0.94.`). The probe EXECUTES the candidate, which is
* exactly why it must never run under vitest — the hermeticity test below pins
* that gate with a real executable fixture that would make the test fail
* loudly if the gate were deleted again.
*/
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createCopilotResolverForTest } from '../src/utils/copilot-cli-resolver.js';
import {
cliResolveRetryDelayMs,
createProductionCliResolverHost,
type CliResolverHost,
} from '../src/utils/cli-executable-resolver.js';
const temporaryDirectories: string[] = [];
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function createHost(
options: {
processPathResult?: string | null;
loginShellResults?: Array<string | null>;
existingPaths?: string[];
} = {}
): CliResolverHost {
const loginShellResults = [...(options.loginShellResults ?? [])];
const existingPaths = new Set(options.existingPaths ?? []);
return {
processPath: '/service/bin',
shellPath: '/bin/zsh',
shellArgs: ['-l'],
findOnProcessPath: () => options.processPathResult ?? null,
findInLoginShell: () => loginShellResults.shift() ?? null,
exists: (path) => existingPaths.has(path),
};
}
describe('GitHub Copilot CLI resolver', () => {
it('accepts a candidate the version probe verifies and carries the version as metadata', () => {
const binaryPath = '/service/bin/copilot';
const probe = vi.fn(() => '1.0.94');
const resolver = createCopilotResolverForTest(
createHost({ processPathResult: binaryPath, existingPaths: [binaryPath] }),
probe
);
expect(resolver.resolve()).toMatchObject({
binaryPath,
directory: '/service/bin',
source: 'process-path',
metadata: '1.0.94',
});
expect(probe).toHaveBeenCalledWith(binaryPath);
});
it('rejects a candidate the probe refuses and falls through to a later one', () => {
// An unrelated `copilot` on the service PATH (probe returns null) must not mask
// the real coding agent found by the login shell.
const impostor = '/service/bin/copilot';
const genuine = '/login-shell/bin/copilot';
const probe = vi.fn((binPath: string) => (binPath === genuine ? '1.0.94' : null));
const resolver = createCopilotResolverForTest(
createHost({
processPathResult: impostor,
loginShellResults: [genuine],
existingPaths: [impostor, genuine],
}),
probe
);
expect(resolver.resolve()).toMatchObject({ binaryPath: genuine, source: 'login-shell', metadata: '1.0.94' });
});
it('negative-caches a miss and retries only after the backoff elapses', () => {
const binaryPath = '/late/bin/copilot';
let now = 0;
const probe = vi.fn(() => '1.0.94');
const resolver = createCopilotResolverForTest(
createHost({ loginShellResults: [null, binaryPath], existingPaths: [binaryPath] }),
probe,
() => now
);
expect(resolver.resolve()).toBeNull();
expect(resolver.resolve()).toBeNull(); // within the backoff: no re-run
expect(probe).not.toHaveBeenCalled();
now = cliResolveRetryDelayMs(1);
expect(resolver.resolve()?.metadata).toBe('1.0.94');
expect(resolver.resolve()?.binaryPath).toBe(binaryPath);
});
it('never executes a copilot candidate under vitest (the ambient probe is VITEST-gated)', () => {
// A REAL executable fixture that prints a valid version. If the guard in
// probeCopilotVersion is ever removed again, the probe runs this script, the
// resolution SUCCEEDS, and this test fails — pinning hermeticity by
// behavior rather than by source text. (The suites must never execute
// whatever `copilot` binary the machine running them happens to carry.)
const root = mkdtempSync(join(tmpdir(), 'codeman-copilot-vitest-gate-'));
temporaryDirectories.push(root);
const binaryPath = join(root, 'copilot');
writeFileSync(binaryPath, '#!/bin/sh\necho GitHub Copilot CLI 0.99.0.\n');
chmodSync(binaryPath, 0o755);
const hostOptions = {
processPath: root,
shellPath: '/bin/bash',
shellArgs: ['-i', '-l'] as string[],
runCommand: () => '',
isExecutableFile: (path: string) => path === binaryPath,
};
// Default (ambient) probe: the candidate is found but never executed, so
// the VITEST gate reports it unusable and resolution misses.
const gated = createCopilotResolverForTest(createProductionCliResolverHost(hostOptions));
expect(gated.resolve()).toBeNull();
// Control: identical setup with an injected probe resolves, proving the
// null above comes from the gate, not from the fixture or the host.
const control = createCopilotResolverForTest(createProductionCliResolverHost(hostOptions), () => '0.99.0');
expect(control.resolve()).toMatchObject({ binaryPath, metadata: '0.99.0' });
});
});
+25
View File
@@ -0,0 +1,25 @@
/**
* @fileoverview copilotConfig on the session create body: `model` and `resumeSessionId` follow a flag
* (`--model`, `--resume`), so a value that starts with `-` would be read as its own flag and get
* around the allowAll clamp. Both need a leading letter or digit.
*/
import { describe, it, expect } from 'vitest';
import { CreateSessionSchema } from '../src/web/schemas.js';
const parse = (copilotConfig: unknown) =>
CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'copilot', copilotConfig });
describe('copilotConfig schema', () => {
it.each(['--yolo', '-n', '--allow-all-tools', '.hidden', '/etc/passwd'])('rejects model %s', (model) => {
expect(parse({ model }).success).toBe(false);
});
it.each(['--yolo', '-n', '--allow-all-tools'])('rejects resumeSessionId %s', (resumeSessionId) => {
expect(parse({ resumeSessionId }).success).toBe(false);
});
it.each(['auto', 'claude-sonnet-5.5', 'openai/gpt-5'])('accepts model %s', (model) => {
expect(parse({ model }).success).toBe(true);
});
});
+94
View File
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, it, expect } from 'vitest';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
import { _clampEnvOverridesForOwner } from '../src/web/routes/session-routes.js';
import { CreateSessionSchema } from '../src/web/schemas.js';
const copilot = STOCK_CLIS.find((c) => (c.id as string) === 'copilot')!;
describe('GitHub Copilot custom endpoint (BYOK)', () => {
it('injects its COPILOT_PROVIDER_* variables, with the /v1 suffix an OpenAI route needs', () => {
expect(copilot.capabilities.customModelInjection).toEqual({
kind: 'env',
baseUrlVar: 'COPILOT_PROVIDER_BASE_URL',
apiKeyVar: 'COPILOT_PROVIDER_API_KEY',
modelVars: ['COPILOT_MODEL'],
appendV1Suffix: true,
});
});
it('keeps every injected variable privileged, so a non-granted owner cannot set them by hand', () => {
const injection = copilot.capabilities.customModelInjection;
if (injection.kind !== 'env') throw new Error('expected env injection');
for (const name of [injection.baseUrlVar, injection.apiKeyVar, ...injection.modelVars]) {
expect(copilot.capabilities.privilegedEnvKeys).toContain(name);
expect(name.startsWith('COPILOT_')).toBe(true); // reachable through the prefix allowlist
}
});
});
describe('GitHub Copilot environment and sync declarations', () => {
it('admits only its own COPILOT_ namespace, never the shared GitHub token names', () => {
// The env allowlist is one global list with no mode context, so a foreign key here would be
// settable on every session. COPILOT_GITHUB_TOKEN is already reachable through the prefix.
expect(copilot.env.allowedPrefixes).toEqual(['COPILOT_']);
expect(copilot.env.allowedKeys).toEqual([]);
});
it('declares its own MCP config so Settings does not list it as unsupported', () => {
expect(copilot.capabilities.mcpConfig).toEqual({
path: '.copilot/mcp-config.json',
format: 'copilot-json',
relocation: { envVar: 'COPILOT_HOME', path: 'mcp-config.json' },
});
});
it('seeds no credentials into a Docker case', () => {
expect(copilot.overlays?.credStore).toBeUndefined();
});
});
describe('GitHub Copilot multi-user clamp: the env-var half', () => {
// COPILOT_ is an allowlisted prefix, so every variable below would otherwise pass the schema.
// Each one hands back what the allowAll clamp removed: an approval switch, code that runs with no
// approval row (a CLI build, an editor, a key command, skills, hooks), or the sign-in token sent to
// a host of the caller's choosing.
const DANGEROUS: Record<string, string> = {
COPILOT_ALLOW_ALL: 'true',
COPILOT_PLAN_THEN_AUTOPILOT: '1',
COPILOT_HOME: '/tmp/planted-copilot-home',
COPILOT_CLI_DIST_DIR: '/tmp/planted-dist',
COPILOT_PKG_CACHE_HOME: '/tmp/planted-cache',
COPILOT_CACHE_HOME: '/tmp/planted-cache',
COPILOT_EDITOR: 'sh -c planted',
COPILOT_PROVIDER_API_KEY_COMMAND: 'sh -c planted',
COPILOT_SKILLS_DIRS: '/tmp/planted-skills',
COPILOT_PROVIDERS_CONFIG: '/tmp/planted-providers.json',
COPILOT_API_URL: 'https://attacker.example',
COPILOT_GH_HOST: 'attacker.example',
COPILOT_PROVIDER_BASE_URL: 'https://attacker.example/v1',
COPILOT_PROVIDER_HEADERS: 'X-Planted: 1',
};
const ORIGINAL = process.env.CODEMAN_MULTIUSER;
beforeEach(() => {
process.env.CODEMAN_MULTIUSER = '1';
});
afterEach(() => {
if (ORIGINAL === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = ORIGINAL;
});
it('passes the env allowlist, so only the clamp stands between these and the pane', () => {
expect(() =>
CreateSessionSchema.parse({ workingDir: '/tmp', mode: 'copilot', envOverrides: DANGEROUS })
).not.toThrow();
});
it("strips every one of them from a non-granted owner's overrides and keeps the harmless knobs", async () => {
const out = await _clampEnvOverridesForOwner('nobody', {
...DANGEROUS,
COPILOT_GITHUB_TOKEN: 'own-token',
COPILOT_AUTO_UPDATE: 'false',
});
expect(out).toEqual({ COPILOT_GITHUB_TOKEN: 'own-token', COPILOT_AUTO_UPDATE: 'false' });
});
});
+1 -1
View File
@@ -128,7 +128,7 @@ describe('docker server image divergence is declared, not accidental', () => {
// untouched by this change because two other open PRs already modify it. Asserting the
// omissions here makes the divergence reviewable without editing the file: if someone adds
// a CLI there, or the intent changes, this fails and the list has to be restated.
const SERVER_INTENTIONAL_OMISSIONS = new Set(['antigravity', 'pi', 'grok', 'deepseek', 'omp']);
const SERVER_INTENTIONAL_OMISSIONS = new Set(['antigravity', 'pi', 'grok', 'deepseek', 'omp', 'copilot']);
it('installs exactly the CLIs it declares, and no more', () => {
for (const entry of enabledAgents) {
+11
View File
@@ -51,6 +51,17 @@ describe('envOverrides exact-key allowlist', () => {
).toThrow();
});
it('rejects the shared GitHub token names (Copilot reads COPILOT_GITHUB_TOKEN, in its own prefix)', () => {
for (const key of ['GH_TOKEN', 'GITHUB_TOKEN']) {
for (const mode of ['claude', 'shell', 'codex', 'copilot'] as const) {
expect(() => CreateSessionSchema.parse({ workingDir: '/tmp', mode, envOverrides: { [key]: 'x' } })).toThrow();
}
}
expect(() =>
CreateSessionSchema.parse({ workingDir: '/tmp', mode: 'copilot', envOverrides: { COPILOT_GITHUB_TOKEN: 'x' } })
).not.toThrow();
});
it('still blocks security-sensitive keys', () => {
for (const key of ['PATH', 'LD_PRELOAD', 'NODE_OPTIONS', 'CODEMAN_MUX_NAME']) {
expect(() =>
+8
View File
@@ -86,6 +86,13 @@ const LITERAL_SEARCH_PATHS: Record<string, string[]> = {
'$HOME/.npm-global/bin/omp',
'$HOME/bin/omp',
],
COPILOT: [
'$HOME/.local/bin/copilot',
'/usr/local/bin/copilot',
'$HOME/.bun/bin/copilot',
'$HOME/.npm-global/bin/copilot',
'$HOME/bin/copilot',
],
};
/** Array prefix in `install.sh` -> registry id, for the two that differ. */
@@ -99,6 +106,7 @@ const ARRAY_PREFIX_TO_CLI_ID: Record<string, string> = {
GROK: 'grok',
ANTIGRAVITY: 'antigravity',
OMP: 'omp',
COPILOT: 'copilot',
};
/**
+2
View File
@@ -36,6 +36,7 @@ it('pins every remote pane command', () => {
grok: remoteLoginShellCommand('grok'),
deepseek: remoteLoginShellCommand('dsh'),
omp: remoteLoginShellCommand('omp'),
copilot: remoteLoginShellCommand('copilot'),
};
for (const [mode, want] of Object.entries(expected)) {
expect(defaultRemoteCommandForMode(mode as SessionMode), mode).toBe(want);
@@ -55,6 +56,7 @@ it('pins every in-container pane command', () => {
grok: 'exec grok',
deepseek: 'exec dsh',
omp: 'exec omp',
copilot: 'exec copilot',
};
for (const [mode, want] of Object.entries(expected)) {
expect(defaultDockerCommandForMode(mode as SessionMode), mode).toBe(want);
+2 -1
View File
@@ -18,7 +18,7 @@ function withMcp(mcpConfig: unknown) {
describe('capabilities.mcpConfig', () => {
it('is declared by exactly the CLIs whose format is verified', () => {
const declared = STOCK_CLIS.filter((e) => e.capabilities.mcpConfig).map((e) => e.id as string);
expect(declared.sort()).toEqual(['antigravity', 'claude', 'codex', 'gemini', 'opencode']);
expect(declared.sort()).toEqual(['antigravity', 'claude', 'codex', 'copilot', 'gemini', 'opencode']);
});
it('every stock declaration passes the schema, with a distinct file per CLI', () => {
@@ -41,6 +41,7 @@ describe('capabilities.mcpConfig', () => {
claude: { envVar: 'CLAUDE_CONFIG_DIR', path: '.claude.json' },
opencode: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' },
codex: { envVar: 'CODEX_HOME', path: 'config.toml' },
copilot: { envVar: 'COPILOT_HOME', path: 'mcp-config.json' },
gemini: { envVar: 'GEMINI_CLI_HOME', path: '.gemini/settings.json' },
antigravity: undefined,
});
+1
View File
@@ -469,6 +469,7 @@ describe('mobile overview run picker (CLI availability gating)', () => {
'grok',
'deepseek',
'omp',
'copilot',
'shell',
]);
});
+8
View File
@@ -21,6 +21,7 @@ import { isPiAvailable } from '../src/utils/pi-cli-resolver.js';
import { isGrokAvailable } from '../src/utils/grok-cli-resolver.js';
import { isDeepSeekAvailable, isDeepSeekRunnable } from '../src/utils/deepseek-cli-resolver.js';
import { isOmpAvailable } from '../src/utils/omp-cli-resolver.js';
import { isCopilotAvailable } from '../src/utils/copilot-cli-resolver.js';
import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js';
import { isGitAvailable } from '../src/git-clone.js';
import { enabledClis, reloadCliRegistry } from '../src/config/cli-registry/registry.js';
@@ -76,6 +77,10 @@ vi.mock('../src/utils/omp-cli-resolver.js', () => ({
isOmpAvailable: vi.fn(() => false),
resolveOmpDir: vi.fn(() => null),
}));
vi.mock('../src/utils/copilot-cli-resolver.js', () => ({
isCopilotAvailable: vi.fn(() => false),
resolveCopilotDir: vi.fn(() => null),
}));
vi.mock('../src/utils/cloudflared-resolver.js', () => ({
isCloudflaredAvailable: vi.fn(() => false),
resolveCloudflaredPath: vi.fn(() => null),
@@ -207,6 +212,7 @@ describe('WebServer.renderIndexHtml', () => {
vi.mocked(isDeepSeekAvailable).mockReturnValue(false);
vi.mocked(isDeepSeekRunnable).mockReturnValue(false);
vi.mocked(isOmpAvailable).mockReturnValue(true);
vi.mocked(isCopilotAvailable).mockReturnValue(true);
vi.mocked(isCloudflaredAvailable).mockReturnValue(true);
vi.mocked(isGitAvailable).mockReturnValue(true);
const { server } = makeServer({});
@@ -225,6 +231,7 @@ describe('WebServer.renderIndexHtml', () => {
deepseek: false,
deepseekBinary: false,
omp: true,
copilot: true,
cloudflared: true,
git: true,
shell: true,
@@ -365,6 +372,7 @@ describe('WebServer.renderIndexHtml', () => {
isDeepSeekAvailable,
isDeepSeekRunnable,
isOmpAvailable,
isCopilotAvailable,
isCloudflaredAvailable,
isGitAvailable,
]) {
+201
View File
@@ -460,3 +460,204 @@ Here is the assistant answer at column zero.
});
});
});
describe('GitHub Copilot pane dialect', () => {
// Captured from a real Copilot 1.0.95 pane inside tmux (a throwaway COPILOT_HOME, a local
// OpenAI-compatible endpoint): banner, two turns, status rows, composer between rules, footer.
const rule = '─'.repeat(100);
const pane = [
' ╭─╮╭─╮',
' ╰─╯╰─╯ Copilot v1.0.95 uses AI.',
' █ ▘▝ █ Check for mistakes.',
' ▔▔▔▔',
' ! Model "mock" is not in the built-in catalog. Using defaults for: prompt tokens',
' (COPILOT_PROVIDER_MAX_PROMPT_TOKENS), output tokens (COPILOT_PROVIDER_MAX_OUTPUT_TOKENS). Run copilot help',
' providers for configuration details.',
' ● Tip: /theme',
' └ View or set color mode',
' ❯ multi please 11:16',
' ● First line of the answer. ┃',
' ┃',
' Second paragraph with bold text. ┃',
' ┃',
' • bullet one ┃',
' • bullet two ┃',
' ❯ WORKDONE followed by _4711 11:17',
' ● WORKDONE_4711',
' ● MCP Servers reloaded: 1 server connected',
' /tmp/work/.../scratch-2 Session: credits unavailable',
rule,
'❯',
rule,
' ← open sidebar · Interactive · Manual Approval · / commands · ? help · tab next tab mock',
].join('\n');
it('is a transcript mode', () => {
expect(isExternalCliTranscriptMode('copilot')).toBe(true);
});
it('returns the last answer, with no banner, status rows, composer or footer in it', () => {
const blocks = parseExternalCliTranscript(pane, 'copilot');
expect(getLastTranscriptResponse(blocks)).toBe('WORKDONE_4711');
});
it('keeps a multi-paragraph answer whole and strips the prompt timestamp', () => {
const blocks = parseExternalCliTranscript(pane, 'copilot');
const prompts = blocks.filter((b) => b.kind === 'prompt').map((b) => b.text);
expect(prompts).toEqual(['multi please', 'WORKDONE followed by _4711']);
const first = blocks.find((b) => b.kind === 'response')!;
expect(first.text).toContain('First line of the answer.');
expect(first.text).toContain('Second paragraph with bold text.');
expect(first.text).toContain('• bullet two');
expect(first.text).not.toMatch(/Session:|open sidebar|Tip:/);
// The scrollbar gutter Copilot draws at the right edge of every row never reaches the text.
expect(first.text).not.toContain('┃');
});
// The footer and composer every screen below ends with.
const tail = [
' /tmp/work/.../scratch-2 Session: credits unavailable',
rule,
'❯',
rule,
' ← open sidebar · Interactive · Manual Approval · / commands · ? help · tab next tab mock',
];
it("keeps a shell prompt inside an answer's code block as answer text, not a new prompt", () => {
const screen = [
' ❯ how do I run the tests 11:20',
' ● Run this from the repo root:',
'',
' ❯ npm test',
'',
' Then read the summary line.',
...tail,
].join('\n');
const blocks = parseExternalCliTranscript(screen, 'copilot');
expect(blocks.filter((b) => b.kind === 'prompt').map((b) => b.text)).toEqual(['how do I run the tests']);
const answer = getLastTranscriptResponse(blocks);
expect(answer).toContain('Run this from the repo root:');
expect(answer).toContain('❯ npm test');
expect(answer).toContain('Then read the summary line.');
});
it('keeps a paragraph between two rules inside an answer, and treats only the last triple as the composer', () => {
const screen = [
' ❯ quote the passage 11:21',
' ● Here it is:',
rule,
' Quoted passage between two rules.',
rule,
...tail,
].join('\n');
expect(getLastTranscriptResponse(parseExternalCliTranscript(screen, 'copilot'))).toBe(
'Here it is:\n\n---\n\nQuoted passage between two rules.'
);
});
it('keeps an answer whole across a rule inside it, so agent read never returns only its tail', () => {
const screen = [
' ❯ summarize the change 11:22',
' ● Summary',
'',
' One line of summary.',
rule,
' Details',
'',
' The longer explanation.',
...tail,
].join('\n');
const blocks = parseExternalCliTranscript(screen, 'copilot');
expect(blocks.filter((b) => b.kind === 'response')).toHaveLength(1);
const answer = getLastTranscriptResponse(blocks);
expect(answer).toContain('Summary');
expect(answer).toContain('One line of summary.');
expect(answer).toContain('Details');
expect(answer).toContain('The longer explanation.');
// The rule before the footer separates the answer from the chrome and never trails it.
expect(answer.endsWith('---')).toBe(false);
});
it('answers nothing for a session that has only drawn its banner or a trust dialog', () => {
const banner = pane.split('\n').slice(0, 9).join('\n');
expect(getLastTranscriptResponse(parseExternalCliTranscript(banner, 'copilot'))).toBe('');
const trust = [
'│ Do you trust the files in this folder?',
'│ ❯ 1. Yes',
'│ 2. Yes, and remember this folder for future sessions',
].join('\n');
expect(getLastTranscriptResponse(parseExternalCliTranscript(trust, 'copilot'))).toBe('');
});
it('does not take the empty composer glyph for a prompt', () => {
const blocks = parseExternalCliTranscript(pane, 'copilot');
expect(blocks.some((b) => b.kind === 'prompt' && b.text === '')).toBe(false);
});
// The pane is all there is to read, so a long answer scrolls the prompt row (and the banner) off
// the top. That must read as the tail of the answer, not as "nothing yet".
describe('an answer taller than the pane', () => {
const rule = '─'.repeat(100);
const tail = (n: number) =>
Array.from({ length: n }, (_, i) => ` line ${String(i + 1).padStart(3, '0')} of the long answer`);
const footer = [
' /tmp/work/.../scratch-2 Session: 0 AIC used',
rule,
'❯',
rule,
' ← open sidebar · Interactive · Manual Approval · / commands · ? help · tab next tab mock',
];
it('returns the visible rows when the prompt row has scrolled off', () => {
const screen = [...tail(35), ...footer].join('\n');
const text = getLastTranscriptResponse(parseExternalCliTranscript(screen, 'copilot'));
expect(text).toContain('line 001 of the long answer');
expect(text).toContain('line 035 of the long answer');
expect(text).not.toMatch(/Session:|open sidebar|commands/);
});
it('still reads the tail when the answer has a leading bullet row on screen', () => {
const screen = [' ● the answer begins here', ...tail(20), ...footer].join('\n');
const text = getLastTranscriptResponse(parseExternalCliTranscript(screen, 'copilot'));
expect(text).toContain('the answer begins here');
expect(text).toContain('line 020 of the long answer');
});
it('is not fooled by text typed in the composer under the answer', () => {
const typed = [
...tail(30),
' /tmp/work/.../scratch-2 Session: 0 AIC used',
rule,
'❯ half a thought',
rule,
footer[4],
];
const text = getLastTranscriptResponse(parseExternalCliTranscript(typed.join('\n'), 'copilot'));
expect(text).toContain('line 030 of the long answer');
expect(text).not.toContain('half a thought');
});
it('still answers nothing while the banner or a trust dialog is on screen', () => {
const banner = [
' ╭─╮╭─╮',
' ╰─╯╰─╯ Copilot v1.0.95 uses AI.',
' ! Model "mock" is not in the built-in catalog.',
' /tmp/work Session: 0 AIC used',
rule,
'❯',
rule,
footer[4],
].join('\n');
expect(getLastTranscriptResponse(parseExternalCliTranscript(banner, 'copilot'))).toBe('');
});
it('prefers the prompt row when it is on screen', () => {
const screen = [
' ❯ do the thing 11:16',
' ● short answer',
...footer,
].join('\n');
expect(getLastTranscriptResponse(parseExternalCliTranscript(screen, 'copilot'))).toBe('short answer');
});
});
});
@@ -147,6 +147,7 @@ describe('resumeHistorySession: row retirement is gated on actual continuation',
['opencode', 'openCodeConfig'],
['pi', 'piConfig'],
['grok', 'grokConfig'],
['copilot', 'copilotConfig'],
['omp', 'ompConfig'],
])('retires the old row for %s (continueSession is wired via %s)', async (mode, configKey) => {
const app = makeApp();
@@ -104,6 +104,25 @@ describe('clampExternalCliBypassForOwner — multi-user mode', () => {
expect(out.grokConfig).toEqual({ alwaysApprove: false, model: 'grok-4.5' });
});
it("forces a non-granted owner's copilot --yolo off and keeps the model", async () => {
const out = await _clampExternalCliBypassForOwner(
'peon',
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
{ allowAll: true, model: 'claude-sonnet-5.5' }
);
expect(out.copilotConfig).toEqual({ allowAll: false, model: 'claude-sonnet-5.5' });
});
it('leaves copilot absent when nothing was sent (a bare copilot is already Manual Approval)', async () => {
const out = await _clampExternalCliBypassForOwner('peon', undefined, undefined, undefined, undefined, undefined);
expect(out.copilotConfig).toBeUndefined();
});
it("keeps a non-granted owner's codex reasoning effort while forcing bypass off", async () => {
// The clamp rewrites one field and must carry the rest; a clamp rebuilt from named
// fields would drop the effort here without a word.
@@ -14,9 +14,11 @@
* block without it lost the "You" badge on prompts
* - a pane that has produced no output reports hasContext: false rather than 404ing
* - Claude mode still takes the Claude path (regression guard)
* - copilot (a repainting full-screen TUI) is read off the pane SCREEN via
* capturePaneText, and falls back to the buffer only when there is no screen
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, createMockSession, type MockRouteContext } from '../mocks/index.js';
@@ -154,6 +156,65 @@ describe('GET /api/sessions/:id/last-response — external CLI panes', () => {
expect(body.data.hasContext).toBe(false);
});
describe('copilot reads the pane screen, not the stream', () => {
const rule = '─'.repeat(80);
const screenOf = (answer: string) =>
[
' ❯ what changed 11:30',
` ● ${answer}`,
' /tmp/work Session: 0 AIC used',
rule,
'❯',
rule,
' ← open sidebar · Interactive · Manual Approval · / commands · ? help',
].join('\n');
function stubCapture(result: string | null) {
const capturePaneText = vi.fn((_muxName: string) => result);
(harness.ctx.mux as unknown as { capturePaneText: typeof capturePaneText }).capturePaneText = capturePaneText;
return capturePaneText;
}
it('segments the captured screen of the session pane', async () => {
session.mode = 'copilot';
// What the stream looks like for a repainting TUI once escapes are stripped: rows fused
// together, so a reader of the buffer would return this run of text.
session.terminalBuffer = 'STREAM-ONLY fused rows that are not the answer';
const capture = stubCapture(screenOf('The screen answer.'));
const { res, body } = await lastResponse();
expect(res.statusCode).toBe(200);
expect(capture).toHaveBeenCalledWith(session.muxName);
expect(body.data.text).toBe('The screen answer.');
expect(body.data.text).not.toContain('STREAM-ONLY');
expect(body.data.hasContext).toBe(true);
});
it('falls back to the buffer when the pane has no screen to capture', async () => {
session.mode = 'copilot';
session.terminalBuffer = screenOf('The buffer answer.');
const capture = stubCapture(null);
const { res, body } = await lastResponse();
expect(res.statusCode).toBe(200);
expect(capture).toHaveBeenCalledWith(session.muxName);
expect(body.data.text).toBe('The buffer answer.');
});
it('never captures the screen for a mode that streams lines', async () => {
session.mode = 'opencode';
session.terminalBuffer = PANE;
const capture = stubCapture(screenOf('must not be read'));
const { body } = await lastResponse();
expect(capture).not.toHaveBeenCalled();
expect(body.data.text).toContain('Documented the helper in docs/retry.md');
});
});
it('leaves Claude mode on the Claude transcript path', async () => {
// Regression guard: a claude pane must NOT be segmented off its terminal
// buffer, or a real transcript would be shadowed by scraped pane text.
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
// An agent CLI whose Run button has no `mode-<id>` rule falls back to the default button
// colours, so it reads as a different button from every other CLI's. The Run button, its
// gear, the phone toolbar and the light skins each carry their own rule.
const AGENT_IDS = STOCK_CLIS.filter((c) => c.kind === 'agent').map((c) => c.id as string);
describe('Run button colours for every stock agent CLI', () => {
it('lists agent CLIs (anti-vacuity)', () => {
expect(AGENT_IDS).toContain('copilot');
expect(AGENT_IDS.length).toBeGreaterThan(5);
});
for (const id of AGENT_IDS) {
it(`${id} has a Run button rule`, () => {
expect(styles, `${id} has no .btn-run.mode-${id} rule in styles.css`).toContain(
`.btn-toolbar.btn-run.mode-${id}`
);
});
}
});
+48
View File
@@ -303,6 +303,31 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~'.repeat(3) }]);
});
it('pages a Copilot pane when the tracking flag is stale, so the wheel never goes dead', () => {
// Copilot pages its own transcript on PageUp/PageDown (three PageUp moved lines 109-140 to
// 010-042 against a 140-line answer in tmux, and PageDown returned). While it tracks the mouse
// the wheel is forwarded as SGR reports; with the flag stale (a server restart) this is the
// gesture's fallback, on an alternate-screen TUI whose local buffer is one screen.
const { app, sent } = hollowApp({ mode: 'copilot', cliMouseTracking: false });
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true);
app._flushWheelSgrQueue();
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]);
app._maybePageCliTranscript({ shiftKey: false }, 18);
app._flushWheelSgrQueue();
expect(sent[1]).toEqual({ id: 'sess-1', data: '\x1b[6~' });
});
it('does not page a Copilot pane that has real local scrollback', () => {
const { app, sent } = hollowApp({ mode: 'copilot' });
app.terminal.buffer.active.baseY = 200;
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
app._flushWheelSgrQueue();
expect(sent).toEqual([]);
});
it('pages an OpenCode pane too, whose TUI never fills the local buffer', () => {
// OpenCode's TUI runs on the ALTERNATE SCREEN (measured on 1.18.31: tmux
// `alternate_on=1`, `history_size=0`), so the browser's normal buffer stays at
@@ -707,6 +732,29 @@ describe('pageKeysForGesture, the pure gesture rules both panes share', () => {
});
});
describe('GitHub Copilot wheel forwarding', () => {
// Copilot's mouse DECSETs are stripped so a drag selects locally, which stops xterm from
// encoding the wheel for it; the gate must therefore forward by hand, but only while tracking.
it('forwards the wheel while the CLI tracks the mouse, with no version gate', () => {
const { app } = hollowApp({ mode: 'copilot', cliMouseTracking: true });
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true);
});
it('keeps Shift+wheel on local scrollback and falls back to paging when not tracking', () => {
const tracking = hollowApp({ mode: 'copilot', cliMouseTracking: true });
expect(tracking.app._shouldForwardWheelToApp({ shiftKey: true })).toBe(false);
const idle = hollowApp({ mode: 'copilot', cliMouseTracking: false });
expect(idle.app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
});
it('does not change what other non-claude modes do', () => {
for (const mode of ['codex', 'opencode', 'grok', 'gemini']) {
const { app } = hollowApp({ mode, cliMouseTracking: true });
expect(app._shouldForwardWheelToApp({ shiftKey: false }), mode).toBe(false);
}
});
});
describe('the paging gates asked for another pane (a TerminalTile)', () => {
it('exports the paging math, and the primary pane runs on it', () => {
const { app, sent, windowRef } = hollowApp();
+9
View File
@@ -239,6 +239,15 @@ describe('a tile pages a hollow buffer through the primary pane gates', () => {
expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: PAGE_DOWN });
});
it('pages a copilot tile with an empty local buffer (the stale-tracking fallback)', async () => {
const app = makeApp({ other: { mode: 'shell' }, 's-tile': { mode: 'copilot', cliMouseTracking: false } }, 'other');
const { ws, mount } = await connectTile(app, { mode: 'copilot' });
mount.fire('wheel', wheelLines(-12));
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
});
it("reads the TILE's session, not the active one", async () => {
// Active session is a shell; the tile shows opencode: the tile still pages.
const app = makeApp({ other: { mode: 'shell' }, 's-tile': { mode: 'opencode' } }, 'other');