mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
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:
1 parent
de99a6d57a
commit
c5de8383e7
48 files changed
+867
-79
No files matched your search
@@ -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)'),
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -469,6 +469,7 @@ describe('mobile overview run picker (CLI availability gating)', () => {
|
||||
'grok',
|
||||
'deepseek',
|
||||
'omp',
|
||||
'copilot',
|
||||
'shell',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user