oh-my-copilot 5.6.2 → 5.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +2 -0
- package/bridge/__pycache__/gyoshu_bridge.cpython-312.pyc +0 -0
- package/bridge/claude-md-coordinator.cjs +2 -2
- package/bridge/cli.cjs +81405 -76559
- package/bridge/mcp-server.cjs +40048 -32187
- package/bridge/runtime-cli.cjs +943 -199
- package/bridge/team-mcp.cjs +716 -125
- package/bridge/team.js +1066 -290
- package/dist/__tests__/cli-win32-warning.test.js +7 -0
- package/dist/__tests__/cli-win32-warning.test.js.map +1 -1
- package/dist/__tests__/run-cjs-copilot-worker-routing.test.d.ts +2 -0
- package/dist/__tests__/run-cjs-copilot-worker-routing.test.d.ts.map +1 -0
- package/dist/__tests__/run-cjs-copilot-worker-routing.test.js +294 -0
- package/dist/__tests__/run-cjs-copilot-worker-routing.test.js.map +1 -0
- package/dist/__tests__/run-cjs-fail-closed-timeout.test.d.ts +2 -0
- package/dist/__tests__/run-cjs-fail-closed-timeout.test.d.ts.map +1 -0
- package/dist/__tests__/run-cjs-fail-closed-timeout.test.js +68 -0
- package/dist/__tests__/run-cjs-fail-closed-timeout.test.js.map +1 -0
- package/dist/__tests__/run-cjs-session-end-budget.test.d.ts +2 -0
- package/dist/__tests__/run-cjs-session-end-budget.test.d.ts.map +1 -0
- package/dist/__tests__/run-cjs-session-end-budget.test.js +171 -0
- package/dist/__tests__/run-cjs-session-end-budget.test.js.map +1 -0
- package/dist/__tests__/session-end-process-exit.test.js +70 -2
- package/dist/__tests__/session-end-process-exit.test.js.map +1 -1
- package/dist/cli/__tests__/doctor-conflicts-json.test.d.ts +2 -0
- package/dist/cli/__tests__/doctor-conflicts-json.test.d.ts.map +1 -0
- package/dist/cli/__tests__/doctor-conflicts-json.test.js +46 -0
- package/dist/cli/__tests__/doctor-conflicts-json.test.js.map +1 -0
- package/dist/cli/__tests__/team.test.js +67 -0
- package/dist/cli/__tests__/team.test.js.map +1 -1
- package/dist/cli/__tests__/wait-teleport-json.test.d.ts +2 -0
- package/dist/cli/__tests__/wait-teleport-json.test.d.ts.map +1 -0
- package/dist/cli/__tests__/wait-teleport-json.test.js +64 -0
- package/dist/cli/__tests__/wait-teleport-json.test.js.map +1 -0
- package/dist/cli/commands/__tests__/smoke-tier2.test.d.ts +2 -0
- package/dist/cli/commands/__tests__/smoke-tier2.test.d.ts.map +1 -0
- package/dist/cli/commands/__tests__/smoke-tier2.test.js +110 -0
- package/dist/cli/commands/__tests__/smoke-tier2.test.js.map +1 -0
- package/dist/cli/commands/__tests__/team-status-json.test.d.ts +2 -0
- package/dist/cli/commands/__tests__/team-status-json.test.d.ts.map +1 -0
- package/dist/cli/commands/__tests__/team-status-json.test.js +141 -0
- package/dist/cli/commands/__tests__/team-status-json.test.js.map +1 -0
- package/dist/cli/commands/factory.d.ts +2 -0
- package/dist/cli/commands/factory.d.ts.map +1 -1
- package/dist/cli/commands/factory.js +8 -1
- package/dist/cli/commands/factory.js.map +1 -1
- package/dist/cli/commands/smoke.d.ts +36 -0
- package/dist/cli/commands/smoke.d.ts.map +1 -0
- package/dist/cli/commands/smoke.js +169 -0
- package/dist/cli/commands/smoke.js.map +1 -0
- package/dist/cli/commands/team.d.ts +2 -0
- package/dist/cli/commands/team.d.ts.map +1 -1
- package/dist/cli/commands/team.js +124 -10
- package/dist/cli/commands/team.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +70 -10
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/win32-warning.d.ts +7 -1
- package/dist/cli/win32-warning.d.ts.map +1 -1
- package/dist/cli/win32-warning.js +9 -1
- package/dist/cli/win32-warning.js.map +1 -1
- package/dist/constants/names.d.ts +1 -0
- package/dist/constants/names.d.ts.map +1 -1
- package/dist/constants/names.js +1 -0
- package/dist/constants/names.js.map +1 -1
- package/dist/factory/__tests__/check-run.test.js +2 -2
- package/dist/factory/__tests__/check-run.test.js.map +1 -1
- package/dist/factory/__tests__/listener.test.js +1 -1
- package/dist/factory/__tests__/listener.test.js.map +1 -1
- package/dist/factory/__tests__/status.test.js +42 -0
- package/dist/factory/__tests__/status.test.js.map +1 -1
- package/dist/factory/listener.d.ts.map +1 -1
- package/dist/factory/listener.js +7 -8
- package/dist/factory/listener.js.map +1 -1
- package/dist/factory/status.d.ts +19 -1
- package/dist/factory/status.d.ts.map +1 -1
- package/dist/factory/status.js +49 -8
- package/dist/factory/status.js.map +1 -1
- package/dist/factory/watchdog.d.ts.map +1 -1
- package/dist/factory/watchdog.js +14 -0
- package/dist/factory/watchdog.js.map +1 -1
- package/dist/factory/watchdog.test.js +45 -0
- package/dist/factory/watchdog.test.js.map +1 -1
- package/dist/hooks/session-end/__tests__/action-runner.test.js +5 -0
- package/dist/hooks/session-end/__tests__/action-runner.test.js.map +1 -1
- package/dist/hooks/session-end/__tests__/chain-enqueuer.test.js +186 -1
- package/dist/hooks/session-end/__tests__/chain-enqueuer.test.js.map +1 -1
- package/dist/hooks/session-end/__tests__/chain-link-env-passthrough.test.d.ts +2 -0
- package/dist/hooks/session-end/__tests__/chain-link-env-passthrough.test.d.ts.map +1 -0
- package/dist/hooks/session-end/__tests__/chain-link-env-passthrough.test.js +49 -0
- package/dist/hooks/session-end/__tests__/chain-link-env-passthrough.test.js.map +1 -0
- package/dist/hooks/session-end/__tests__/foreground-bootstrap.test.js +30 -1
- package/dist/hooks/session-end/__tests__/foreground-bootstrap.test.js.map +1 -1
- package/dist/hooks/session-end/__tests__/spawn-next.test.js +104 -3
- package/dist/hooks/session-end/__tests__/spawn-next.test.js.map +1 -1
- package/dist/hooks/session-end/__tests__/worker.test.js +6 -0
- package/dist/hooks/session-end/__tests__/worker.test.js.map +1 -1
- package/dist/hooks/session-end/action-runner.d.ts.map +1 -1
- package/dist/hooks/session-end/action-runner.js +2 -1
- package/dist/hooks/session-end/action-runner.js.map +1 -1
- package/dist/hooks/session-end/chain-enqueuer.d.ts +70 -7
- package/dist/hooks/session-end/chain-enqueuer.d.ts.map +1 -1
- package/dist/hooks/session-end/chain-enqueuer.js +354 -82
- package/dist/hooks/session-end/chain-enqueuer.js.map +1 -1
- package/dist/hooks/session-end/foreground-bootstrap.d.ts.map +1 -1
- package/dist/hooks/session-end/foreground-bootstrap.js +24 -16
- package/dist/hooks/session-end/foreground-bootstrap.js.map +1 -1
- package/dist/hooks/session-end/index.d.ts +2 -3
- package/dist/hooks/session-end/index.d.ts.map +1 -1
- package/dist/hooks/session-end/index.js +11 -13
- package/dist/hooks/session-end/index.js.map +1 -1
- package/dist/hooks/session-end/spawn-next.d.ts +81 -4
- package/dist/hooks/session-end/spawn-next.d.ts.map +1 -1
- package/dist/hooks/session-end/spawn-next.js +141 -14
- package/dist/hooks/session-end/spawn-next.js.map +1 -1
- package/dist/hooks/session-end/wiki-foreground-bootstrap.d.ts +17 -0
- package/dist/hooks/session-end/wiki-foreground-bootstrap.d.ts.map +1 -0
- package/dist/hooks/session-end/wiki-foreground-bootstrap.js +20 -0
- package/dist/hooks/session-end/wiki-foreground-bootstrap.js.map +1 -0
- package/dist/hooks/session-end/worker.d.ts.map +1 -1
- package/dist/hooks/session-end/worker.js +11 -6
- package/dist/hooks/session-end/worker.js.map +1 -1
- package/dist/lib/__tests__/atomic-write.test.js +32 -1
- package/dist/lib/__tests__/atomic-write.test.js.map +1 -1
- package/dist/lib/__tests__/mode-state-lock.test.js +121 -12
- package/dist/lib/__tests__/mode-state-lock.test.js.map +1 -1
- package/dist/lib/atomic-write.d.ts +13 -3
- package/dist/lib/atomic-write.d.ts.map +1 -1
- package/dist/lib/atomic-write.js +29 -15
- package/dist/lib/atomic-write.js.map +1 -1
- package/dist/lib/mode-state-io.d.ts +2 -2
- package/dist/lib/mode-state-io.d.ts.map +1 -1
- package/dist/lib/mode-state-io.js +16 -10
- package/dist/lib/mode-state-io.js.map +1 -1
- package/dist/mcp/__tests__/host-smoke-tool.test.d.ts +2 -0
- package/dist/mcp/__tests__/host-smoke-tool.test.d.ts.map +1 -0
- package/dist/mcp/__tests__/host-smoke-tool.test.js +174 -0
- package/dist/mcp/__tests__/host-smoke-tool.test.js.map +1 -0
- package/dist/mcp/disable-tools.d.ts.map +1 -1
- package/dist/mcp/disable-tools.js +1 -0
- package/dist/mcp/disable-tools.js.map +1 -1
- package/dist/mcp/tool-registry.d.ts.map +1 -1
- package/dist/mcp/tool-registry.js +2 -0
- package/dist/mcp/tool-registry.js.map +1 -1
- package/dist/shared/types.d.ts +11 -0
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/smoke/__tests__/copilot-chain-scenario.test.d.ts +2 -0
- package/dist/smoke/__tests__/copilot-chain-scenario.test.d.ts.map +1 -0
- package/dist/smoke/__tests__/copilot-chain-scenario.test.js +154 -0
- package/dist/smoke/__tests__/copilot-chain-scenario.test.js.map +1 -0
- package/dist/smoke/__tests__/copilot-sdk-driver.test.d.ts +2 -0
- package/dist/smoke/__tests__/copilot-sdk-driver.test.d.ts.map +1 -0
- package/dist/smoke/__tests__/copilot-sdk-driver.test.js +591 -0
- package/dist/smoke/__tests__/copilot-sdk-driver.test.js.map +1 -0
- package/dist/smoke/__tests__/copilot-sdk-scenarios.test.d.ts +2 -0
- package/dist/smoke/__tests__/copilot-sdk-scenarios.test.d.ts.map +1 -0
- package/dist/smoke/__tests__/copilot-sdk-scenarios.test.js +362 -0
- package/dist/smoke/__tests__/copilot-sdk-scenarios.test.js.map +1 -0
- package/dist/smoke/__tests__/copilot-session-eval.test.d.ts +2 -0
- package/dist/smoke/__tests__/copilot-session-eval.test.d.ts.map +1 -0
- package/dist/smoke/__tests__/copilot-session-eval.test.js +264 -0
- package/dist/smoke/__tests__/copilot-session-eval.test.js.map +1 -0
- package/dist/smoke/__tests__/copilot-smoke.test.d.ts +2 -0
- package/dist/smoke/__tests__/copilot-smoke.test.d.ts.map +1 -0
- package/dist/smoke/__tests__/copilot-smoke.test.js +482 -0
- package/dist/smoke/__tests__/copilot-smoke.test.js.map +1 -0
- package/dist/smoke/copilot-binary.d.ts +21 -0
- package/dist/smoke/copilot-binary.d.ts.map +1 -0
- package/dist/smoke/copilot-binary.js +46 -0
- package/dist/smoke/copilot-binary.js.map +1 -0
- package/dist/smoke/copilot-chain-scenario.d.ts +61 -0
- package/dist/smoke/copilot-chain-scenario.d.ts.map +1 -0
- package/dist/smoke/copilot-chain-scenario.js +253 -0
- package/dist/smoke/copilot-chain-scenario.js.map +1 -0
- package/dist/smoke/copilot-sdk-driver.d.ts +189 -0
- package/dist/smoke/copilot-sdk-driver.d.ts.map +1 -0
- package/dist/smoke/copilot-sdk-driver.js +637 -0
- package/dist/smoke/copilot-sdk-driver.js.map +1 -0
- package/dist/smoke/copilot-sdk-scenarios.d.ts +200 -0
- package/dist/smoke/copilot-sdk-scenarios.d.ts.map +1 -0
- package/dist/smoke/copilot-sdk-scenarios.js +620 -0
- package/dist/smoke/copilot-sdk-scenarios.js.map +1 -0
- package/dist/smoke/copilot-session-env.d.ts +75 -0
- package/dist/smoke/copilot-session-env.d.ts.map +1 -0
- package/dist/smoke/copilot-session-env.js +154 -0
- package/dist/smoke/copilot-session-env.js.map +1 -0
- package/dist/smoke/copilot-session-eval.d.ts +90 -0
- package/dist/smoke/copilot-session-eval.d.ts.map +1 -0
- package/dist/smoke/copilot-session-eval.js +323 -0
- package/dist/smoke/copilot-session-eval.js.map +1 -0
- package/dist/smoke/copilot-smoke.d.ts +166 -0
- package/dist/smoke/copilot-smoke.d.ts.map +1 -0
- package/dist/smoke/copilot-smoke.js +688 -0
- package/dist/smoke/copilot-smoke.js.map +1 -0
- package/dist/smoke/process-utils.d.ts +47 -0
- package/dist/smoke/process-utils.d.ts.map +1 -0
- package/dist/smoke/process-utils.js +99 -0
- package/dist/smoke/process-utils.js.map +1 -0
- package/dist/team/__tests__/runtime-v2.monitor.test.js +35 -0
- package/dist/team/__tests__/runtime-v2.monitor.test.js.map +1 -1
- package/dist/team/__tests__/scaling.test.js +13 -0
- package/dist/team/__tests__/scaling.test.js.map +1 -1
- package/dist/team/__tests__/sdk-transport.runtime.test.d.ts +2 -0
- package/dist/team/__tests__/sdk-transport.runtime.test.d.ts.map +1 -0
- package/dist/team/__tests__/sdk-transport.runtime.test.js +96 -0
- package/dist/team/__tests__/sdk-transport.runtime.test.js.map +1 -0
- package/dist/team/__tests__/sdk-transport.test.d.ts +2 -0
- package/dist/team/__tests__/sdk-transport.test.d.ts.map +1 -0
- package/dist/team/__tests__/sdk-transport.test.js +650 -0
- package/dist/team/__tests__/sdk-transport.test.js.map +1 -0
- package/dist/team/__tests__/tmux-session.pane-env.test.d.ts +2 -0
- package/dist/team/__tests__/tmux-session.pane-env.test.d.ts.map +1 -0
- package/dist/team/__tests__/tmux-session.pane-env.test.js +52 -0
- package/dist/team/__tests__/tmux-session.pane-env.test.js.map +1 -0
- package/dist/team/api-interop.d.ts.map +1 -1
- package/dist/team/api-interop.js +8 -0
- package/dist/team/api-interop.js.map +1 -1
- package/dist/team/runtime-v2.d.ts +33 -0
- package/dist/team/runtime-v2.d.ts.map +1 -1
- package/dist/team/runtime-v2.js +338 -24
- package/dist/team/runtime-v2.js.map +1 -1
- package/dist/team/scaling.d.ts.map +1 -1
- package/dist/team/scaling.js +5 -0
- package/dist/team/scaling.js.map +1 -1
- package/dist/team/sdk-host.d.ts +48 -0
- package/dist/team/sdk-host.d.ts.map +1 -0
- package/dist/team/sdk-host.js +604 -0
- package/dist/team/sdk-host.js.map +1 -0
- package/dist/team/sdk-policy.d.ts +79 -0
- package/dist/team/sdk-policy.d.ts.map +1 -0
- package/dist/team/sdk-policy.js +186 -0
- package/dist/team/sdk-policy.js.map +1 -0
- package/dist/team/sdk-transport.d.ts +164 -0
- package/dist/team/sdk-transport.d.ts.map +1 -0
- package/dist/team/sdk-transport.js +266 -0
- package/dist/team/sdk-transport.js.map +1 -0
- package/dist/team/tmux-session.d.ts +7 -0
- package/dist/team/tmux-session.d.ts.map +1 -1
- package/dist/team/tmux-session.js +80 -18
- package/dist/team/tmux-session.js.map +1 -1
- package/dist/team/types.d.ts +2 -0
- package/dist/team/types.d.ts.map +1 -1
- package/dist/team/types.js.map +1 -1
- package/dist/tools/host-smoke-tool.d.ts +34 -0
- package/dist/tools/host-smoke-tool.d.ts.map +1 -0
- package/dist/tools/host-smoke-tool.js +133 -0
- package/dist/tools/host-smoke-tool.js.map +1 -0
- package/docs/CLAUDE.md +1 -1
- package/docs/DEVELOPERS.md +122 -0
- package/docs/HOOKS.md +34 -2
- package/docs/MIGRATION.md +194 -3
- package/docs/REFERENCE.md +95 -1
- package/docs/TOOLS.md +32 -1
- package/package.json +11 -2
- package/plugin.json +1 -1
- package/scripts/dev/repro-state-lock.mjs +1 -1
- package/scripts/lib/atomic-write.mjs +4 -2
- package/scripts/lib/state-lock.mjs +21 -12
- package/scripts/run.cjs +141 -18
- package/scripts/session-start.mjs +13 -0
- package/scripts/wiki-session-end.mjs +2 -1
- package/templates/hooks/lib/atomic-write.mjs +4 -2
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { randomUUID } from 'crypto';
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, appendFileSync } from 'fs';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
7
|
+
import { buildSdkExcludedTools, decideSdkPermission } from '../sdk-policy.js';
|
|
8
|
+
import { prependPath, readDoorbell, runSdkHost } from '../sdk-host.js';
|
|
9
|
+
import { isSdkTarget, launchSdkHost, readSdkSession, ringDoorbell, sdkPaneId, sdkWorkerFiles, stopSdkHost, } from '../sdk-transport.js';
|
|
10
|
+
import { awaitWorkerLaunchAcknowledgement, loadWorkerLaunchAttempt, observeWorkerLaunchProvider, prepareWorkerLaunchAttempt, retireAndCleanupCurrentWorkerLaunchAttempt, } from '../worker-launch-ack.js';
|
|
11
|
+
import { teamStateRoot } from '../state-paths.js';
|
|
12
|
+
import { getProcessStartIdentitySync, isProcessAlive } from '../../platform/process-utils.js';
|
|
13
|
+
let TEAM = 'sdk-team';
|
|
14
|
+
const WORKER = 'worker-1';
|
|
15
|
+
let cwd;
|
|
16
|
+
let worktree;
|
|
17
|
+
let pluginRoot;
|
|
18
|
+
let stateRoot;
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
// The state root does not follow a temp cwd under the test config: keep each test's team distinct.
|
|
21
|
+
TEAM = `sdk-team-${randomUUID().slice(0, 8)}`;
|
|
22
|
+
cwd = mkdtempSync(join(tmpdir(), 'sdk-team-leader-'));
|
|
23
|
+
worktree = mkdtempSync(join(tmpdir(), 'sdk-team-wt-'));
|
|
24
|
+
pluginRoot = mkdtempSync(join(tmpdir(), 'sdk-team-plugin-'));
|
|
25
|
+
stateRoot = teamStateRoot(cwd, TEAM);
|
|
26
|
+
mkdirSync(join(stateRoot, 'workers', WORKER), { recursive: true });
|
|
27
|
+
});
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
for (const dir of [cwd, worktree, pluginRoot, stateRoot])
|
|
30
|
+
rmSync(dir, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Policy
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
describe('sdk permission policy', () => {
|
|
36
|
+
const ctx = () => ({
|
|
37
|
+
cwd: worktree,
|
|
38
|
+
stateRoot,
|
|
39
|
+
pluginRoot,
|
|
40
|
+
mcpServer: 't',
|
|
41
|
+
denyTools: ['shell(rm -rf)', 'web_fetch', 't(state_clear)', 'write'],
|
|
42
|
+
denyUrls: ['evil.example'],
|
|
43
|
+
platform: process.platform,
|
|
44
|
+
});
|
|
45
|
+
const cases = [
|
|
46
|
+
["{ kind: 'read', path: join(worktree, 'README.md') }", () => ({ kind: 'read', path: join(worktree, 'README.md') }), true],
|
|
47
|
+
["{ kind: 'read', path: join(stateRoot, 'workers', WORKER, 'inbox.md') }", () => ({ kind: 'read', path: join(stateRoot, 'workers', WORKER, 'inbox.md') }), true],
|
|
48
|
+
["{ kind: 'read', path: join(pluginRoot, 'skills', 'plan', 'SKILL.md') }", () => ({ kind: 'read', path: join(pluginRoot, 'skills', 'plan', 'SKILL.md') }), true],
|
|
49
|
+
["{ kind: 'read', path: join(tmpdir(), 'elsewhere.txt') }", () => ({ kind: 'read', path: join(tmpdir(), 'elsewhere.txt') }), false],
|
|
50
|
+
["{ kind: 'write', fileName: 'README.md' }", () => ({ kind: 'write', fileName: 'README.md' }), true], // relative: resolved against the worktree
|
|
51
|
+
["{ kind: 'write', fileName: join(stateRoot, 'workers', WORKER, 'status.", () => ({ kind: 'write', fileName: join(stateRoot, 'workers', WORKER, 'status.json') }), true],
|
|
52
|
+
["{ kind: 'write', fileName: join(cwd, 'README.md') }", () => ({ kind: 'write', fileName: join(cwd, 'README.md') }), false], // the leader checkout is not the worktree
|
|
53
|
+
["{ kind: 'write' }", () => ({ kind: 'write' }), false],
|
|
54
|
+
["{ kind: 'shell', fullCommandText: 'omg team api claim-task --input \\'{", () => ({ kind: 'shell', fullCommandText: 'omg team api claim-task --input \'{}\' --json' }), true],
|
|
55
|
+
["{ kind: 'shell', fullCommandText: 'git add README.md && git commit -m ", () => ({ kind: 'shell', fullCommandText: 'git add README.md && git commit -m "x"' }), true],
|
|
56
|
+
["{ kind: 'shell', fullCommandText: 'omg team shutdown sdk-team' }", () => ({ kind: 'shell', fullCommandText: 'omg team shutdown sdk-team' }), false],
|
|
57
|
+
["{ kind: 'shell', fullCommandText: 'omg team 2:copilot \"nested\"' }", () => ({ kind: 'shell', fullCommandText: 'omg team 2:copilot "nested"' }), false],
|
|
58
|
+
["{ kind: 'shell', fullCommandText: 'node \"C:/x/bridge/cli.cjs\" team shu", () => ({ kind: 'shell', fullCommandText: 'node "C:/x/bridge/cli.cjs" team shutdown t' }), false],
|
|
59
|
+
["{ kind: 'shell', fullCommandText: 'omg smoke copilot --tier 2' }", () => ({ kind: 'shell', fullCommandText: 'omg smoke copilot --tier 2' }), false],
|
|
60
|
+
["{ kind: 'shell', fullCommandText: 'tmux kill-server' }", () => ({ kind: 'shell', fullCommandText: 'tmux kill-server' }), false],
|
|
61
|
+
["{ kind: 'shell', fullCommandText: 'git -c a=b push origin HEAD' }", () => ({ kind: 'shell', fullCommandText: 'git -c a=b push origin HEAD' }), false],
|
|
62
|
+
["{ kind: 'shell', fullCommandText: 'rm -rf /' }", () => ({ kind: 'shell', fullCommandText: 'rm -rf /' }), false], // workerDenyTools shell(rm -rf)
|
|
63
|
+
["{ kind: 'shell', fullCommandText: '' }", () => ({ kind: 'shell', fullCommandText: '' }), false],
|
|
64
|
+
["{ kind: 'url', url: 'https://docs.github.com/x' }", () => ({ kind: 'url', url: 'https://docs.github.com/x' }), true],
|
|
65
|
+
["{ kind: 'url', url: 'https://api.evil.example/x' }", () => ({ kind: 'url', url: 'https://api.evil.example/x' }), false],
|
|
66
|
+
["{ kind: 'mcp', serverName: 't', toolName: 'state_read' }", () => ({ kind: 'mcp', serverName: 't', toolName: 'state_read' }), true],
|
|
67
|
+
["{ kind: 'mcp', serverName: 'github-mcp-server', toolName: 'x' }", () => ({ kind: 'mcp', serverName: 'github-mcp-server', toolName: 'x' }), false],
|
|
68
|
+
["{ kind: 'memory' }", () => ({ kind: 'memory' }), false],
|
|
69
|
+
["{ kind: 'custom-tool' }", () => ({ kind: 'custom-tool' }), false],
|
|
70
|
+
["{ kind: 'hook' }", () => ({ kind: 'hook' }), false],
|
|
71
|
+
];
|
|
72
|
+
it.each(cases)('%s -> approve=%s', (_label, req, approve) => {
|
|
73
|
+
expect(decideSdkPermission(req(), ctx()).approve).toBe(approve);
|
|
74
|
+
});
|
|
75
|
+
// Review cases: quoted tokens, doubled whitespace, variable indirection, and
|
|
76
|
+
// the false positives of a loose `git ... push` match. The policy is a
|
|
77
|
+
// denylist, not a sandbox (see SDK_SHELL_DENY_PATTERNS); these are the
|
|
78
|
+
// spellings it is expected to catch or to leave alone.
|
|
79
|
+
const shellCases = [
|
|
80
|
+
['"omg" team shutdown x', false],
|
|
81
|
+
["omg 'team' shutdown", false],
|
|
82
|
+
['node bridge/cli.cjs "team" start', false],
|
|
83
|
+
['"git" push', false],
|
|
84
|
+
['$g="git"; & $g push', false],
|
|
85
|
+
['g=git; $g push origin', false],
|
|
86
|
+
['"tmux" kill-server', false],
|
|
87
|
+
['"omg" smoke', false],
|
|
88
|
+
['git -C ../wt push', false],
|
|
89
|
+
['git --no-pager push', false],
|
|
90
|
+
['cmd /c "git push"', false],
|
|
91
|
+
['git add . && git push', false],
|
|
92
|
+
['"rm" -rf /', false], // workerDenyTools shell(rm -rf) after quote stripping
|
|
93
|
+
['git commit -m "push the thing"', true],
|
|
94
|
+
['git commit -m push', true],
|
|
95
|
+
['git log --grep push', true],
|
|
96
|
+
['omg team api claim-task --json', true],
|
|
97
|
+
['omg team api read-task --json', true],
|
|
98
|
+
['echo pushd', true],
|
|
99
|
+
];
|
|
100
|
+
it.each(shellCases)('shell %s -> approve=%s', (command, approve) => {
|
|
101
|
+
expect(decideSdkPermission({ kind: 'shell', fullCommandText: command }, ctx()).approve).toBe(approve);
|
|
102
|
+
});
|
|
103
|
+
it('excludes the recursion fence and maps workerDenyTools to SDK tool names', () => {
|
|
104
|
+
expect(buildSdkExcludedTools('t', ['shell(rm -rf)', 'web_fetch', 't(state_clear)', 'write']).sort())
|
|
105
|
+
.toEqual(['create', 'edit', 't-host_smoke', 't-state_clear', 'task', 'web_fetch',
|
|
106
|
+
'run_dynamic_workflow', 'dynamic_workflows_manage', 'write_agent', 'read_agent', 'list_agents',
|
|
107
|
+
'search_code_subagent'].sort());
|
|
108
|
+
expect(buildSdkExcludedTools('t', ['shell'])).toEqual(expect.arrayContaining(['shell', 'powershell']));
|
|
109
|
+
});
|
|
110
|
+
it('removes every sub-agent / workflow tool even with an empty deny list', () => {
|
|
111
|
+
expect(buildSdkExcludedTools('t')).toEqual(expect.arrayContaining([
|
|
112
|
+
'task', 'run_dynamic_workflow', 'dynamic_workflows_manage', 'write_agent', 'read_agent', 'list_agents',
|
|
113
|
+
'search_code_subagent', 't-host_smoke',
|
|
114
|
+
]));
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Doorbell + ids
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
describe('doorbell', () => {
|
|
121
|
+
it('reads complete lines only and resumes from the returned offset', () => {
|
|
122
|
+
const { doorbell } = sdkWorkerFiles(stateRoot, WORKER);
|
|
123
|
+
const a = ringDoorbell(stateRoot, WORKER, { kind: 'prompt', text: 'one' });
|
|
124
|
+
appendFileSync(doorbell, '{"kind":"prompt","id":"torn"'); // a writer mid-line
|
|
125
|
+
const first = readDoorbell(doorbell, 0);
|
|
126
|
+
expect(first.entries.map((e) => e.id)).toEqual([a]);
|
|
127
|
+
appendFileSync(doorbell, ',"text":"two","at":"x"}\nnot json\n');
|
|
128
|
+
const second = readDoorbell(doorbell, first.offset);
|
|
129
|
+
expect(second.entries.map((e) => e.id)).toEqual(['torn']);
|
|
130
|
+
expect(readDoorbell(doorbell, second.offset).entries).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
it('marks sdk targets and worker ids', () => {
|
|
133
|
+
expect(isSdkTarget('sdk:t')).toBe(true);
|
|
134
|
+
expect(isSdkTarget(sdkPaneId('worker-2'))).toBe(true);
|
|
135
|
+
expect(isSdkTarget('%3')).toBe(false);
|
|
136
|
+
expect(isSdkTarget(undefined)).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
it('prepends the omg shim dir to PATH under the existing key casing', () => {
|
|
139
|
+
const env = { Path: 'C:\\a' };
|
|
140
|
+
prependPath(env, 'C:\\shim', 'win32');
|
|
141
|
+
expect(env.Path).toBe('C:\\shim;C:\\a');
|
|
142
|
+
const posix = { PATH: '/a' };
|
|
143
|
+
prependPath(posix, '/shim', 'linux');
|
|
144
|
+
expect(posix.PATH).toBe('/shim:/a');
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
function fakeSdk(opts = {}) {
|
|
148
|
+
const rec = { ops: [], sessions: [], sentWhileBusy: false, prompts: [] };
|
|
149
|
+
class FakeClient {
|
|
150
|
+
cliProcess = opts.pid ? { pid: opts.pid } : null;
|
|
151
|
+
constructor(options) { rec.connection = options.connection; }
|
|
152
|
+
async start() { rec.ops.push('start'); if (opts.startError)
|
|
153
|
+
throw new Error(opts.startError); }
|
|
154
|
+
async stop() { rec.ops.push('stop'); if (opts.pid)
|
|
155
|
+
opts.alive?.delete(opts.pid); this.cliProcess = null; return []; }
|
|
156
|
+
async forceStop() { rec.ops.push('forceStop'); if (opts.pid)
|
|
157
|
+
opts.alive?.delete(opts.pid); this.cliProcess = null; }
|
|
158
|
+
async getStatus() { return { version: '1.0.91', protocolVersion: 3 }; }
|
|
159
|
+
async listModels() { return [{ id: 'auto' }]; }
|
|
160
|
+
async deleteSession(id) { rec.ops.push(`delete:${id}`); }
|
|
161
|
+
async createSession(config) {
|
|
162
|
+
rec.ops.push('create');
|
|
163
|
+
rec.sessions.push(config);
|
|
164
|
+
const emit = (e) => config.onEvent?.(e);
|
|
165
|
+
let busy = false;
|
|
166
|
+
let timer;
|
|
167
|
+
const idle = (aborted = false) => { busy = false; emit({ type: 'session.idle', data: aborted ? { aborted } : {} }); };
|
|
168
|
+
return {
|
|
169
|
+
sessionId: 's1',
|
|
170
|
+
async send({ prompt }) {
|
|
171
|
+
rec.ops.push('send');
|
|
172
|
+
if (busy)
|
|
173
|
+
rec.sentWhileBusy = true;
|
|
174
|
+
busy = true;
|
|
175
|
+
rec.prompts.push(prompt);
|
|
176
|
+
for (const req of opts.permissions ?? []) {
|
|
177
|
+
await config.onPermissionRequest(req, { sessionId: 's1' });
|
|
178
|
+
}
|
|
179
|
+
emit({ type: 'assistant.usage', data: { initiator: 'user', copilotUsage: { totalNanoAiu: (opts.credits ?? 0.5) * 1e9 } } });
|
|
180
|
+
emit({ type: 'assistant.message', data: { content: 'ok' } });
|
|
181
|
+
if (!opts.hang)
|
|
182
|
+
timer = setTimeout(() => idle(), opts.turnMs ?? 30);
|
|
183
|
+
return 'm1';
|
|
184
|
+
},
|
|
185
|
+
async abort() {
|
|
186
|
+
rec.ops.push('abort');
|
|
187
|
+
if (timer)
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
setImmediate(() => { emit({ type: 'abort', data: { reason: 'user_initiated' } }); idle(true); });
|
|
190
|
+
},
|
|
191
|
+
async disconnect() {
|
|
192
|
+
rec.ops.push('disconnect');
|
|
193
|
+
setImmediate(() => emit({ type: 'session.shutdown', data: { totalPremiumRequests: rec.prompts.length, totalNanoAiu: 1.5e9 } }));
|
|
194
|
+
},
|
|
195
|
+
rpc: {
|
|
196
|
+
plugins: { list: async () => ({}) },
|
|
197
|
+
skills: { list: async () => ({}) },
|
|
198
|
+
agent: { list: async () => ({}) },
|
|
199
|
+
mcp: { list: async () => ({}), listTools: async () => ({}) },
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const module = {
|
|
205
|
+
CopilotClient: FakeClient,
|
|
206
|
+
RuntimeConnection: { forStdio: (o) => o },
|
|
207
|
+
};
|
|
208
|
+
return { loaded: { module, version: '1.0.16', from: 'fake' }, rec };
|
|
209
|
+
}
|
|
210
|
+
async function newAttempt() {
|
|
211
|
+
return prepareWorkerLaunchAttempt({
|
|
212
|
+
cwd,
|
|
213
|
+
teamName: TEAM,
|
|
214
|
+
workerName: WORKER,
|
|
215
|
+
instanceId: randomUUID(),
|
|
216
|
+
paneId: sdkPaneId(WORKER),
|
|
217
|
+
provider: 'copilot',
|
|
218
|
+
runtimeCliPath: '/runtime-cli.cjs',
|
|
219
|
+
context: { kind: 'initial' },
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
function specFor(attempt, over = {}) {
|
|
223
|
+
return {
|
|
224
|
+
schema_version: 1,
|
|
225
|
+
leader_cwd: cwd,
|
|
226
|
+
team_name: TEAM,
|
|
227
|
+
worker_name: WORKER,
|
|
228
|
+
instance_id: attempt.instance_id,
|
|
229
|
+
attempt_id: attempt.attempt_id,
|
|
230
|
+
pane_id: attempt.pane_id,
|
|
231
|
+
runtime_cli_path: '/runtime-cli.cjs',
|
|
232
|
+
worker_cwd: worktree,
|
|
233
|
+
state_root: stateRoot,
|
|
234
|
+
plugin_root: pluginRoot,
|
|
235
|
+
copilot_bin: 'C:/fake/copilot.exe',
|
|
236
|
+
model: 'gpt-test',
|
|
237
|
+
max_credits: 10,
|
|
238
|
+
worker_env: { OMC_TEAM_WORKER: `${TEAM}/${WORKER}`, OMC_WORKER_LAUNCH_ATTEMPT_ID: attempt.attempt_id },
|
|
239
|
+
deny_tools: ['web_fetch'],
|
|
240
|
+
deny_urls: [],
|
|
241
|
+
mcp_server: 't',
|
|
242
|
+
omg_cli_path: '/bridge/cli.cjs',
|
|
243
|
+
node_path: process.execPath,
|
|
244
|
+
user_config_dir: join(cwd, 'no-user-config'),
|
|
245
|
+
...over,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
function deps(sdk, over = {}) {
|
|
249
|
+
const alive = over.alive ?? new Set();
|
|
250
|
+
return {
|
|
251
|
+
loadSdk: async () => sdk,
|
|
252
|
+
pid: 4242,
|
|
253
|
+
startIdentity: () => 'ticks:123456',
|
|
254
|
+
isAlive: (pid) => alive.has(pid),
|
|
255
|
+
killTree: (pid) => { over.killed?.push(pid); alive.delete(pid); },
|
|
256
|
+
// The terminal record's shape follows the real platform, which the shared observers read.
|
|
257
|
+
platform: process.platform,
|
|
258
|
+
pollMs: 10,
|
|
259
|
+
env: { [process.platform === 'win32' ? 'Path' : 'PATH']: '/usr/bin', OMC_STRIPPED: '1', COPILOT_HOME: '/real-home' },
|
|
260
|
+
...over,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
/** Run the host while the test plays leader: accept the attempt, then drive the doorbell. */
|
|
264
|
+
async function hostWithLeader(attempt, hostDeps, drive, spec = specFor(attempt)) {
|
|
265
|
+
const host = runSdkHost(spec, hostDeps);
|
|
266
|
+
const ack = await awaitWorkerLaunchAcknowledgement(attempt, { timeoutMs: 5_000, pollIntervalMs: 5 });
|
|
267
|
+
expect(ack).toEqual({ ok: true });
|
|
268
|
+
await drive();
|
|
269
|
+
return host;
|
|
270
|
+
}
|
|
271
|
+
async function until(cond, timeoutMs = 5_000) {
|
|
272
|
+
const deadline = Date.now() + timeoutMs;
|
|
273
|
+
while (!cond()) {
|
|
274
|
+
if (Date.now() > deadline)
|
|
275
|
+
throw new Error('timed out');
|
|
276
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
describe('sdk host', () => {
|
|
280
|
+
it('handshakes as the launch provider, serializes doorbell prompts, acks shutdown and tears down', async () => {
|
|
281
|
+
const attempt = await newAttempt();
|
|
282
|
+
const alive = new Set([777]);
|
|
283
|
+
const { loaded, rec } = fakeSdk({ pid: 777, alive, turnMs: 60, permissions: [
|
|
284
|
+
{ kind: 'write', fileName: join(cwd, 'README.md') },
|
|
285
|
+
{ kind: 'shell', fullCommandText: 'omg team api claim-task --json' },
|
|
286
|
+
] });
|
|
287
|
+
const killed = [];
|
|
288
|
+
const host = await hostWithLeader(attempt, deps(loaded, { alive, killed }), async () => {
|
|
289
|
+
await until(() => readSdkSession(stateRoot, WORKER)?.state === 'idle');
|
|
290
|
+
ringDoorbell(stateRoot, WORKER, { kind: 'prompt', text: 'first' });
|
|
291
|
+
ringDoorbell(stateRoot, WORKER, { kind: 'prompt', text: 'second' });
|
|
292
|
+
await until(() => rec.prompts.length === 2 && readSdkSession(stateRoot, WORKER)?.state === 'idle');
|
|
293
|
+
ringDoorbell(stateRoot, WORKER, { kind: 'shutdown' });
|
|
294
|
+
});
|
|
295
|
+
expect(await host).toBe('stopped');
|
|
296
|
+
// The second prompt waited for the first turn's idle.
|
|
297
|
+
expect(rec.prompts).toEqual(['first', 'second']);
|
|
298
|
+
expect(rec.sentWhileBusy).toBe(false);
|
|
299
|
+
// Session config: plugin, worktree, fence, no github MCP, explicit model.
|
|
300
|
+
const cfg = rec.sessions[0];
|
|
301
|
+
expect(cfg.pluginDirectories).toEqual([pluginRoot]);
|
|
302
|
+
expect(cfg.workingDirectory).toBe(worktree);
|
|
303
|
+
expect(cfg.disabledMcpServers).toEqual(['github-mcp-server']);
|
|
304
|
+
expect(cfg.excludedTools).toEqual(expect.arrayContaining(['t-host_smoke', 'task', 'web_fetch']));
|
|
305
|
+
expect(cfg.model).toBe('gpt-test');
|
|
306
|
+
// Runtime: installed exe, credit cap floor, deny flag, no allow-all, worker env, isolated home.
|
|
307
|
+
expect(rec.connection?.path).toBe('C:/fake/copilot.exe');
|
|
308
|
+
expect(rec.connection?.args).toEqual(['--log-level', 'debug', '--max-ai-credits', '30', '--deny-tool=web_fetch']);
|
|
309
|
+
const env = rec.connection.env;
|
|
310
|
+
expect(env.COPILOT_ALLOW_ALL).toBe('false');
|
|
311
|
+
expect(env.OMC_GIT_GUARDRAILS).toBe('1');
|
|
312
|
+
expect(env.OMC_TEAM_WORKER).toBe(`${TEAM}/${WORKER}`);
|
|
313
|
+
expect(env.OMC_STRIPPED).toBeUndefined();
|
|
314
|
+
expect(env.COPILOT_HOME).toBe(sdkWorkerFiles(stateRoot, WORKER).home);
|
|
315
|
+
const pathKey = Object.keys(env).find((k) => k.toUpperCase() === 'PATH');
|
|
316
|
+
expect(env[pathKey]?.startsWith(sdkWorkerFiles(stateRoot, WORKER).bin)).toBe(true);
|
|
317
|
+
expect(existsSync(join(sdkWorkerFiles(stateRoot, WORKER).bin, 'omg.cmd'))).toBe(true);
|
|
318
|
+
// Graceful teardown ladder, no tree kill needed.
|
|
319
|
+
const order = ['disconnect', 'delete:s1', 'stop'].map((op) => rec.ops.indexOf(op));
|
|
320
|
+
expect(order.every((i) => i >= 0)).toBe(true);
|
|
321
|
+
expect([...order].sort((a, b) => a - b)).toEqual(order);
|
|
322
|
+
expect(rec.ops).not.toContain('forceStop');
|
|
323
|
+
expect(killed).toEqual([]);
|
|
324
|
+
// Evidence: ack, session file, events, permissions, billing totals.
|
|
325
|
+
const ackFile = JSON.parse(readFileSync(sdkWorkerFiles(stateRoot, WORKER).shutdownAck, 'utf8'));
|
|
326
|
+
expect(ackFile.status).toBe('accept');
|
|
327
|
+
const session = readSdkSession(stateRoot, WORKER);
|
|
328
|
+
expect(session.state).toBe('closed');
|
|
329
|
+
expect(session.turns).toBe(2);
|
|
330
|
+
expect(session.usage.premium_requests).toBe(2);
|
|
331
|
+
expect(session.usage.shutdown_premium_requests).toBe(2);
|
|
332
|
+
expect(session.usage.credits).toBeCloseTo(1);
|
|
333
|
+
expect(session.permissions.map((p) => [p.kind, p.approve])).toEqual([
|
|
334
|
+
['write', false], ['shell', true], ['write', false], ['shell', true],
|
|
335
|
+
]);
|
|
336
|
+
expect(session.timeline.first_send_at).toBeDefined();
|
|
337
|
+
const events = readFileSync(sdkWorkerFiles(stateRoot, WORKER).events, 'utf8').trim().split('\n');
|
|
338
|
+
expect(events.length).toBeGreaterThanOrEqual(7);
|
|
339
|
+
// Provider records: the host's identity, then a reaped terminal the shared observers read as dead.
|
|
340
|
+
const started = JSON.parse(readFileSync(attempt.startedPath, 'utf8'));
|
|
341
|
+
expect(started).toMatchObject({ kind: 'worker_launch_provider_started', pid: 4242, process_start_identity: 'ticks:123456' });
|
|
342
|
+
expect(await observeWorkerLaunchProvider(attempt)).toBe('dead');
|
|
343
|
+
expect(await retireAndCleanupCurrentWorkerLaunchAttempt(attempt, 'test', async () => true)).toBe(true);
|
|
344
|
+
expect(existsSync(attempt.currentPath)).toBe(false);
|
|
345
|
+
});
|
|
346
|
+
it('a termination request hard-stops: tree-kills the runtime and publishes termination-complete', async () => {
|
|
347
|
+
const attempt = await newAttempt();
|
|
348
|
+
const { loaded, rec } = fakeSdk({ pid: 888 });
|
|
349
|
+
const alive = new Set([888]);
|
|
350
|
+
const killed = [];
|
|
351
|
+
const host = await hostWithLeader(attempt, deps(loaded, { alive, killed }), async () => {
|
|
352
|
+
await until(() => readSdkSession(stateRoot, WORKER)?.state === 'idle');
|
|
353
|
+
writeFileSync(`${attempt.startedPath}.termination-request`, '{}');
|
|
354
|
+
});
|
|
355
|
+
expect(await host).toBe('terminated');
|
|
356
|
+
expect(killed).toEqual([888]);
|
|
357
|
+
expect(rec.ops).toContain('forceStop');
|
|
358
|
+
expect(rec.ops).not.toContain('disconnect');
|
|
359
|
+
const complete = JSON.parse(readFileSync(`${attempt.startedPath}.termination-complete`, 'utf8'));
|
|
360
|
+
expect(complete).toMatchObject({ kind: 'worker_launch_termination_complete', cleanup_verified: true, pid: 4242 });
|
|
361
|
+
});
|
|
362
|
+
it('aborts the turn and stops sending once the credit cap is passed', async () => {
|
|
363
|
+
const attempt = await newAttempt();
|
|
364
|
+
const { loaded, rec } = fakeSdk({ credits: 6, hang: true });
|
|
365
|
+
const host = await hostWithLeader(attempt, deps(loaded), async () => {
|
|
366
|
+
await until(() => readSdkSession(stateRoot, WORKER)?.state === 'idle');
|
|
367
|
+
ringDoorbell(stateRoot, WORKER, { kind: 'prompt', text: 'a' });
|
|
368
|
+
await until(() => rec.ops.includes('abort') === false && rec.prompts.length === 1);
|
|
369
|
+
// 6 credits < cap 10: still busy (hang). Abort via doorbell, then a second prompt pushes past the cap.
|
|
370
|
+
ringDoorbell(stateRoot, WORKER, { kind: 'abort' });
|
|
371
|
+
await until(() => readSdkSession(stateRoot, WORKER)?.state === 'idle');
|
|
372
|
+
ringDoorbell(stateRoot, WORKER, { kind: 'prompt', text: 'b' });
|
|
373
|
+
await until(() => readSdkSession(stateRoot, WORKER)?.state === 'capped');
|
|
374
|
+
ringDoorbell(stateRoot, WORKER, { kind: 'prompt', text: 'c' });
|
|
375
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
376
|
+
ringDoorbell(stateRoot, WORKER, { kind: 'stop' });
|
|
377
|
+
});
|
|
378
|
+
expect(await host).toBe('stopped');
|
|
379
|
+
expect(rec.prompts).toEqual(['a', 'b']);
|
|
380
|
+
const session = readSdkSession(stateRoot, WORKER);
|
|
381
|
+
expect(session.last_error).toMatch(/credit cap 10 reached/);
|
|
382
|
+
expect(session.aborts).toBeGreaterThanOrEqual(2);
|
|
383
|
+
});
|
|
384
|
+
it('a missing SDK fails the launch with a terminal record and an install hint', async () => {
|
|
385
|
+
const attempt = await newAttempt();
|
|
386
|
+
const host = await hostWithLeader(attempt, deps(null), async () => { });
|
|
387
|
+
expect(await host).toBe('sdk_missing');
|
|
388
|
+
const session = readSdkSession(stateRoot, WORKER);
|
|
389
|
+
expect(session.state).toBe('failed');
|
|
390
|
+
expect(session.last_error).toMatch(/npm i -g @github\/copilot-sdk/);
|
|
391
|
+
expect(await observeWorkerLaunchProvider(attempt)).toBe('dead');
|
|
392
|
+
});
|
|
393
|
+
it('a runtime that fails to start is reported and its tree is killed', async () => {
|
|
394
|
+
const attempt = await newAttempt();
|
|
395
|
+
const { loaded } = fakeSdk({ pid: 999, startError: 'spawn EACCES' });
|
|
396
|
+
const alive = new Set([999]);
|
|
397
|
+
const killed = [];
|
|
398
|
+
const host = await hostWithLeader(attempt, deps(loaded, { alive, killed }), async () => { });
|
|
399
|
+
expect(await host).toBe('start_failed');
|
|
400
|
+
expect(killed).toEqual([999]);
|
|
401
|
+
expect(readSdkSession(stateRoot, WORKER)?.last_error).toBe('spawn EACCES');
|
|
402
|
+
});
|
|
403
|
+
it('exits when the team state root disappears (orphan guard)', async () => {
|
|
404
|
+
const attempt = await newAttempt();
|
|
405
|
+
const { loaded } = fakeSdk();
|
|
406
|
+
const spec = specFor(attempt, { state_root: join(stateRoot, 'gone-root') });
|
|
407
|
+
mkdirSync(spec.state_root, { recursive: true });
|
|
408
|
+
const host = await hostWithLeader(attempt, deps(loaded), async () => {
|
|
409
|
+
await until(() => existsSync(join(spec.state_root, 'workers', WORKER, 'sdk-session.json')));
|
|
410
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
411
|
+
rmSync(spec.state_root, { recursive: true, force: true });
|
|
412
|
+
}, spec);
|
|
413
|
+
expect(await host).toBe('team_gone');
|
|
414
|
+
});
|
|
415
|
+
it('a revoked launch never starts the runtime, and publishes its exit so a stop returns at once', async () => {
|
|
416
|
+
const attempt = await newAttempt();
|
|
417
|
+
const { loaded, rec } = fakeSdk();
|
|
418
|
+
const host = runSdkHost(specFor(attempt), deps(loaded));
|
|
419
|
+
await until(() => existsSync(attempt.ackPath));
|
|
420
|
+
writeFileSync(attempt.decisionPath, JSON.stringify({ ...attempt, kind: 'worker_launch_decision', decision: 'revoked', reason: 'test', written_at: new Date().toISOString() }));
|
|
421
|
+
expect(await host).toBe('decision_revoked');
|
|
422
|
+
expect(rec.ops).toEqual([]);
|
|
423
|
+
// No provider_started, but a terminal record and a failed session file.
|
|
424
|
+
expect(existsSync(attempt.startedPath)).toBe(false);
|
|
425
|
+
expect(JSON.parse(readFileSync(`${attempt.startedPath}.terminal`, 'utf8')))
|
|
426
|
+
.toMatchObject({ kind: 'worker_launch_provider_terminal', outcome: 'exit', cleanup_verified: true, pid: 4242 });
|
|
427
|
+
expect(readSdkSession(stateRoot, WORKER)).toMatchObject({ state: 'failed', last_error: 'launch decision revoked' });
|
|
428
|
+
const t0 = Date.now();
|
|
429
|
+
expect(await stopSdkHost(attempt, stateRoot, { timeoutMs: 30_000, pollMs: 10 })).toBe(true);
|
|
430
|
+
expect(Date.now() - t0).toBeLessThan(2_000);
|
|
431
|
+
});
|
|
432
|
+
it('caps at exactly max_credits (>=), not only past it', async () => {
|
|
433
|
+
const attempt = await newAttempt();
|
|
434
|
+
const { loaded, rec } = fakeSdk({ credits: 5 });
|
|
435
|
+
const host = await hostWithLeader(attempt, deps(loaded), async () => {
|
|
436
|
+
await until(() => readSdkSession(stateRoot, WORKER)?.state === 'idle');
|
|
437
|
+
ringDoorbell(stateRoot, WORKER, { kind: 'prompt', text: 'a' });
|
|
438
|
+
await until(() => rec.prompts.length === 1 && readSdkSession(stateRoot, WORKER)?.state === 'idle');
|
|
439
|
+
ringDoorbell(stateRoot, WORKER, { kind: 'prompt', text: 'b' }); // 5 + 5 = 10 = the cap
|
|
440
|
+
await until(() => readSdkSession(stateRoot, WORKER)?.state === 'capped');
|
|
441
|
+
ringDoorbell(stateRoot, WORKER, { kind: 'prompt', text: 'c' });
|
|
442
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
443
|
+
ringDoorbell(stateRoot, WORKER, { kind: 'stop' });
|
|
444
|
+
});
|
|
445
|
+
expect(await host).toBe('stopped');
|
|
446
|
+
expect(rec.prompts).toEqual(['a', 'b']);
|
|
447
|
+
expect(readSdkSession(stateRoot, WORKER)?.last_error).toMatch(/credit cap 10 reached \(10\.00\)/);
|
|
448
|
+
});
|
|
449
|
+
it('retries a session write that fails on win32 rename contention', async () => {
|
|
450
|
+
const attempt = await newAttempt();
|
|
451
|
+
const { loaded } = fakeSdk();
|
|
452
|
+
const { atomicWriteJson } = await import('../../lib/atomic-write.js');
|
|
453
|
+
const logs = [];
|
|
454
|
+
let failures = 0;
|
|
455
|
+
const writeSessionJson = async (path, value) => {
|
|
456
|
+
// The first three writes lose the rename race, as with a reader holding the file.
|
|
457
|
+
if (failures < 3) {
|
|
458
|
+
failures += 1;
|
|
459
|
+
throw Object.assign(new Error(`EPERM: operation not permitted, rename '${path}.tmp' -> '${path}'`), { code: 'EPERM' });
|
|
460
|
+
}
|
|
461
|
+
await atomicWriteJson(path, value);
|
|
462
|
+
};
|
|
463
|
+
const host = await hostWithLeader(attempt, deps(loaded, { writeSessionJson, log: (l) => logs.push(l) }), async () => {
|
|
464
|
+
await until(() => readSdkSession(stateRoot, WORKER)?.state === 'idle');
|
|
465
|
+
ringDoorbell(stateRoot, WORKER, { kind: 'stop' });
|
|
466
|
+
});
|
|
467
|
+
expect(await host).toBe('stopped');
|
|
468
|
+
expect(failures).toBe(3);
|
|
469
|
+
expect(logs.filter((l) => l.startsWith('session write failed: EPERM'))).toHaveLength(3);
|
|
470
|
+
// The dropped writes were retried: the file reached its final state.
|
|
471
|
+
expect(readSdkSession(stateRoot, WORKER)).toMatchObject({ state: 'closed', session_id: 's1' });
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
// ---------------------------------------------------------------------------
|
|
475
|
+
// Leader side: launch and stop. Provider pids are throwaway child processes,
|
|
476
|
+
// never this vitest worker: a stop that escalates kills what the record names.
|
|
477
|
+
// ---------------------------------------------------------------------------
|
|
478
|
+
const children = [];
|
|
479
|
+
async function dummyProcess() {
|
|
480
|
+
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore', windowsHide: true });
|
|
481
|
+
children.push(child);
|
|
482
|
+
const pid = child.pid;
|
|
483
|
+
let identity = null;
|
|
484
|
+
await until(() => (identity = getProcessStartIdentitySync(pid)) !== null);
|
|
485
|
+
return { pid, identity: identity };
|
|
486
|
+
}
|
|
487
|
+
afterEach(() => {
|
|
488
|
+
for (const child of children.splice(0)) {
|
|
489
|
+
try {
|
|
490
|
+
child.kill('SIGKILL');
|
|
491
|
+
}
|
|
492
|
+
catch { /* already gone */ }
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
function launchInput(over = {}) {
|
|
496
|
+
return {
|
|
497
|
+
leaderCwd: cwd,
|
|
498
|
+
teamName: TEAM,
|
|
499
|
+
workerName: WORKER,
|
|
500
|
+
instanceId: randomUUID(),
|
|
501
|
+
workerCwd: worktree,
|
|
502
|
+
stateRoot,
|
|
503
|
+
workerEnv: {},
|
|
504
|
+
copilotBin: 'C:/fake/copilot.exe',
|
|
505
|
+
pluginRoot,
|
|
506
|
+
userConfigDir: join(cwd, 'no-user-config'),
|
|
507
|
+
maxCredits: 10,
|
|
508
|
+
denyTools: [],
|
|
509
|
+
denyUrls: [],
|
|
510
|
+
ackTimeoutMs: 1_500,
|
|
511
|
+
...over,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
function specAt(specPath) {
|
|
515
|
+
return JSON.parse(readFileSync(specPath, 'utf8'));
|
|
516
|
+
}
|
|
517
|
+
async function attemptFor(spec) {
|
|
518
|
+
const attempt = await loadWorkerLaunchAttempt({
|
|
519
|
+
cwd: spec.leader_cwd, teamName: spec.team_name, workerName: spec.worker_name, instanceId: spec.instance_id,
|
|
520
|
+
paneId: spec.pane_id, provider: 'copilot', attemptId: spec.attempt_id, runtimeCliPath: spec.runtime_cli_path,
|
|
521
|
+
});
|
|
522
|
+
return attempt;
|
|
523
|
+
}
|
|
524
|
+
describe('launchSdkHost', () => {
|
|
525
|
+
it('truncates the doorbell so a new host never replays an earlier attempt', async () => {
|
|
526
|
+
const { doorbell } = sdkWorkerFiles(stateRoot, WORKER);
|
|
527
|
+
ringDoorbell(stateRoot, WORKER, { kind: 'shutdown' });
|
|
528
|
+
const host = await dummyProcess();
|
|
529
|
+
let sizeAtSpawn = -1;
|
|
530
|
+
const result = await launchSdkHost(launchInput({
|
|
531
|
+
spawnHost: () => { sizeAtSpawn = readFileSync(doorbell).length; return host.pid; },
|
|
532
|
+
ackTimeoutMs: 50,
|
|
533
|
+
}));
|
|
534
|
+
expect(result.ok).toBe(false);
|
|
535
|
+
expect(sizeAtSpawn).toBe(0);
|
|
536
|
+
});
|
|
537
|
+
it('kills the host it spawned when no ack arrives', async () => {
|
|
538
|
+
const host = await dummyProcess();
|
|
539
|
+
const result = await launchSdkHost(launchInput({ spawnHost: () => host.pid, ackTimeoutMs: 300 }));
|
|
540
|
+
expect(result).toMatchObject({ ok: false, hostPid: host.pid });
|
|
541
|
+
expect(!result.ok && result.reason).toMatch(/^sdk_host_ack_/);
|
|
542
|
+
await until(() => !isProcessAlive(host.pid));
|
|
543
|
+
});
|
|
544
|
+
it('kills an acked host that never publishes provider_started', async () => {
|
|
545
|
+
const host = await dummyProcess();
|
|
546
|
+
const result = await launchSdkHost(launchInput({
|
|
547
|
+
spawnHost: (_cli, specPath) => {
|
|
548
|
+
// Ack like a host, then go silent.
|
|
549
|
+
void attemptFor(specAt(specPath)).then((a) => writeFileSync(a.ackPath, JSON.stringify({
|
|
550
|
+
schema_version: a.schema_version, attempt_id: a.attempt_id, nonce: a.nonce, instance_id: a.instance_id,
|
|
551
|
+
team_name: a.team_name, worker_name: a.worker_name, pane_id: a.pane_id, provider: a.provider,
|
|
552
|
+
created_at: a.created_at, kind: 'worker_launch_ack', written_at: new Date().toISOString(),
|
|
553
|
+
})));
|
|
554
|
+
return host.pid;
|
|
555
|
+
},
|
|
556
|
+
}));
|
|
557
|
+
expect(result).toMatchObject({ ok: false, reason: 'sdk_host_provider_not_started', hostPid: host.pid });
|
|
558
|
+
await until(() => !isProcessAlive(host.pid));
|
|
559
|
+
});
|
|
560
|
+
it('stops waiting as soon as the host reports a failure before provider_started', async () => {
|
|
561
|
+
const host = await dummyProcess();
|
|
562
|
+
const { loaded, rec } = fakeSdk();
|
|
563
|
+
let run;
|
|
564
|
+
const t0 = Date.now();
|
|
565
|
+
const result = await launchSdkHost(launchInput({
|
|
566
|
+
ackTimeoutMs: 20_000,
|
|
567
|
+
spawnHost: (_cli, specPath) => {
|
|
568
|
+
// A real host whose start identity cannot be read fails right after the accept.
|
|
569
|
+
run = runSdkHost(specAt(specPath), deps(loaded, { startIdentity: () => null }));
|
|
570
|
+
return host.pid;
|
|
571
|
+
},
|
|
572
|
+
}));
|
|
573
|
+
expect(await run).toBe('start_failed');
|
|
574
|
+
expect(result).toMatchObject({ ok: false, reason: 'sdk_host_failed_before_start:host process start identity unavailable' });
|
|
575
|
+
expect(Date.now() - t0).toBeLessThan(10_000);
|
|
576
|
+
expect(rec.ops).toEqual([]);
|
|
577
|
+
await until(() => !isProcessAlive(host.pid));
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
describe('stopSdkHost', () => {
|
|
581
|
+
it('rings stop and returns once the host reports its provider dead', async () => {
|
|
582
|
+
const attempt = await newAttempt();
|
|
583
|
+
const { loaded, rec } = fakeSdk();
|
|
584
|
+
const provider = await dummyProcess();
|
|
585
|
+
let stopped;
|
|
586
|
+
// The provider record must name a live process with its real start identity.
|
|
587
|
+
const live = deps(loaded, { pid: provider.pid, startIdentity: (pid) => getProcessStartIdentitySync(pid) });
|
|
588
|
+
const host = await hostWithLeader(attempt, live, async () => {
|
|
589
|
+
await until(() => readSdkSession(stateRoot, WORKER)?.state === 'idle');
|
|
590
|
+
stopped = stopSdkHost(attempt, stateRoot, { timeoutMs: 5_000, pollMs: 10 });
|
|
591
|
+
});
|
|
592
|
+
expect(await host).toBe('stopped');
|
|
593
|
+
expect(await stopped).toBe(true);
|
|
594
|
+
expect(rec.ops).toContain('stop');
|
|
595
|
+
expect(isProcessAlive(provider.pid)).toBe(true); // graceful: nothing was killed
|
|
596
|
+
}, 60_000);
|
|
597
|
+
it('escalates to an identity-checked kill of the host and its runtime, then publishes termination-complete', async () => {
|
|
598
|
+
const attempt = await newAttempt();
|
|
599
|
+
const hostProc = await dummyProcess();
|
|
600
|
+
const runtimeProc = await dummyProcess();
|
|
601
|
+
const { loaded } = fakeSdk({ pid: runtimeProc.pid });
|
|
602
|
+
// pollMs far above the stop budget: the host does not answer the stop doorbell in time.
|
|
603
|
+
const hung = deps(loaded, {
|
|
604
|
+
pid: hostProc.pid,
|
|
605
|
+
startIdentity: (pid) => getProcessStartIdentitySync(pid),
|
|
606
|
+
isAlive: (pid) => isProcessAlive(pid),
|
|
607
|
+
killTree: () => { },
|
|
608
|
+
pollMs: 4_000,
|
|
609
|
+
});
|
|
610
|
+
let stopped = false;
|
|
611
|
+
const host = await hostWithLeader(attempt, hung, async () => {
|
|
612
|
+
await until(() => readSdkSession(stateRoot, WORKER)?.state === 'idle');
|
|
613
|
+
expect(readSdkSession(stateRoot, WORKER)).toMatchObject({ runtime_pid: runtimeProc.pid, runtime_start_identity: runtimeProc.identity });
|
|
614
|
+
stopped = await stopSdkHost(attempt, stateRoot, { timeoutMs: 300, pollMs: 20 });
|
|
615
|
+
});
|
|
616
|
+
expect(stopped).toBe(true);
|
|
617
|
+
await until(() => !isProcessAlive(hostProc.pid) && !isProcessAlive(runtimeProc.pid));
|
|
618
|
+
expect(JSON.parse(readFileSync(`${attempt.startedPath}.termination-complete`, 'utf8')))
|
|
619
|
+
.toMatchObject({ kind: 'worker_launch_termination_complete', cleanup_verified: true, pid: hostProc.pid, process_start_identity: hostProc.identity });
|
|
620
|
+
await host;
|
|
621
|
+
}, 60_000);
|
|
622
|
+
it('never signals a recorded pid whose start identity no longer matches', async () => {
|
|
623
|
+
const attempt = await newAttempt();
|
|
624
|
+
const bystander = await dummyProcess();
|
|
625
|
+
// A started record and a session naming a live pid with a stale identity (a recycled pid).
|
|
626
|
+
writeFileSync(attempt.startedPath, JSON.stringify({
|
|
627
|
+
schema_version: attempt.schema_version, attempt_id: attempt.attempt_id, nonce: attempt.nonce,
|
|
628
|
+
instance_id: attempt.instance_id, team_name: attempt.team_name, worker_name: attempt.worker_name,
|
|
629
|
+
pane_id: attempt.pane_id, provider: attempt.provider, created_at: attempt.created_at,
|
|
630
|
+
kind: 'worker_launch_provider_started', pid: bystander.pid, process_start_identity: 'ticks:1',
|
|
631
|
+
written_at: new Date().toISOString(),
|
|
632
|
+
}));
|
|
633
|
+
writeFileSync(sdkWorkerFiles(stateRoot, WORKER).session, JSON.stringify({
|
|
634
|
+
schema_version: 1, attempt_id: attempt.attempt_id, worker_name: WORKER, state: 'busy',
|
|
635
|
+
runtime_pid: bystander.pid, runtime_start_identity: 'ticks:2',
|
|
636
|
+
}));
|
|
637
|
+
expect(await stopSdkHost(attempt, stateRoot, { timeoutMs: 100, pollMs: 20 })).toBe(true);
|
|
638
|
+
expect(isProcessAlive(bystander.pid)).toBe(true);
|
|
639
|
+
});
|
|
640
|
+
it('reports cleanup unverified while a runtime without a recorded identity is still alive', async () => {
|
|
641
|
+
const attempt = await newAttempt();
|
|
642
|
+
const orphan = await dummyProcess();
|
|
643
|
+
writeFileSync(sdkWorkerFiles(stateRoot, WORKER).session, JSON.stringify({
|
|
644
|
+
schema_version: 1, attempt_id: attempt.attempt_id, worker_name: WORKER, state: 'busy', runtime_pid: orphan.pid,
|
|
645
|
+
}));
|
|
646
|
+
expect(await stopSdkHost(attempt, stateRoot, { timeoutMs: 50, pollMs: 50 })).toBe(false);
|
|
647
|
+
expect(isProcessAlive(orphan.pid)).toBe(true); // unverifiable pids are reported, not killed
|
|
648
|
+
}, 20_000);
|
|
649
|
+
});
|
|
650
|
+
//# sourceMappingURL=sdk-transport.test.js.map
|