@hellcoder/companion 0.96.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/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
- package/dist/assets/CronManager-EGwLJONv.js +1 -0
- package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
- package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
- package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
- package/dist/assets/Playground-BV3k0RbV.js +109 -0
- package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
- package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
- package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
- package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
- package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
- package/dist/assets/index-BhUa1e6X.css +1 -0
- package/dist/assets/index-DkqeP-R9.js +134 -0
- package/dist/assets/sw-register-BibwRdvC.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +20 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/sw.js +2 -0
- package/package.json +104 -0
- package/server/agent-cron-migrator.test.ts +610 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.test.ts +1108 -0
- package/server/agent-executor.ts +346 -0
- package/server/agent-store.test.ts +588 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-types.ts +138 -0
- package/server/ai-validation-settings.test.ts +128 -0
- package/server/ai-validation-settings.ts +35 -0
- package/server/ai-validator.test.ts +387 -0
- package/server/ai-validator.ts +271 -0
- package/server/auth-manager.test.ts +83 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-namer.test.ts +252 -0
- package/server/auto-namer.ts +78 -0
- package/server/backend-adapter.test.ts +38 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.test.ts +98 -0
- package/server/cache-headers.ts +61 -0
- package/server/claude-adapter.test.ts +1363 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.test.ts +44 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-protocol-contract.test.ts +71 -0
- package/server/claude-protocol-drift.test.ts +78 -0
- package/server/claude-session-discovery.test.ts +132 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.test.ts +158 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.test.ts +1343 -0
- package/server/cli-launcher.ts +1298 -0
- package/server/cli.test.ts +16 -0
- package/server/codex-adapter.test.ts +5545 -0
- package/server/codex-adapter.ts +3062 -0
- package/server/codex-container-auth.test.ts +50 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.test.ts +61 -0
- package/server/codex-home.ts +26 -0
- package/server/codex-protocol-contract.test.ts +96 -0
- package/server/codex-protocol-drift.test.ts +123 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.test.ts +179 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.test.ts +1211 -0
- package/server/container-manager.ts +1053 -0
- package/server/cron-scheduler.test.ts +957 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.test.ts +422 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/env-manager.test.ts +268 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +64 -0
- package/server/event-bus.test.ts +244 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.test.ts +307 -0
- package/server/execution-store.ts +170 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.test.ts +938 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.test.ts +498 -0
- package/server/github-pr.ts +379 -0
- package/server/image-pull-manager.test.ts +303 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +396 -0
- package/server/linear-agent-bridge.test.ts +1157 -0
- package/server/linear-agent-bridge.ts +629 -0
- package/server/linear-agent.test.ts +473 -0
- package/server/linear-agent.ts +479 -0
- package/server/linear-cache.test.ts +136 -0
- package/server/linear-cache.ts +113 -0
- package/server/linear-connections.test.ts +350 -0
- package/server/linear-connections.ts +231 -0
- package/server/linear-credential-migration.test.ts +337 -0
- package/server/linear-credential-migration.ts +63 -0
- package/server/linear-oauth-connections-migration.test.ts +268 -0
- package/server/linear-oauth-connections.test.ts +365 -0
- package/server/linear-oauth-connections.ts +294 -0
- package/server/linear-project-manager.test.ts +162 -0
- package/server/linear-project-manager.ts +111 -0
- package/server/linear-prompt-builder.test.ts +74 -0
- package/server/linear-prompt-builder.ts +61 -0
- package/server/linear-staging.test.ts +276 -0
- package/server/linear-staging.ts +142 -0
- package/server/logger.test.ts +393 -0
- package/server/logger.ts +259 -0
- package/server/metrics-collector.test.ts +413 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.test.ts +264 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.test.ts +333 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.test.ts +552 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.test.ts +31 -0
- package/server/paths.ts +11 -0
- package/server/pr-poller.test.ts +191 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.test.ts +211 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/recorder.test.ts +454 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.test.ts +150 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.test.ts +140 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.test.ts +44 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.test.ts +417 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.test.ts +262 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.test.ts +294 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.test.ts +337 -0
- package/server/relay-client.ts +320 -0
- package/server/replay.test.ts +200 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.test.ts +1400 -0
- package/server/routes/agent-routes.ts +409 -0
- package/server/routes/cron-routes.test.ts +881 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.test.ts +383 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/fs-routes.test.ts +1198 -0
- package/server/routes/fs-routes.ts +605 -0
- package/server/routes/git-routes.test.ts +813 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/linear-agent-routes.test.ts +721 -0
- package/server/routes/linear-agent-routes.ts +304 -0
- package/server/routes/linear-connection-routes.test.ts +927 -0
- package/server/routes/linear-connection-routes.ts +244 -0
- package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
- package/server/routes/linear-oauth-connection-routes.ts +129 -0
- package/server/routes/linear-routes.test.ts +1510 -0
- package/server/routes/linear-routes.ts +953 -0
- package/server/routes/metrics-routes.test.ts +103 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/sandbox-routes.test.ts +513 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +270 -0
- package/server/routes/skills-routes.test.ts +690 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/system-routes.test.ts +637 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.test.ts +176 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes.test.ts +4655 -0
- package/server/routes.ts +1277 -0
- package/server/sandbox-manager.test.ts +378 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.test.ts +1419 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.test.ts +661 -0
- package/server/session-creation-service.ts +473 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-linear-issues.test.ts +118 -0
- package/server/session-linear-issues.ts +88 -0
- package/server/session-names.test.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.test.ts +1784 -0
- package/server/session-orchestrator.ts +973 -0
- package/server/session-state-machine.test.ts +606 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.test.ts +290 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +509 -0
- package/server/settings-manager.test.ts +275 -0
- package/server/settings-manager.ts +173 -0
- package/server/tailscale-manager.test.ts +553 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.test.ts +306 -0
- package/server/update-checker.ts +197 -0
- package/server/usage-limits.test.ts +536 -0
- package/server/usage-limits.ts +225 -0
- package/server/worktree-tracker.test.ts +243 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.test.ts +59 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.test.ts +272 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.test.ts +302 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.test.ts +1837 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.test.ts +124 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.test.ts +296 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.test.ts +234 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.test.ts +44 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +106 -0
- package/server/ws-bridge.test.ts +4777 -0
- package/server/ws-bridge.ts +1279 -0
|
@@ -0,0 +1,1211 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mockExecSync = vi.hoisted(() => vi.fn((..._args: unknown[]) => ""));
|
|
4
|
+
const mockExistsSync = vi.hoisted(() => vi.fn((..._args: unknown[]) => false));
|
|
5
|
+
const mockWriteFileSync = vi.hoisted(() => vi.fn());
|
|
6
|
+
const mockReadFileSync = vi.hoisted(() => vi.fn((..._args: unknown[]) => ""));
|
|
7
|
+
const mockMkdirSync = vi.hoisted(() => vi.fn());
|
|
8
|
+
const mockRmSync = vi.hoisted(() => vi.fn());
|
|
9
|
+
const mockSpawn = vi.hoisted(() => vi.fn());
|
|
10
|
+
|
|
11
|
+
vi.mock("node:child_process", () => ({
|
|
12
|
+
execSync: mockExecSync,
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock("node:fs", async (importOriginal) => {
|
|
16
|
+
const actual = (await importOriginal()) as Record<string, unknown>;
|
|
17
|
+
return {
|
|
18
|
+
...actual,
|
|
19
|
+
existsSync: mockExistsSync,
|
|
20
|
+
writeFileSync: mockWriteFileSync,
|
|
21
|
+
readFileSync: mockReadFileSync,
|
|
22
|
+
mkdirSync: mockMkdirSync,
|
|
23
|
+
rmSync: mockRmSync,
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
import { ContainerManager } from "./container-manager.js";
|
|
28
|
+
|
|
29
|
+
function createMockProc(exitCode: number, stderrText = "") {
|
|
30
|
+
return {
|
|
31
|
+
exited: Promise.resolve(exitCode),
|
|
32
|
+
stderr: new ReadableStream<Uint8Array>({
|
|
33
|
+
start(controller) {
|
|
34
|
+
controller.enqueue(new TextEncoder().encode(stderrText));
|
|
35
|
+
controller.close();
|
|
36
|
+
},
|
|
37
|
+
}),
|
|
38
|
+
kill: vi.fn(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
vi.stubGlobal("Bun", { spawn: mockSpawn });
|
|
43
|
+
|
|
44
|
+
describe("ContainerManager git auth seeding", () => {
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
mockExecSync.mockReset();
|
|
47
|
+
mockExistsSync.mockReset();
|
|
48
|
+
// Default: existsSync returns false (no host files)
|
|
49
|
+
mockExistsSync.mockReturnValue(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("always configures gh as git credential helper when host token lookup fails", () => {
|
|
53
|
+
// Regression guard: copied gh auth files in the container are still valid even
|
|
54
|
+
// when `gh auth token` cannot read host keychain state.
|
|
55
|
+
mockExecSync.mockImplementation((...args: unknown[]) => {
|
|
56
|
+
const cmd = String(args[0] ?? "");
|
|
57
|
+
if (cmd.includes("gh auth token")) throw new Error("host token unavailable");
|
|
58
|
+
return "";
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const manager = new ContainerManager();
|
|
62
|
+
manager.reseedGitAuth("container123");
|
|
63
|
+
|
|
64
|
+
const commands = mockExecSync.mock.calls.map((call) => String(call[0] ?? ""));
|
|
65
|
+
expect(commands.some((cmd) => cmd.includes("gh auth setup-git"))).toBe(true);
|
|
66
|
+
expect(commands.some((cmd) => cmd.includes("gh auth login --with-token"))).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("logs in with host token before running gh auth setup-git when token exists", () => {
|
|
70
|
+
// Ordering matters: authenticate first, then wire git credential helper.
|
|
71
|
+
mockExecSync.mockImplementation((...args: unknown[]) => {
|
|
72
|
+
const cmd = String(args[0] ?? "");
|
|
73
|
+
if (cmd.includes("gh auth token")) return "ghp_test_token";
|
|
74
|
+
return "";
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const manager = new ContainerManager();
|
|
78
|
+
manager.reseedGitAuth("container123");
|
|
79
|
+
|
|
80
|
+
const commands = mockExecSync.mock.calls.map((call) => String(call[0] ?? ""));
|
|
81
|
+
const loginIndex = commands.findIndex((cmd) => cmd.includes("gh auth login --with-token"));
|
|
82
|
+
const setupGitIndex = commands.findIndex((cmd) => cmd.includes("gh auth setup-git"));
|
|
83
|
+
|
|
84
|
+
expect(loginIndex).toBeGreaterThan(-1);
|
|
85
|
+
expect(setupGitIndex).toBeGreaterThan(-1);
|
|
86
|
+
expect(loginIndex).toBeLessThan(setupGitIndex);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("ContainerManager git identity seeding from host .gitconfig", () => {
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
mockExecSync.mockReset();
|
|
93
|
+
mockExistsSync.mockReset();
|
|
94
|
+
mockExistsSync.mockReturnValue(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("copies user.name and user.email from /companion-host-gitconfig into container global config", () => {
|
|
98
|
+
// The host .gitconfig is mounted read-only at /companion-host-gitconfig.
|
|
99
|
+
// seedGitAuth should read identity from that file and write it into the
|
|
100
|
+
// container's writable /root/.gitconfig via git config --global.
|
|
101
|
+
mockExecSync.mockImplementation((...args: unknown[]) => {
|
|
102
|
+
const cmd = String(args[0] ?? "");
|
|
103
|
+
if (cmd.includes("gh auth token")) throw new Error("no token");
|
|
104
|
+
return "";
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const manager = new ContainerManager();
|
|
108
|
+
manager.reseedGitAuth("container123");
|
|
109
|
+
|
|
110
|
+
const commands = mockExecSync.mock.calls.map((call) => String(call[0] ?? ""));
|
|
111
|
+
// The seeding command should reference the staged host gitconfig path
|
|
112
|
+
const identityCmd = commands.find((cmd) => cmd.includes("companion-host-gitconfig"));
|
|
113
|
+
expect(identityCmd).toBeDefined();
|
|
114
|
+
// It should use git config -f to read from the mounted file
|
|
115
|
+
expect(identityCmd).toContain("git config -f /companion-host-gitconfig user.name");
|
|
116
|
+
expect(identityCmd).toContain("git config -f /companion-host-gitconfig user.email");
|
|
117
|
+
// It should write user.name and user.email via git config --global
|
|
118
|
+
expect(identityCmd).toContain("git config --global user.name");
|
|
119
|
+
expect(identityCmd).toContain("git config --global user.email");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("disables gpgsign in writable global config (not the read-only mount)", () => {
|
|
123
|
+
// With the host .gitconfig mounted at /companion-host-gitconfig instead
|
|
124
|
+
// of /root/.gitconfig, git config --global writes succeed in the container.
|
|
125
|
+
mockExecSync.mockImplementation((...args: unknown[]) => {
|
|
126
|
+
const cmd = String(args[0] ?? "");
|
|
127
|
+
if (cmd.includes("gh auth token")) throw new Error("no token");
|
|
128
|
+
return "";
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const manager = new ContainerManager();
|
|
132
|
+
manager.reseedGitAuth("container123");
|
|
133
|
+
|
|
134
|
+
const commands = mockExecSync.mock.calls.map((call) => String(call[0] ?? ""));
|
|
135
|
+
expect(commands.some((cmd) => cmd.includes("git config --global commit.gpgsign false"))).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("marks /workspace as a safe directory to avoid dubious ownership errors", () => {
|
|
139
|
+
// The workspace volume may be owned by a different uid (e.g. ubuntu)
|
|
140
|
+
// than the container user (root), triggering git's ownership check.
|
|
141
|
+
mockExecSync.mockImplementation((...args: unknown[]) => {
|
|
142
|
+
const cmd = String(args[0] ?? "");
|
|
143
|
+
if (cmd.includes("gh auth token")) throw new Error("no token");
|
|
144
|
+
return "";
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const manager = new ContainerManager();
|
|
148
|
+
manager.reseedGitAuth("container123");
|
|
149
|
+
|
|
150
|
+
const commands = mockExecSync.mock.calls.map((call) => String(call[0] ?? ""));
|
|
151
|
+
expect(commands.some((cmd) => cmd.includes("safe.directory /workspace"))).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("ContainerManager Codex file seeding", () => {
|
|
156
|
+
beforeEach(() => {
|
|
157
|
+
mockExecSync.mockReset();
|
|
158
|
+
mockExistsSync.mockReset();
|
|
159
|
+
mockExistsSync.mockReturnValue(false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("seeds Codex auth files when /companion-host-codex is available", () => {
|
|
163
|
+
// seedCodexFiles is called internally during createContainer and startContainer.
|
|
164
|
+
// Since we can't call createContainer in a unit test (it needs docker), we
|
|
165
|
+
// test the seeding indirectly via a restart (startContainer).
|
|
166
|
+
// However startContainer also calls docker start, so we test via the public
|
|
167
|
+
// reseedGitAuth path which triggers seedGitAuth but not seedCodexFiles.
|
|
168
|
+
// Instead, verify the command is issued during a docker exec mock.
|
|
169
|
+
mockExecSync.mockImplementation((..._args: unknown[]) => "");
|
|
170
|
+
|
|
171
|
+
const manager = new ContainerManager();
|
|
172
|
+
// Access private method via bracket notation for testing
|
|
173
|
+
(manager as unknown as Record<string, (id: string) => void>)["seedCodexFiles"]("container456");
|
|
174
|
+
|
|
175
|
+
const commands = mockExecSync.mock.calls.map((call) => String(call[0] ?? ""));
|
|
176
|
+
// Should attempt to copy Codex files from bind mount
|
|
177
|
+
expect(commands.some((cmd) =>
|
|
178
|
+
cmd.includes("/companion-host-codex") && cmd.includes("/root/.codex"),
|
|
179
|
+
)).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("copies auth.json, config.toml, and directory seeds for Codex", () => {
|
|
183
|
+
mockExecSync.mockImplementation((..._args: unknown[]) => "");
|
|
184
|
+
|
|
185
|
+
const manager = new ContainerManager();
|
|
186
|
+
(manager as unknown as Record<string, (id: string) => void>)["seedCodexFiles"]("container789");
|
|
187
|
+
|
|
188
|
+
const commands = mockExecSync.mock.calls.map((call) => String(call[0] ?? ""));
|
|
189
|
+
const seedCmd = commands.find((cmd) => cmd.includes("companion-host-codex"));
|
|
190
|
+
expect(seedCmd).toBeDefined();
|
|
191
|
+
// Verify it copies the expected files
|
|
192
|
+
expect(seedCmd).toContain("auth.json");
|
|
193
|
+
expect(seedCmd).toContain("config.toml");
|
|
194
|
+
expect(seedCmd).toContain("models_cache.json");
|
|
195
|
+
// Verify it copies directories
|
|
196
|
+
expect(seedCmd).toContain("skills");
|
|
197
|
+
expect(seedCmd).toContain("prompts");
|
|
198
|
+
expect(seedCmd).toContain("rules");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("does not fail when seedCodexFiles encounters an error", () => {
|
|
202
|
+
// seedCodexFiles is best-effort and should not throw
|
|
203
|
+
mockExecSync.mockImplementation(() => {
|
|
204
|
+
throw new Error("container not running");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const manager = new ContainerManager();
|
|
208
|
+
expect(() => {
|
|
209
|
+
(manager as unknown as Record<string, (id: string) => void>)["seedCodexFiles"]("container999");
|
|
210
|
+
}).not.toThrow();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("ContainerManager workspace copy", () => {
|
|
215
|
+
beforeEach(() => {
|
|
216
|
+
mockSpawn.mockReset();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("uses tar stream + docker exec to copy workspace content", async () => {
|
|
220
|
+
// Validates the fast path used for large workspaces (especially on macOS):
|
|
221
|
+
// tar the host directory and stream directly into /workspace in-container.
|
|
222
|
+
mockSpawn.mockReturnValue(createMockProc(0));
|
|
223
|
+
|
|
224
|
+
const manager = new ContainerManager();
|
|
225
|
+
await expect(manager.copyWorkspaceToContainer("container123", "/tmp/my-workspace")).resolves.toBeUndefined();
|
|
226
|
+
|
|
227
|
+
expect(mockSpawn).toHaveBeenCalledOnce();
|
|
228
|
+
const [args, options] = mockSpawn.mock.calls[0] as [string[], Record<string, unknown>];
|
|
229
|
+
|
|
230
|
+
expect(args[0]).toBe("bash");
|
|
231
|
+
expect(args[1]).toBe("-lc");
|
|
232
|
+
expect(args[2]).toContain("set -o pipefail");
|
|
233
|
+
expect(args[2]).toContain("COPYFILE_DISABLE=1 tar -C /tmp/my-workspace -cf - .");
|
|
234
|
+
expect(args[2]).toContain("docker exec -i container123 tar -xf - -C /workspace");
|
|
235
|
+
expect(options.stdout).toBe("pipe");
|
|
236
|
+
expect(options.stderr).toBe("pipe");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("throws a descriptive error when copy command fails", async () => {
|
|
240
|
+
// Ensures stderr from the tar/docker pipeline is surfaced to users.
|
|
241
|
+
mockSpawn.mockReturnValue(createMockProc(2, "tar: write error"));
|
|
242
|
+
|
|
243
|
+
const manager = new ContainerManager();
|
|
244
|
+
await expect(manager.copyWorkspaceToContainer("container123", "/tmp/my-workspace"))
|
|
245
|
+
.rejects.toThrow("workspace copy failed (exit 2): tar: write error");
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe("ContainerManager gitOpsInContainer", () => {
|
|
250
|
+
beforeEach(() => {
|
|
251
|
+
mockExecSync.mockReset();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("runs fetch, checkout, and pull in sequence and reports success", () => {
|
|
255
|
+
// All git commands succeed inside the container
|
|
256
|
+
mockExecSync.mockReturnValue("");
|
|
257
|
+
|
|
258
|
+
const manager = new ContainerManager();
|
|
259
|
+
const result = manager.gitOpsInContainer("cid-123", {
|
|
260
|
+
branch: "feat/new",
|
|
261
|
+
currentBranch: "main",
|
|
262
|
+
defaultBranch: "main",
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
expect(result.fetchOk).toBe(true);
|
|
266
|
+
expect(result.checkoutOk).toBe(true);
|
|
267
|
+
expect(result.pullOk).toBe(true);
|
|
268
|
+
expect(result.errors).toHaveLength(0);
|
|
269
|
+
|
|
270
|
+
// Verify commands were executed in the container
|
|
271
|
+
const cmds = mockExecSync.mock.calls.map((c) => String(c[0] ?? ""));
|
|
272
|
+
expect(cmds.some((c) => c.includes("git fetch --prune"))).toBe(true);
|
|
273
|
+
expect(cmds.some((c) => c.includes("git checkout"))).toBe(true);
|
|
274
|
+
expect(cmds.some((c) => c.includes("git pull"))).toBe(true);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("treats fetch failure as non-fatal and continues with checkout/pull", () => {
|
|
278
|
+
// git fetch fails but checkout and pull succeed
|
|
279
|
+
mockExecSync.mockImplementation((...args: unknown[]) => {
|
|
280
|
+
const cmd = String(args[0] ?? "");
|
|
281
|
+
if (cmd.includes("git fetch")) throw new Error("network unreachable");
|
|
282
|
+
return "";
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const manager = new ContainerManager();
|
|
286
|
+
const result = manager.gitOpsInContainer("cid-123", {
|
|
287
|
+
branch: "feat/new",
|
|
288
|
+
currentBranch: "main",
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
expect(result.fetchOk).toBe(false);
|
|
292
|
+
expect(result.checkoutOk).toBe(true);
|
|
293
|
+
expect(result.pullOk).toBe(true);
|
|
294
|
+
expect(result.errors).toHaveLength(1);
|
|
295
|
+
expect(result.errors[0]).toContain("fetch:");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("reports checkout failure when branch does not exist and createBranch is false", () => {
|
|
299
|
+
mockExecSync.mockImplementation((...args: unknown[]) => {
|
|
300
|
+
const cmd = String(args[0] ?? "");
|
|
301
|
+
if (cmd.includes("git checkout")) throw new Error("pathspec did not match");
|
|
302
|
+
return "";
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const manager = new ContainerManager();
|
|
306
|
+
const result = manager.gitOpsInContainer("cid-123", {
|
|
307
|
+
branch: "nonexistent",
|
|
308
|
+
currentBranch: "main",
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
expect(result.checkoutOk).toBe(false);
|
|
312
|
+
expect(result.errors.some((e) => e.includes("does not exist"))).toBe(true);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("creates a new branch when checkout fails and createBranch is true", () => {
|
|
316
|
+
// The simple checkout should fail, then the "checkout -b" fallback should succeed.
|
|
317
|
+
mockExecSync.mockImplementation((...args: unknown[]) => {
|
|
318
|
+
const cmd = String(args[0] ?? "");
|
|
319
|
+
// Match simple checkout (no -b flag) — the -b flag follows "checkout" directly
|
|
320
|
+
if (cmd.includes("git checkout") && !cmd.includes("checkout -b")) {
|
|
321
|
+
throw new Error("pathspec did not match");
|
|
322
|
+
}
|
|
323
|
+
return "";
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const manager = new ContainerManager();
|
|
327
|
+
const result = manager.gitOpsInContainer("cid-123", {
|
|
328
|
+
branch: "feat/new",
|
|
329
|
+
currentBranch: "main",
|
|
330
|
+
createBranch: true,
|
|
331
|
+
defaultBranch: "main",
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
expect(result.checkoutOk).toBe(true);
|
|
335
|
+
const cmds = mockExecSync.mock.calls.map((c) => String(c[0] ?? ""));
|
|
336
|
+
expect(cmds.some((c) => c.includes("checkout -b"))).toBe(true);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("treats pull failure as non-fatal", () => {
|
|
340
|
+
mockExecSync.mockImplementation((...args: unknown[]) => {
|
|
341
|
+
const cmd = String(args[0] ?? "");
|
|
342
|
+
if (cmd.includes("git pull")) throw new Error("no tracking info");
|
|
343
|
+
return "";
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
const manager = new ContainerManager();
|
|
347
|
+
const result = manager.gitOpsInContainer("cid-123", {
|
|
348
|
+
branch: "feat/new",
|
|
349
|
+
currentBranch: "main",
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
expect(result.pullOk).toBe(false);
|
|
353
|
+
expect(result.checkoutOk).toBe(true);
|
|
354
|
+
expect(result.errors.some((e) => e.includes("pull:"))).toBe(true);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("skips checkout when currentBranch matches requested branch", () => {
|
|
358
|
+
mockExecSync.mockReturnValue("");
|
|
359
|
+
|
|
360
|
+
const manager = new ContainerManager();
|
|
361
|
+
const result = manager.gitOpsInContainer("cid-123", {
|
|
362
|
+
branch: "main",
|
|
363
|
+
currentBranch: "main",
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
expect(result.checkoutOk).toBe(true);
|
|
367
|
+
const cmds = mockExecSync.mock.calls.map((c) => String(c[0] ?? ""));
|
|
368
|
+
// Should not have any checkout command
|
|
369
|
+
expect(cmds.some((c) => c.includes("git checkout"))).toBe(false);
|
|
370
|
+
// But should still fetch and pull
|
|
371
|
+
expect(cmds.some((c) => c.includes("git fetch"))).toBe(true);
|
|
372
|
+
expect(cmds.some((c) => c.includes("git pull"))).toBe(true);
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
// Docker daemon checks
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
describe("ContainerManager checkDocker", () => {
|
|
381
|
+
beforeEach(() => {
|
|
382
|
+
mockExecSync.mockReset();
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("returns true when docker info succeeds", () => {
|
|
386
|
+
mockExecSync.mockReturnValue("24.0.7");
|
|
387
|
+
const manager = new ContainerManager();
|
|
388
|
+
expect(manager.checkDocker()).toBe(true);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("returns false when docker info fails", () => {
|
|
392
|
+
mockExecSync.mockImplementation(() => { throw new Error("not found"); });
|
|
393
|
+
const manager = new ContainerManager();
|
|
394
|
+
expect(manager.checkDocker()).toBe(false);
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
describe("ContainerManager getDockerVersion", () => {
|
|
399
|
+
beforeEach(() => {
|
|
400
|
+
mockExecSync.mockReset();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("returns version string on success", () => {
|
|
404
|
+
mockExecSync.mockReturnValue("24.0.7");
|
|
405
|
+
const manager = new ContainerManager();
|
|
406
|
+
expect(manager.getDockerVersion()).toBe("24.0.7");
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("returns null on failure", () => {
|
|
410
|
+
mockExecSync.mockImplementation(() => { throw new Error("not found"); });
|
|
411
|
+
const manager = new ContainerManager();
|
|
412
|
+
expect(manager.getDockerVersion()).toBeNull();
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
// Image operations
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
describe("ContainerManager listImages", () => {
|
|
421
|
+
beforeEach(() => {
|
|
422
|
+
mockExecSync.mockReset();
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("returns parsed image list", () => {
|
|
426
|
+
mockExecSync.mockReturnValue("node:22\nubuntu:latest\npython:3.12");
|
|
427
|
+
const manager = new ContainerManager();
|
|
428
|
+
expect(manager.listImages()).toEqual(["node:22", "python:3.12", "ubuntu:latest"]);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("filters out <none> entries", () => {
|
|
432
|
+
mockExecSync.mockReturnValue("<none>:latest\nnode:22");
|
|
433
|
+
const manager = new ContainerManager();
|
|
434
|
+
expect(manager.listImages()).toEqual(["node:22"]);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("returns empty array when docker command fails", () => {
|
|
438
|
+
mockExecSync.mockImplementation(() => { throw new Error("fail"); });
|
|
439
|
+
const manager = new ContainerManager();
|
|
440
|
+
expect(manager.listImages()).toEqual([]);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("returns empty array when output is empty", () => {
|
|
444
|
+
mockExecSync.mockReturnValue("");
|
|
445
|
+
const manager = new ContainerManager();
|
|
446
|
+
expect(manager.listImages()).toEqual([]);
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
describe("ContainerManager imageExists", () => {
|
|
451
|
+
beforeEach(() => {
|
|
452
|
+
mockExecSync.mockReset();
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("returns true when image inspect succeeds", () => {
|
|
456
|
+
mockExecSync.mockReturnValue("[]");
|
|
457
|
+
const manager = new ContainerManager();
|
|
458
|
+
expect(manager.imageExists("node:22")).toBe(true);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("returns false when image inspect fails", () => {
|
|
462
|
+
mockExecSync.mockImplementation(() => { throw new Error("not found"); });
|
|
463
|
+
const manager = new ContainerManager();
|
|
464
|
+
expect(manager.imageExists("nonexistent:latest")).toBe(false);
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
// Container execution
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
describe("ContainerManager execInContainer", () => {
|
|
473
|
+
beforeEach(() => {
|
|
474
|
+
mockExecSync.mockReset();
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it("runs docker exec with properly escaped args", () => {
|
|
478
|
+
mockExecSync.mockReturnValue("hello world");
|
|
479
|
+
const manager = new ContainerManager();
|
|
480
|
+
const result = manager.execInContainer("abc123", ["sh", "-c", "echo hello"]);
|
|
481
|
+
expect(result).toBe("hello world");
|
|
482
|
+
const cmd = String(mockExecSync.mock.calls[0]?.[0] ?? "");
|
|
483
|
+
expect(cmd).toContain("docker exec");
|
|
484
|
+
expect(cmd).toContain("abc123");
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("throws on invalid container ID", () => {
|
|
488
|
+
const manager = new ContainerManager();
|
|
489
|
+
expect(() => manager.execInContainer("../evil", ["ls"])).toThrow("Invalid container ID");
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it("throws on container ID starting with hyphen", () => {
|
|
493
|
+
const manager = new ContainerManager();
|
|
494
|
+
expect(() => manager.execInContainer("-bad", ["ls"])).toThrow("Invalid container ID");
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// ---------------------------------------------------------------------------
|
|
499
|
+
// Container tracking (retrack, getContainer, getContainerById, listContainers)
|
|
500
|
+
// ---------------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
describe("ContainerManager tracking", () => {
|
|
503
|
+
beforeEach(() => {
|
|
504
|
+
mockExecSync.mockReset();
|
|
505
|
+
mockExistsSync.mockReset();
|
|
506
|
+
mockExistsSync.mockReturnValue(false);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("retrack moves container to new session key", () => {
|
|
510
|
+
// Manually set up a container in the manager's internal map via restoreContainer
|
|
511
|
+
mockExecSync.mockReturnValue("true"); // docker inspect returns "true" (running)
|
|
512
|
+
const manager = new ContainerManager();
|
|
513
|
+
const info = {
|
|
514
|
+
containerId: "abc123def456",
|
|
515
|
+
name: "companion-abc123de",
|
|
516
|
+
image: "node:22",
|
|
517
|
+
portMappings: [],
|
|
518
|
+
hostCwd: "/tmp",
|
|
519
|
+
containerCwd: "/workspace",
|
|
520
|
+
state: "running" as const,
|
|
521
|
+
};
|
|
522
|
+
manager.restoreContainer("old-session", info);
|
|
523
|
+
expect(manager.getContainer("old-session")).toBeDefined();
|
|
524
|
+
|
|
525
|
+
manager.retrack("abc123def456", "new-session");
|
|
526
|
+
expect(manager.getContainer("old-session")).toBeUndefined();
|
|
527
|
+
expect(manager.getContainer("new-session")).toBeDefined();
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("retrack is a no-op when containerId is not tracked", () => {
|
|
531
|
+
const manager = new ContainerManager();
|
|
532
|
+
// Should not throw
|
|
533
|
+
manager.retrack("nonexistent", "new-session");
|
|
534
|
+
expect(manager.listContainers()).toHaveLength(0);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it("getContainerById finds container by docker ID", () => {
|
|
538
|
+
mockExecSync.mockReturnValue("true");
|
|
539
|
+
const manager = new ContainerManager();
|
|
540
|
+
const info = {
|
|
541
|
+
containerId: "abc123def456",
|
|
542
|
+
name: "companion-abc123de",
|
|
543
|
+
image: "node:22",
|
|
544
|
+
portMappings: [],
|
|
545
|
+
hostCwd: "/tmp",
|
|
546
|
+
containerCwd: "/workspace",
|
|
547
|
+
state: "running" as const,
|
|
548
|
+
};
|
|
549
|
+
manager.restoreContainer("sess-1", info);
|
|
550
|
+
expect(manager.getContainerById("abc123def456")).toBeDefined();
|
|
551
|
+
expect(manager.getContainerById("nonexistent")).toBeUndefined();
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it("listContainers returns all tracked containers", () => {
|
|
555
|
+
mockExecSync.mockReturnValue("true");
|
|
556
|
+
const manager = new ContainerManager();
|
|
557
|
+
expect(manager.listContainers()).toHaveLength(0);
|
|
558
|
+
|
|
559
|
+
manager.restoreContainer("s1", {
|
|
560
|
+
containerId: "c1", name: "n1", image: "i1",
|
|
561
|
+
portMappings: [], hostCwd: "/a", containerCwd: "/workspace", state: "running",
|
|
562
|
+
});
|
|
563
|
+
manager.restoreContainer("s2", {
|
|
564
|
+
containerId: "c2", name: "n2", image: "i2",
|
|
565
|
+
portMappings: [], hostCwd: "/b", containerCwd: "/workspace", state: "running",
|
|
566
|
+
});
|
|
567
|
+
expect(manager.listContainers()).toHaveLength(2);
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
// ---------------------------------------------------------------------------
|
|
572
|
+
// removeContainer
|
|
573
|
+
// ---------------------------------------------------------------------------
|
|
574
|
+
|
|
575
|
+
describe("ContainerManager removeContainer", () => {
|
|
576
|
+
beforeEach(() => {
|
|
577
|
+
mockExecSync.mockReset();
|
|
578
|
+
mockExistsSync.mockReset();
|
|
579
|
+
mockExistsSync.mockReturnValue(false);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it("removes container and volume from docker and internal map", () => {
|
|
583
|
+
// Set up a tracked container
|
|
584
|
+
mockExecSync.mockReturnValue("true");
|
|
585
|
+
const manager = new ContainerManager();
|
|
586
|
+
manager.restoreContainer("sess-1", {
|
|
587
|
+
containerId: "abc123", name: "companion-abc", image: "node:22",
|
|
588
|
+
portMappings: [], hostCwd: "/tmp", containerCwd: "/workspace",
|
|
589
|
+
state: "running", volumeName: "companion-ws-abc",
|
|
590
|
+
});
|
|
591
|
+
expect(manager.getContainer("sess-1")).toBeDefined();
|
|
592
|
+
|
|
593
|
+
// Reset so we can track removal calls
|
|
594
|
+
mockExecSync.mockReset();
|
|
595
|
+
mockExecSync.mockReturnValue("");
|
|
596
|
+
manager.removeContainer("sess-1");
|
|
597
|
+
|
|
598
|
+
expect(manager.getContainer("sess-1")).toBeUndefined();
|
|
599
|
+
const cmds = mockExecSync.mock.calls.map((c) => String(c[0] ?? ""));
|
|
600
|
+
expect(cmds.some((c) => c.includes("docker rm -f"))).toBe(true);
|
|
601
|
+
expect(cmds.some((c) => c.includes("docker volume rm"))).toBe(true);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it("is a no-op when session is not tracked", () => {
|
|
605
|
+
const manager = new ContainerManager();
|
|
606
|
+
// Should not throw
|
|
607
|
+
manager.removeContainer("nonexistent");
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it("continues cleanup even when docker rm fails", () => {
|
|
611
|
+
mockExecSync.mockReturnValue("true");
|
|
612
|
+
const manager = new ContainerManager();
|
|
613
|
+
manager.restoreContainer("sess-1", {
|
|
614
|
+
containerId: "abc123", name: "companion-abc", image: "node:22",
|
|
615
|
+
portMappings: [], hostCwd: "/tmp", containerCwd: "/workspace",
|
|
616
|
+
state: "running", volumeName: "vol-1",
|
|
617
|
+
});
|
|
618
|
+
// Make docker rm fail but volume rm succeed
|
|
619
|
+
mockExecSync.mockReset();
|
|
620
|
+
mockExecSync.mockImplementation((...args: unknown[]) => {
|
|
621
|
+
const cmd = String(args[0] ?? "");
|
|
622
|
+
if (cmd.includes("docker rm")) throw new Error("rm failed");
|
|
623
|
+
return "";
|
|
624
|
+
});
|
|
625
|
+
// Should not throw — removal is best-effort
|
|
626
|
+
manager.removeContainer("sess-1");
|
|
627
|
+
expect(manager.getContainer("sess-1")).toBeUndefined();
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
// ---------------------------------------------------------------------------
|
|
632
|
+
// isContainerAlive
|
|
633
|
+
// ---------------------------------------------------------------------------
|
|
634
|
+
|
|
635
|
+
describe("ContainerManager isContainerAlive", () => {
|
|
636
|
+
beforeEach(() => {
|
|
637
|
+
mockExecSync.mockReset();
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it("returns 'running' when docker inspect shows true", () => {
|
|
641
|
+
mockExecSync.mockReturnValue("true");
|
|
642
|
+
const manager = new ContainerManager();
|
|
643
|
+
expect(manager.isContainerAlive("abc123")).toBe("running");
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it("returns 'stopped' when docker inspect shows false", () => {
|
|
647
|
+
mockExecSync.mockReturnValue("false");
|
|
648
|
+
const manager = new ContainerManager();
|
|
649
|
+
expect(manager.isContainerAlive("abc123")).toBe("stopped");
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it("returns 'missing' when docker inspect throws", () => {
|
|
653
|
+
mockExecSync.mockImplementation(() => { throw new Error("not found"); });
|
|
654
|
+
const manager = new ContainerManager();
|
|
655
|
+
expect(manager.isContainerAlive("abc123")).toBe("missing");
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
// ---------------------------------------------------------------------------
|
|
660
|
+
// hasBinaryInContainer
|
|
661
|
+
// ---------------------------------------------------------------------------
|
|
662
|
+
|
|
663
|
+
describe("ContainerManager hasBinaryInContainer", () => {
|
|
664
|
+
beforeEach(() => {
|
|
665
|
+
mockExecSync.mockReset();
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it("returns true when which finds the binary", () => {
|
|
669
|
+
mockExecSync.mockReturnValue("/usr/bin/node");
|
|
670
|
+
const manager = new ContainerManager();
|
|
671
|
+
expect(manager.hasBinaryInContainer("abc123", "node")).toBe(true);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
it("returns false when which fails", () => {
|
|
675
|
+
mockExecSync.mockImplementation(() => { throw new Error("not found"); });
|
|
676
|
+
const manager = new ContainerManager();
|
|
677
|
+
expect(manager.hasBinaryInContainer("abc123", "nonexistent")).toBe(false);
|
|
678
|
+
});
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
// ---------------------------------------------------------------------------
|
|
682
|
+
// startContainer
|
|
683
|
+
// ---------------------------------------------------------------------------
|
|
684
|
+
|
|
685
|
+
describe("ContainerManager startContainer", () => {
|
|
686
|
+
beforeEach(() => {
|
|
687
|
+
mockExecSync.mockReset();
|
|
688
|
+
mockExistsSync.mockReset();
|
|
689
|
+
mockExistsSync.mockReturnValue(false);
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it("runs docker start and re-seeds auth files", () => {
|
|
693
|
+
// startContainer calls docker start, seedAuthFiles, seedCodexFiles, seedGitAuth
|
|
694
|
+
mockExecSync.mockReturnValue("");
|
|
695
|
+
const manager = new ContainerManager();
|
|
696
|
+
manager.startContainer("abc123");
|
|
697
|
+
|
|
698
|
+
const cmds = mockExecSync.mock.calls.map((c) => String(c[0] ?? ""));
|
|
699
|
+
expect(cmds[0]).toContain("docker start");
|
|
700
|
+
// Should have multiple docker exec calls for seeding
|
|
701
|
+
expect(cmds.length).toBeGreaterThan(1);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it("throws on invalid container ID", () => {
|
|
705
|
+
const manager = new ContainerManager();
|
|
706
|
+
expect(() => manager.startContainer("../evil")).toThrow("Invalid container ID");
|
|
707
|
+
});
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
// ---------------------------------------------------------------------------
|
|
711
|
+
// restoreContainer
|
|
712
|
+
// ---------------------------------------------------------------------------
|
|
713
|
+
|
|
714
|
+
describe("ContainerManager restoreContainer", () => {
|
|
715
|
+
beforeEach(() => {
|
|
716
|
+
mockExecSync.mockReset();
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
it("tracks a running container", () => {
|
|
720
|
+
mockExecSync.mockReturnValue("true");
|
|
721
|
+
const manager = new ContainerManager();
|
|
722
|
+
const info = {
|
|
723
|
+
containerId: "abc123", name: "test", image: "node:22",
|
|
724
|
+
portMappings: [], hostCwd: "/tmp", containerCwd: "/workspace",
|
|
725
|
+
state: "stopped" as const,
|
|
726
|
+
};
|
|
727
|
+
const ok = manager.restoreContainer("sess-1", info);
|
|
728
|
+
expect(ok).toBe(true);
|
|
729
|
+
expect(info.state).toBe("running");
|
|
730
|
+
expect(manager.getContainer("sess-1")).toBe(info);
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it("tracks a stopped container", () => {
|
|
734
|
+
mockExecSync.mockReturnValue("false");
|
|
735
|
+
const manager = new ContainerManager();
|
|
736
|
+
const info = {
|
|
737
|
+
containerId: "abc123", name: "test", image: "node:22",
|
|
738
|
+
portMappings: [], hostCwd: "/tmp", containerCwd: "/workspace",
|
|
739
|
+
state: "running" as const,
|
|
740
|
+
};
|
|
741
|
+
const ok = manager.restoreContainer("sess-1", info);
|
|
742
|
+
expect(ok).toBe(true);
|
|
743
|
+
expect(info.state).toBe("stopped");
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
it("returns false when container no longer exists", () => {
|
|
747
|
+
mockExecSync.mockImplementation(() => { throw new Error("not found"); });
|
|
748
|
+
const manager = new ContainerManager();
|
|
749
|
+
const info = {
|
|
750
|
+
containerId: "abc123", name: "test", image: "node:22",
|
|
751
|
+
portMappings: [], hostCwd: "/tmp", containerCwd: "/workspace",
|
|
752
|
+
state: "running" as const,
|
|
753
|
+
};
|
|
754
|
+
const ok = manager.restoreContainer("sess-1", info);
|
|
755
|
+
expect(ok).toBe(false);
|
|
756
|
+
expect(manager.getContainer("sess-1")).toBeUndefined();
|
|
757
|
+
});
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
// ---------------------------------------------------------------------------
|
|
761
|
+
// persistState / restoreState
|
|
762
|
+
// ---------------------------------------------------------------------------
|
|
763
|
+
|
|
764
|
+
describe("ContainerManager persistState", () => {
|
|
765
|
+
beforeEach(() => {
|
|
766
|
+
mockExecSync.mockReset();
|
|
767
|
+
mockWriteFileSync.mockReset();
|
|
768
|
+
mockExistsSync.mockReset();
|
|
769
|
+
mockExistsSync.mockReturnValue(false);
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
it("writes tracked containers to disk as JSON", () => {
|
|
773
|
+
mockExecSync.mockReturnValue("true");
|
|
774
|
+
const manager = new ContainerManager();
|
|
775
|
+
manager.restoreContainer("sess-1", {
|
|
776
|
+
containerId: "c1", name: "n1", image: "i1",
|
|
777
|
+
portMappings: [], hostCwd: "/a", containerCwd: "/workspace", state: "running",
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
manager.persistState("/tmp/state.json");
|
|
781
|
+
|
|
782
|
+
expect(mockWriteFileSync).toHaveBeenCalledOnce();
|
|
783
|
+
const [path, content] = mockWriteFileSync.mock.calls[0] as [string, string, string];
|
|
784
|
+
expect(path).toBe("/tmp/state.json");
|
|
785
|
+
const parsed = JSON.parse(content);
|
|
786
|
+
expect(parsed).toHaveLength(1);
|
|
787
|
+
expect(parsed[0].sessionId).toBe("sess-1");
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
it("excludes removed containers from persisted state", () => {
|
|
791
|
+
mockExecSync.mockReturnValue("true");
|
|
792
|
+
const manager = new ContainerManager();
|
|
793
|
+
manager.restoreContainer("sess-1", {
|
|
794
|
+
containerId: "c1", name: "n1", image: "i1",
|
|
795
|
+
portMappings: [], hostCwd: "/a", containerCwd: "/workspace", state: "running",
|
|
796
|
+
});
|
|
797
|
+
// Remove the container so state = "removed"
|
|
798
|
+
mockExecSync.mockReset();
|
|
799
|
+
mockExecSync.mockReturnValue("");
|
|
800
|
+
manager.removeContainer("sess-1");
|
|
801
|
+
|
|
802
|
+
mockWriteFileSync.mockReset();
|
|
803
|
+
manager.persistState("/tmp/state.json");
|
|
804
|
+
|
|
805
|
+
const [, content] = mockWriteFileSync.mock.calls[0] as [string, string, string];
|
|
806
|
+
expect(JSON.parse(content)).toHaveLength(0);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
it("does not throw when write fails", () => {
|
|
810
|
+
mockWriteFileSync.mockImplementation(() => { throw new Error("EACCES"); });
|
|
811
|
+
const manager = new ContainerManager();
|
|
812
|
+
expect(() => manager.persistState("/tmp/state.json")).not.toThrow();
|
|
813
|
+
});
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
describe("ContainerManager restoreState", () => {
|
|
817
|
+
beforeEach(() => {
|
|
818
|
+
mockExecSync.mockReset();
|
|
819
|
+
mockReadFileSync.mockReset();
|
|
820
|
+
mockExistsSync.mockReset();
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
it("returns 0 when file does not exist", () => {
|
|
824
|
+
mockExistsSync.mockReturnValue(false);
|
|
825
|
+
const manager = new ContainerManager();
|
|
826
|
+
expect(manager.restoreState("/tmp/state.json")).toBe(0);
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
it("restores containers from disk", () => {
|
|
830
|
+
mockExistsSync.mockReturnValue(true);
|
|
831
|
+
mockExecSync.mockReturnValue("true"); // container is running
|
|
832
|
+
mockReadFileSync.mockReturnValue(JSON.stringify([
|
|
833
|
+
{ sessionId: "s1", info: { containerId: "c1", name: "n1", image: "i1", portMappings: [], hostCwd: "/a", containerCwd: "/workspace", state: "running" } },
|
|
834
|
+
]));
|
|
835
|
+
|
|
836
|
+
const manager = new ContainerManager();
|
|
837
|
+
const count = manager.restoreState("/tmp/state.json");
|
|
838
|
+
expect(count).toBe(1);
|
|
839
|
+
expect(manager.getContainer("s1")).toBeDefined();
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
it("returns 0 when file is corrupt", () => {
|
|
843
|
+
mockExistsSync.mockReturnValue(true);
|
|
844
|
+
mockReadFileSync.mockReturnValue("not json");
|
|
845
|
+
const manager = new ContainerManager();
|
|
846
|
+
expect(manager.restoreState("/tmp/state.json")).toBe(0);
|
|
847
|
+
});
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
// ---------------------------------------------------------------------------
|
|
851
|
+
// buildImage
|
|
852
|
+
// ---------------------------------------------------------------------------
|
|
853
|
+
|
|
854
|
+
describe("ContainerManager buildImage", () => {
|
|
855
|
+
beforeEach(() => {
|
|
856
|
+
mockExecSync.mockReset();
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
it("runs docker build and returns output", () => {
|
|
860
|
+
mockExecSync.mockReturnValue("Successfully built abc123");
|
|
861
|
+
const manager = new ContainerManager();
|
|
862
|
+
const output = manager.buildImage("/tmp/Dockerfile", "test:latest");
|
|
863
|
+
expect(output).toBe("Successfully built abc123");
|
|
864
|
+
const cmd = String(mockExecSync.mock.calls[0]?.[0] ?? "");
|
|
865
|
+
expect(cmd).toContain("docker build");
|
|
866
|
+
expect(cmd).toContain("-t test:latest");
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
it("throws with descriptive error on build failure", () => {
|
|
870
|
+
mockExecSync.mockImplementation(() => { throw new Error("build error"); });
|
|
871
|
+
const manager = new ContainerManager();
|
|
872
|
+
expect(() => manager.buildImage("/tmp/Dockerfile")).toThrow("Failed to build image");
|
|
873
|
+
});
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
// ---------------------------------------------------------------------------
|
|
877
|
+
// getRegistryImage (static)
|
|
878
|
+
// ---------------------------------------------------------------------------
|
|
879
|
+
|
|
880
|
+
describe("ContainerManager.getRegistryImage", () => {
|
|
881
|
+
it("returns registry path for the-companion:latest", () => {
|
|
882
|
+
const result = ContainerManager.getRegistryImage("the-companion:latest");
|
|
883
|
+
expect(result).toContain("stangirard/the-companion:latest");
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
it("returns null for non-default images", () => {
|
|
887
|
+
expect(ContainerManager.getRegistryImage("node:22")).toBeNull();
|
|
888
|
+
expect(ContainerManager.getRegistryImage("custom:v1")).toBeNull();
|
|
889
|
+
});
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
// ---------------------------------------------------------------------------
|
|
893
|
+
// cleanupAll
|
|
894
|
+
// ---------------------------------------------------------------------------
|
|
895
|
+
|
|
896
|
+
describe("ContainerManager cleanupAll", () => {
|
|
897
|
+
beforeEach(() => {
|
|
898
|
+
mockExecSync.mockReset();
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
it("removes all tracked containers", () => {
|
|
902
|
+
mockExecSync.mockReturnValue("true"); // for restoreContainer
|
|
903
|
+
const manager = new ContainerManager();
|
|
904
|
+
manager.restoreContainer("s1", {
|
|
905
|
+
containerId: "c1", name: "n1", image: "i1",
|
|
906
|
+
portMappings: [], hostCwd: "/a", containerCwd: "/workspace", state: "running",
|
|
907
|
+
});
|
|
908
|
+
manager.restoreContainer("s2", {
|
|
909
|
+
containerId: "c2", name: "n2", image: "i2",
|
|
910
|
+
portMappings: [], hostCwd: "/b", containerCwd: "/workspace", state: "running",
|
|
911
|
+
});
|
|
912
|
+
expect(manager.listContainers()).toHaveLength(2);
|
|
913
|
+
|
|
914
|
+
mockExecSync.mockReset();
|
|
915
|
+
mockExecSync.mockReturnValue("");
|
|
916
|
+
manager.cleanupAll();
|
|
917
|
+
expect(manager.listContainers()).toHaveLength(0);
|
|
918
|
+
});
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
// ---------------------------------------------------------------------------
|
|
922
|
+
// createContainer (full flow with mocked docker commands)
|
|
923
|
+
// ---------------------------------------------------------------------------
|
|
924
|
+
|
|
925
|
+
describe("ContainerManager createContainer", () => {
|
|
926
|
+
beforeEach(() => {
|
|
927
|
+
mockExecSync.mockReset();
|
|
928
|
+
mockExistsSync.mockReset();
|
|
929
|
+
mockExistsSync.mockReturnValue(false);
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
it("creates a container with volume, ports, and auth seeding", () => {
|
|
933
|
+
// Mock the sequence of docker commands:
|
|
934
|
+
// 1. docker volume create
|
|
935
|
+
// 2. docker create → returns container ID
|
|
936
|
+
// 3. docker start
|
|
937
|
+
// 4-N. seedAuthFiles, seedCodexFiles, seedGitAuth (all docker exec)
|
|
938
|
+
// last. docker port → returns port mapping
|
|
939
|
+
let callCount = 0;
|
|
940
|
+
mockExecSync.mockImplementation((...args: unknown[]) => {
|
|
941
|
+
callCount++;
|
|
942
|
+
const cmd = String(args[0] ?? "");
|
|
943
|
+
if (cmd.includes("docker volume create")) return "companion-ws-test1234";
|
|
944
|
+
if (cmd.startsWith("docker create") || cmd.startsWith("'docker' 'create'") || cmd.includes("docker create")) return "abcdef1234567890";
|
|
945
|
+
if (cmd.includes("docker start")) return "";
|
|
946
|
+
if (cmd.includes("docker port")) return "0.0.0.0:49152";
|
|
947
|
+
if (cmd.includes("gh auth token")) throw new Error("no token");
|
|
948
|
+
return "";
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
const manager = new ContainerManager();
|
|
952
|
+
const info = manager.createContainer("test1234-5678-abcd", "/tmp/workspace", {
|
|
953
|
+
image: "node:22",
|
|
954
|
+
ports: [3000],
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
expect(info.containerId).toBe("abcdef1234567890");
|
|
958
|
+
expect(info.state).toBe("running");
|
|
959
|
+
expect(info.portMappings).toHaveLength(1);
|
|
960
|
+
expect(info.portMappings[0].hostPort).toBe(49152);
|
|
961
|
+
expect(info.portMappings[0].containerPort).toBe(3000);
|
|
962
|
+
expect(info.volumeName).toBe("companion-ws-test1234");
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
it("rejects invalid port numbers", () => {
|
|
966
|
+
const manager = new ContainerManager();
|
|
967
|
+
expect(() => manager.createContainer("sess-1", "/tmp", {
|
|
968
|
+
image: "node:22", ports: [0],
|
|
969
|
+
})).toThrow("Invalid port number: 0");
|
|
970
|
+
|
|
971
|
+
expect(() => manager.createContainer("sess-2", "/tmp", {
|
|
972
|
+
image: "node:22", ports: [99999],
|
|
973
|
+
})).toThrow("Invalid port number: 99999");
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
it("cleans up on creation failure", () => {
|
|
977
|
+
mockExecSync.mockImplementation((...args: unknown[]) => {
|
|
978
|
+
const cmd = String(args[0] ?? "");
|
|
979
|
+
if (cmd.includes("docker volume create")) return "vol-123";
|
|
980
|
+
if (cmd.includes("docker create")) throw new Error("image not found");
|
|
981
|
+
return "";
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
const manager = new ContainerManager();
|
|
985
|
+
expect(() => manager.createContainer("sess-1", "/tmp", {
|
|
986
|
+
image: "nonexistent:v1", ports: [],
|
|
987
|
+
})).toThrow("Failed to create container");
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
it("includes extra volumes and env vars in docker create args", () => {
|
|
991
|
+
let createCmd = "";
|
|
992
|
+
mockExecSync.mockImplementation((...args: unknown[]) => {
|
|
993
|
+
const cmd = String(args[0] ?? "");
|
|
994
|
+
if (cmd.includes("docker volume create")) return "vol-1";
|
|
995
|
+
if (cmd.includes("docker create")) { createCmd = cmd; return "cid123"; }
|
|
996
|
+
if (cmd.includes("docker start")) return "";
|
|
997
|
+
if (cmd.includes("docker port")) return "0.0.0.0:8080";
|
|
998
|
+
if (cmd.includes("gh auth token")) throw new Error("no");
|
|
999
|
+
return "";
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
const manager = new ContainerManager();
|
|
1003
|
+
manager.createContainer("sess-1", "/tmp/ws", {
|
|
1004
|
+
image: "node:22",
|
|
1005
|
+
ports: [3000],
|
|
1006
|
+
volumes: ["/data:/data:ro"],
|
|
1007
|
+
env: { NODE_ENV: "production" },
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
expect(createCmd).toContain("/data:/data:ro");
|
|
1011
|
+
expect(createCmd).toContain("NODE_ENV=production");
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
it("mounts host .gitconfig when it exists", () => {
|
|
1015
|
+
let createCmd = "";
|
|
1016
|
+
mockExistsSync.mockImplementation((...args: unknown[]) => {
|
|
1017
|
+
const path = String(args[0] ?? "");
|
|
1018
|
+
return path.endsWith(".gitconfig");
|
|
1019
|
+
});
|
|
1020
|
+
mockExecSync.mockImplementation((...args: unknown[]) => {
|
|
1021
|
+
const cmd = String(args[0] ?? "");
|
|
1022
|
+
if (cmd.includes("docker volume create")) return "vol-1";
|
|
1023
|
+
if (cmd.includes("docker create")) { createCmd = cmd; return "cid123"; }
|
|
1024
|
+
if (cmd.includes("docker start")) return "";
|
|
1025
|
+
if (cmd.includes("docker port")) return "";
|
|
1026
|
+
if (cmd.includes("gh auth token")) throw new Error("no");
|
|
1027
|
+
return "";
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
const manager = new ContainerManager();
|
|
1031
|
+
manager.createContainer("sess-1", "/tmp", { image: "node:22", ports: [] });
|
|
1032
|
+
expect(createCmd).toContain("companion-host-gitconfig");
|
|
1033
|
+
});
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
// ---------------------------------------------------------------------------
|
|
1037
|
+
// seedAuthFiles (private, tested via startContainer which calls it)
|
|
1038
|
+
// ---------------------------------------------------------------------------
|
|
1039
|
+
|
|
1040
|
+
describe("ContainerManager seedAuthFiles via startContainer", () => {
|
|
1041
|
+
beforeEach(() => {
|
|
1042
|
+
mockExecSync.mockReset();
|
|
1043
|
+
mockExistsSync.mockReset();
|
|
1044
|
+
mockExistsSync.mockReturnValue(false);
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
it("copies auth files from /companion-host-claude to /root/.claude", () => {
|
|
1048
|
+
mockExecSync.mockReturnValue("");
|
|
1049
|
+
const manager = new ContainerManager();
|
|
1050
|
+
manager.startContainer("abc123");
|
|
1051
|
+
|
|
1052
|
+
const cmds = mockExecSync.mock.calls.map((c) => String(c[0] ?? ""));
|
|
1053
|
+
// seedAuthFiles runs a docker exec with companion-host-claude
|
|
1054
|
+
expect(cmds.some((c) => c.includes("companion-host-claude") && c.includes("/root/.claude"))).toBe(true);
|
|
1055
|
+
});
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
// ---------------------------------------------------------------------------
|
|
1059
|
+
// copyWorkspaceToContainer — validates container ID
|
|
1060
|
+
// ---------------------------------------------------------------------------
|
|
1061
|
+
|
|
1062
|
+
describe("ContainerManager copyWorkspaceToContainer validation", () => {
|
|
1063
|
+
it("rejects invalid container ID", async () => {
|
|
1064
|
+
const manager = new ContainerManager();
|
|
1065
|
+
await expect(manager.copyWorkspaceToContainer("../evil", "/tmp"))
|
|
1066
|
+
.rejects.toThrow("Invalid container ID");
|
|
1067
|
+
});
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
// ---------------------------------------------------------------------------
|
|
1071
|
+
// pullImage
|
|
1072
|
+
// ---------------------------------------------------------------------------
|
|
1073
|
+
|
|
1074
|
+
describe("ContainerManager pullImage", () => {
|
|
1075
|
+
beforeEach(() => {
|
|
1076
|
+
mockSpawn.mockReset();
|
|
1077
|
+
mockExecSync.mockReset();
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
it("returns true and tags image on successful pull", async () => {
|
|
1081
|
+
// Mock Bun.spawn to return a successful process with readable streams
|
|
1082
|
+
const mockStdout = new ReadableStream<Uint8Array>({
|
|
1083
|
+
start(controller) { controller.close(); },
|
|
1084
|
+
});
|
|
1085
|
+
const mockStderr = new ReadableStream<Uint8Array>({
|
|
1086
|
+
start(controller) { controller.close(); },
|
|
1087
|
+
});
|
|
1088
|
+
mockSpawn.mockReturnValue({
|
|
1089
|
+
stdout: mockStdout,
|
|
1090
|
+
stderr: mockStderr,
|
|
1091
|
+
exited: Promise.resolve(0),
|
|
1092
|
+
kill: vi.fn(),
|
|
1093
|
+
});
|
|
1094
|
+
mockExecSync.mockReturnValue("");
|
|
1095
|
+
|
|
1096
|
+
const manager = new ContainerManager();
|
|
1097
|
+
const result = await manager.pullImage("docker.io/stangirard/test:v1", "test:v1");
|
|
1098
|
+
expect(result).toBe(true);
|
|
1099
|
+
// Should tag the image
|
|
1100
|
+
const cmds = mockExecSync.mock.calls.map((c) => String(c[0] ?? ""));
|
|
1101
|
+
expect(cmds.some((c) => c.includes("docker tag"))).toBe(true);
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
it("returns false when pull fails with non-zero exit", async () => {
|
|
1105
|
+
const mockStdout = new ReadableStream<Uint8Array>({
|
|
1106
|
+
start(controller) { controller.close(); },
|
|
1107
|
+
});
|
|
1108
|
+
const mockStderr = new ReadableStream<Uint8Array>({
|
|
1109
|
+
start(controller) { controller.close(); },
|
|
1110
|
+
});
|
|
1111
|
+
mockSpawn.mockReturnValue({
|
|
1112
|
+
stdout: mockStdout,
|
|
1113
|
+
stderr: mockStderr,
|
|
1114
|
+
exited: Promise.resolve(1),
|
|
1115
|
+
kill: vi.fn(),
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
const manager = new ContainerManager();
|
|
1119
|
+
const result = await manager.pullImage("nonexistent:v1", "nonexistent:v1");
|
|
1120
|
+
expect(result).toBe(false);
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
it("skips tagging when remote and local names match", async () => {
|
|
1124
|
+
const mockStdout = new ReadableStream<Uint8Array>({
|
|
1125
|
+
start(controller) { controller.close(); },
|
|
1126
|
+
});
|
|
1127
|
+
const mockStderr = new ReadableStream<Uint8Array>({
|
|
1128
|
+
start(controller) { controller.close(); },
|
|
1129
|
+
});
|
|
1130
|
+
mockSpawn.mockReturnValue({
|
|
1131
|
+
stdout: mockStdout,
|
|
1132
|
+
stderr: mockStderr,
|
|
1133
|
+
exited: Promise.resolve(0),
|
|
1134
|
+
kill: vi.fn(),
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
const manager = new ContainerManager();
|
|
1138
|
+
await manager.pullImage("node:22", "node:22");
|
|
1139
|
+
// Should NOT call docker tag since names match
|
|
1140
|
+
expect(mockExecSync).not.toHaveBeenCalled();
|
|
1141
|
+
});
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
// ---------------------------------------------------------------------------
|
|
1145
|
+
// buildImageStreaming
|
|
1146
|
+
// ---------------------------------------------------------------------------
|
|
1147
|
+
|
|
1148
|
+
describe("ContainerManager buildImageStreaming", () => {
|
|
1149
|
+
beforeEach(() => {
|
|
1150
|
+
mockSpawn.mockReset();
|
|
1151
|
+
mockMkdirSync.mockReset();
|
|
1152
|
+
mockWriteFileSync.mockReset();
|
|
1153
|
+
mockRmSync.mockReset();
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
it("returns success when build succeeds", async () => {
|
|
1157
|
+
const mockStdout = new ReadableStream<Uint8Array>({
|
|
1158
|
+
start(controller) {
|
|
1159
|
+
controller.enqueue(new TextEncoder().encode("Step 1/3\nStep 2/3\n"));
|
|
1160
|
+
controller.close();
|
|
1161
|
+
},
|
|
1162
|
+
});
|
|
1163
|
+
const mockStderr = new ReadableStream<Uint8Array>({
|
|
1164
|
+
start(controller) { controller.close(); },
|
|
1165
|
+
});
|
|
1166
|
+
mockSpawn.mockReturnValue({
|
|
1167
|
+
stdout: mockStdout,
|
|
1168
|
+
stderr: mockStderr,
|
|
1169
|
+
exited: Promise.resolve(0),
|
|
1170
|
+
kill: vi.fn(),
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
const lines: string[] = [];
|
|
1174
|
+
const manager = new ContainerManager();
|
|
1175
|
+
const result = await manager.buildImageStreaming(
|
|
1176
|
+
"FROM node:22\nRUN echo hi",
|
|
1177
|
+
"test:v1",
|
|
1178
|
+
(line) => lines.push(line),
|
|
1179
|
+
);
|
|
1180
|
+
expect(result.success).toBe(true);
|
|
1181
|
+
expect(lines.length).toBeGreaterThan(0);
|
|
1182
|
+
// Should write Dockerfile to temp dir
|
|
1183
|
+
expect(mockMkdirSync).toHaveBeenCalled();
|
|
1184
|
+
expect(mockWriteFileSync).toHaveBeenCalled();
|
|
1185
|
+
// Should clean up temp dir
|
|
1186
|
+
expect(mockRmSync).toHaveBeenCalled();
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
it("returns failure when build fails", async () => {
|
|
1190
|
+
const mockStdout = new ReadableStream<Uint8Array>({
|
|
1191
|
+
start(controller) { controller.close(); },
|
|
1192
|
+
});
|
|
1193
|
+
const mockStderr = new ReadableStream<Uint8Array>({
|
|
1194
|
+
start(controller) {
|
|
1195
|
+
controller.enqueue(new TextEncoder().encode("ERROR: invalid syntax\n"));
|
|
1196
|
+
controller.close();
|
|
1197
|
+
},
|
|
1198
|
+
});
|
|
1199
|
+
mockSpawn.mockReturnValue({
|
|
1200
|
+
stdout: mockStdout,
|
|
1201
|
+
stderr: mockStderr,
|
|
1202
|
+
exited: Promise.resolve(1),
|
|
1203
|
+
kill: vi.fn(),
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
const manager = new ContainerManager();
|
|
1207
|
+
const result = await manager.buildImageStreaming("INVALID", "test:v1");
|
|
1208
|
+
expect(result.success).toBe(false);
|
|
1209
|
+
expect(result.log).toContain("ERROR: invalid syntax");
|
|
1210
|
+
});
|
|
1211
|
+
});
|