feat(cli-registry): GitHub Copilot CLI as a run mode

Copilot (`copilot`, npm @github/copilot) as a stock CLI entry with the same launch options as the other agent CLIs: model (--model), resume (--resume), continue (--continue) and allow-all (--yolo, clamped off for non-granted multi-user owners). Adds a resolver and GET /api/copilot/status probe, the agent-image package, Run-menu/mobile/logo entries, skill docs and tests.
This commit is contained in:
Devvyn committed 2026-10-18 00:47:46 +08:00
1 parent de99a6d57a
commit c5de8383e7
48 files changed
+867 -79

No files matched your search

+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)'),
+47
View File
@@ -320,3 +320,50 @@ 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');
});
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 @@
import { describe, it, expect } from 'vitest';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.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
}
});
});
+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) {
+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);
+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,
]) {
@@ -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.
+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}`
);
});
}
});