heyhank 0.1.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.
Files changed (199) hide show
  1. package/README.md +40 -0
  2. package/bin/cli.ts +168 -0
  3. package/bin/ctl.ts +528 -0
  4. package/bin/generate-token.ts +28 -0
  5. package/dist/apple-touch-icon.png +0 -0
  6. package/dist/assets/AgentsPage-BPhirnCe.js +7 -0
  7. package/dist/assets/AssistantPage-DJ-cMQfb.js +1 -0
  8. package/dist/assets/CronManager-DDbz-yiT.js +1 -0
  9. package/dist/assets/HelpPage-DMfkzERp.js +1 -0
  10. package/dist/assets/IntegrationsPage-CrOitCmJ.js +1 -0
  11. package/dist/assets/MediaPage-CE5rdvkC.js +1 -0
  12. package/dist/assets/PlatformDashboard-Do6F0O2p.js +1 -0
  13. package/dist/assets/Playground-Fc5cdc5p.js +109 -0
  14. package/dist/assets/ProcessPanel-CslEiZkI.js +2 -0
  15. package/dist/assets/PromptsPage-D2EhsdNO.js +4 -0
  16. package/dist/assets/RunsPage-C5BZF5Rx.js +1 -0
  17. package/dist/assets/SandboxManager-a1AVI5q2.js +8 -0
  18. package/dist/assets/SettingsPage-DirhjQrJ.js +51 -0
  19. package/dist/assets/SocialMediaPage-DBuM28vD.js +1 -0
  20. package/dist/assets/TailscalePage-CHiFhZXF.js +1 -0
  21. package/dist/assets/TelephonyPage-x0VV0fOo.js +1 -0
  22. package/dist/assets/TerminalPage-Drwyrnfd.js +1 -0
  23. package/dist/assets/gemini-audio-t-TSU-To.js +17 -0
  24. package/dist/assets/gemini-live-client-C7rqAW7G.js +166 -0
  25. package/dist/assets/index-C8M_PUmX.css +32 -0
  26. package/dist/assets/index-CEqZnThB.js +204 -0
  27. package/dist/assets/sw-register-LSSpj6RU.js +1 -0
  28. package/dist/assets/time-ago-B6r_l9u1.js +1 -0
  29. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  30. package/dist/favicon-32-original.png +0 -0
  31. package/dist/favicon-32.png +0 -0
  32. package/dist/favicon.ico +0 -0
  33. package/dist/favicon.svg +8 -0
  34. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  35. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  36. package/dist/heyhank-mascot-poster.png +0 -0
  37. package/dist/heyhank-mascot.mp4 +0 -0
  38. package/dist/heyhank-mascot.webm +0 -0
  39. package/dist/icon-192-original.png +0 -0
  40. package/dist/icon-192.png +0 -0
  41. package/dist/icon-512-original.png +0 -0
  42. package/dist/icon-512.png +0 -0
  43. package/dist/index.html +21 -0
  44. package/dist/logo-192.png +0 -0
  45. package/dist/logo-512.png +0 -0
  46. package/dist/logo-codex.svg +14 -0
  47. package/dist/logo-docker.svg +4 -0
  48. package/dist/logo-original.png +0 -0
  49. package/dist/logo.png +0 -0
  50. package/dist/logo.svg +14 -0
  51. package/dist/manifest.json +24 -0
  52. package/dist/push-sw.js +34 -0
  53. package/dist/sw.js +1 -0
  54. package/dist/workbox-d2a0910a.js +1 -0
  55. package/package.json +109 -0
  56. package/server/agent-cron-migrator.ts +85 -0
  57. package/server/agent-executor.ts +357 -0
  58. package/server/agent-store.ts +185 -0
  59. package/server/agent-timeout.ts +107 -0
  60. package/server/agent-types.ts +122 -0
  61. package/server/ai-validation-settings.ts +37 -0
  62. package/server/ai-validator.ts +181 -0
  63. package/server/anthropic-provider-migration.ts +48 -0
  64. package/server/assistant-store.ts +272 -0
  65. package/server/auth-manager.ts +150 -0
  66. package/server/auto-approve.ts +153 -0
  67. package/server/auto-namer.ts +36 -0
  68. package/server/backend-adapter.ts +54 -0
  69. package/server/cache-headers.ts +61 -0
  70. package/server/calendar-service.ts +434 -0
  71. package/server/claude-adapter.ts +889 -0
  72. package/server/claude-container-auth.ts +30 -0
  73. package/server/claude-session-discovery.ts +157 -0
  74. package/server/claude-session-history.ts +410 -0
  75. package/server/cli-launcher.ts +1303 -0
  76. package/server/codex-adapter.ts +3027 -0
  77. package/server/codex-container-auth.ts +24 -0
  78. package/server/codex-home.ts +27 -0
  79. package/server/codex-ws-proxy.cjs +226 -0
  80. package/server/commands-discovery.ts +81 -0
  81. package/server/constants.ts +7 -0
  82. package/server/container-manager.ts +1053 -0
  83. package/server/cost-tracker.ts +222 -0
  84. package/server/cron-scheduler.ts +243 -0
  85. package/server/cron-store.ts +148 -0
  86. package/server/cron-types.ts +63 -0
  87. package/server/email-service.ts +354 -0
  88. package/server/env-manager.ts +161 -0
  89. package/server/event-bus-types.ts +75 -0
  90. package/server/event-bus.ts +124 -0
  91. package/server/execution-store.ts +170 -0
  92. package/server/federation/node-connection.ts +190 -0
  93. package/server/federation/node-manager.ts +366 -0
  94. package/server/federation/node-store.ts +86 -0
  95. package/server/federation/node-types.ts +121 -0
  96. package/server/fs-utils.ts +15 -0
  97. package/server/git-utils.ts +421 -0
  98. package/server/github-pr.ts +379 -0
  99. package/server/google-media.ts +342 -0
  100. package/server/image-pull-manager.ts +279 -0
  101. package/server/index.ts +491 -0
  102. package/server/internal-ai.ts +237 -0
  103. package/server/kill-switch.ts +99 -0
  104. package/server/llm-providers.ts +342 -0
  105. package/server/logger.ts +259 -0
  106. package/server/mcp-registry.ts +401 -0
  107. package/server/message-bus.ts +271 -0
  108. package/server/message-delivery.ts +128 -0
  109. package/server/metrics-collector.ts +350 -0
  110. package/server/metrics-types.ts +108 -0
  111. package/server/middleware/managed-auth.ts +195 -0
  112. package/server/novnc-proxy.ts +99 -0
  113. package/server/path-resolver.ts +186 -0
  114. package/server/paths.ts +13 -0
  115. package/server/pr-poller.ts +162 -0
  116. package/server/prompt-manager.ts +211 -0
  117. package/server/protocol/claude-upstream/README.md +19 -0
  118. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  119. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  120. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  121. package/server/protocol/codex-upstream/README.md +18 -0
  122. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  123. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  124. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  125. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  126. package/server/protocol-monitor.ts +50 -0
  127. package/server/provider-manager.ts +111 -0
  128. package/server/provider-registry.ts +393 -0
  129. package/server/push-notifications.ts +221 -0
  130. package/server/recorder.ts +374 -0
  131. package/server/recording-hub/compat-validator.ts +284 -0
  132. package/server/recording-hub/diagnostics.ts +299 -0
  133. package/server/recording-hub/hub-config.ts +19 -0
  134. package/server/recording-hub/hub-routes.ts +236 -0
  135. package/server/recording-hub/hub-store.ts +265 -0
  136. package/server/recording-hub/replay-adapter.ts +207 -0
  137. package/server/relay-client.ts +320 -0
  138. package/server/reminder-scheduler.ts +38 -0
  139. package/server/replay.ts +78 -0
  140. package/server/routes/agent-routes.ts +264 -0
  141. package/server/routes/assistant-routes.ts +90 -0
  142. package/server/routes/cron-routes.ts +103 -0
  143. package/server/routes/env-routes.ts +95 -0
  144. package/server/routes/federation-routes.ts +76 -0
  145. package/server/routes/fs-routes.ts +622 -0
  146. package/server/routes/git-routes.ts +97 -0
  147. package/server/routes/llm-routes.ts +166 -0
  148. package/server/routes/media-routes.ts +135 -0
  149. package/server/routes/metrics-routes.ts +13 -0
  150. package/server/routes/platform-routes.ts +1379 -0
  151. package/server/routes/prompt-routes.ts +67 -0
  152. package/server/routes/provider-routes.ts +109 -0
  153. package/server/routes/sandbox-routes.ts +127 -0
  154. package/server/routes/settings-routes.ts +285 -0
  155. package/server/routes/skills-routes.ts +100 -0
  156. package/server/routes/socialmedia-routes.ts +208 -0
  157. package/server/routes/system-routes.ts +228 -0
  158. package/server/routes/tailscale-routes.ts +22 -0
  159. package/server/routes/telephony-routes.ts +259 -0
  160. package/server/routes.ts +1379 -0
  161. package/server/sandbox-manager.ts +168 -0
  162. package/server/service.ts +718 -0
  163. package/server/session-creation-service.ts +457 -0
  164. package/server/session-git-info.ts +104 -0
  165. package/server/session-names.ts +67 -0
  166. package/server/session-orchestrator.ts +824 -0
  167. package/server/session-state-machine.ts +207 -0
  168. package/server/session-store.ts +146 -0
  169. package/server/session-types.ts +511 -0
  170. package/server/settings-manager.ts +149 -0
  171. package/server/shared-context.ts +157 -0
  172. package/server/socialmedia/adapter.ts +15 -0
  173. package/server/socialmedia/adapters/ayrshare-adapter.ts +169 -0
  174. package/server/socialmedia/adapters/buffer-adapter.ts +299 -0
  175. package/server/socialmedia/adapters/postiz-adapter.ts +298 -0
  176. package/server/socialmedia/manager.ts +227 -0
  177. package/server/socialmedia/store.ts +98 -0
  178. package/server/socialmedia/types.ts +89 -0
  179. package/server/tailscale-manager.ts +451 -0
  180. package/server/telephony/audio-bridge.ts +331 -0
  181. package/server/telephony/call-manager.ts +457 -0
  182. package/server/telephony/call-types.ts +108 -0
  183. package/server/telephony/telephony-store.ts +119 -0
  184. package/server/terminal-manager.ts +240 -0
  185. package/server/update-checker.ts +192 -0
  186. package/server/usage-limits.ts +225 -0
  187. package/server/web-push.d.ts +51 -0
  188. package/server/worktree-tracker.ts +84 -0
  189. package/server/ws-auth.ts +41 -0
  190. package/server/ws-bridge-browser-ingest.ts +72 -0
  191. package/server/ws-bridge-browser.ts +112 -0
  192. package/server/ws-bridge-cli-ingest.ts +81 -0
  193. package/server/ws-bridge-codex.ts +266 -0
  194. package/server/ws-bridge-controls.ts +20 -0
  195. package/server/ws-bridge-persist.ts +66 -0
  196. package/server/ws-bridge-publish.ts +79 -0
  197. package/server/ws-bridge-replay.ts +61 -0
  198. package/server/ws-bridge-types.ts +121 -0
  199. package/server/ws-bridge.ts +1240 -0
@@ -0,0 +1,1053 @@
1
+ import { execSync, type ExecSyncOptionsWithStringEncoding } from "node:child_process";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { tmpdir } from "node:os";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Types
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export interface ContainerPortSpec {
17
+ port: number;
18
+ /** Host IP to bind to (default: 0.0.0.0). Use "127.0.0.1" for localhost-only. */
19
+ hostIp?: string;
20
+ }
21
+
22
+ export interface ContainerConfig {
23
+ /** Docker image to use (e.g. "the-companion:latest", "node:22-slim") */
24
+ image: string;
25
+ /** Container ports to expose (e.g. [3000, 8080] or [{ port: 6080, hostIp: "127.0.0.1" }]) */
26
+ ports: (number | ContainerPortSpec)[];
27
+ /** Extra volume mounts in "host:container[:opts]" format */
28
+ volumes?: string[];
29
+ /** Extra env vars to inject into the container */
30
+ env?: Record<string, string>;
31
+ /** Run container in privileged mode (required for Docker-in-Docker) */
32
+ privileged?: boolean;
33
+ }
34
+
35
+ export interface PortMapping {
36
+ containerPort: number;
37
+ hostPort: number;
38
+ }
39
+
40
+ export interface ContainerInfo {
41
+ containerId: string;
42
+ name: string;
43
+ image: string;
44
+ portMappings: PortMapping[];
45
+ hostCwd: string;
46
+ containerCwd: string;
47
+ state: "creating" | "running" | "stopped" | "removed";
48
+ /** Named Docker volume for isolated workspace (absent for legacy bind-mount containers). */
49
+ volumeName?: string;
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Helpers
54
+ // ---------------------------------------------------------------------------
55
+
56
+ const EXEC_OPTS: ExecSyncOptionsWithStringEncoding = {
57
+ encoding: "utf-8",
58
+ timeout: 30_000,
59
+ };
60
+ const QUICK_EXEC_TIMEOUT_MS = 8_000;
61
+ const STANDARD_EXEC_TIMEOUT_MS = 30_000;
62
+ const CONTAINER_BOOT_TIMEOUT_MS = 20_000;
63
+ const WORKSPACE_COPY_TIMEOUT_MS = 15 * 60_000; // 15 min for large repos
64
+ const IMAGE_PULL_TIMEOUT_MS = 300_000; // 5 min for pulling images
65
+
66
+ const DOCKER_REGISTRY = "docker.io/stangirard";
67
+
68
+ function exec(cmd: string, opts?: ExecSyncOptionsWithStringEncoding): string {
69
+ return execSync(cmd, { ...EXEC_OPTS, ...opts }).trim();
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // ContainerManager
74
+ // ---------------------------------------------------------------------------
75
+
76
+ export class ContainerManager {
77
+ private containers = new Map<string, ContainerInfo>();
78
+
79
+ /** Check whether Docker daemon is reachable. */
80
+ checkDocker(): boolean {
81
+ try {
82
+ exec("docker info --format '{{.ServerVersion}}'", {
83
+ encoding: "utf-8",
84
+ timeout: QUICK_EXEC_TIMEOUT_MS,
85
+ });
86
+ return true;
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ /** Return Docker version string, or null if unavailable. */
93
+ getDockerVersion(): string | null {
94
+ try {
95
+ return exec("docker version --format '{{.Server.Version}}'", {
96
+ encoding: "utf-8",
97
+ timeout: QUICK_EXEC_TIMEOUT_MS,
98
+ });
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ /** List images available locally. Returns image:tag strings. */
105
+ listImages(): string[] {
106
+ try {
107
+ const raw = exec("docker images --format '{{.Repository}}:{{.Tag}}'", {
108
+ encoding: "utf-8",
109
+ timeout: QUICK_EXEC_TIMEOUT_MS,
110
+ });
111
+ if (!raw) return [];
112
+ return raw
113
+ .split("\n")
114
+ .filter((l) => l && !l.startsWith("<none>"))
115
+ .sort();
116
+ } catch {
117
+ return [];
118
+ }
119
+ }
120
+
121
+ /** Check if a specific image exists locally. */
122
+ imageExists(image: string): boolean {
123
+ try {
124
+ exec(`docker image inspect ${shellEscape(image)}`, {
125
+ encoding: "utf-8",
126
+ timeout: QUICK_EXEC_TIMEOUT_MS,
127
+ });
128
+ return true;
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Create and start a container for a session.
136
+ *
137
+ * - Mounts `~/.claude` read-only at `/heyhank-host-claude` (auth seed)
138
+ * - Uses a writable tmpfs at `/root/.claude` for runtime state
139
+ * - Mounts `hostCwd` at `/workspace`
140
+ * - Publishes requested ports with auto-assigned host ports (`-p 0:PORT`)
141
+ */
142
+ createContainer(
143
+ sessionId: string,
144
+ hostCwd: string,
145
+ config: ContainerConfig,
146
+ ): ContainerInfo {
147
+ const name = `heyhank-${sessionId.slice(0, 8)}`;
148
+ const homedir = process.env.HOME || process.env.USERPROFILE || "/root";
149
+
150
+ // Validate port numbers
151
+ for (const portSpec of config.ports) {
152
+ const port = typeof portSpec === "number" ? portSpec : portSpec.port;
153
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
154
+ throw new Error(`Invalid port number: ${port} (must be 1-65535)`);
155
+ }
156
+ }
157
+
158
+ // Create a named volume for workspace isolation (each container gets its own copy)
159
+ const volumeName = `heyhank-ws-${sessionId.slice(0, 8)}`;
160
+ exec(`docker volume create ${shellEscape(volumeName)}`, {
161
+ encoding: "utf-8",
162
+ timeout: QUICK_EXEC_TIMEOUT_MS,
163
+ });
164
+
165
+ // Build docker create args
166
+ const args: string[] = [
167
+ "docker", "create",
168
+ "--name", name,
169
+ // Enable Docker-in-Docker when privileged mode is requested
170
+ ...(config.privileged ? ["--privileged"] : []),
171
+ // Ensure host.docker.internal resolves (automatic on Mac/Win Docker
172
+ // Desktop, but required explicitly on Linux)
173
+ "--add-host=host.docker.internal:host-gateway",
174
+ // Seed auth/config from host home, but keep runtime writes inside container.
175
+ "-v", `${homedir}/.claude:/heyhank-host-claude:ro`,
176
+ "--tmpfs", "/root/.claude",
177
+ // Seed Codex auth/config from host (if present)
178
+ ...(existsSync(join(homedir, ".codex"))
179
+ ? ["-v", `${homedir}/.codex:/heyhank-host-codex:ro`, "--tmpfs", "/root/.codex"]
180
+ : []),
181
+ // Isolated workspace: named volume populated later via docker cp
182
+ "-v", `${volumeName}:/workspace`,
183
+ "-w", "/workspace",
184
+ ];
185
+
186
+ // Mount host .gitconfig at a staging path (not /root/.gitconfig) so the
187
+ // container keeps a writable global git config. seedGitAuth() copies
188
+ // user.name / user.email from the staged file into /root/.gitconfig and
189
+ // can also write container-specific overrides (e.g. gpgsign=false).
190
+ const gitconfigPath = join(homedir, ".gitconfig");
191
+ if (existsSync(gitconfigPath)) {
192
+ args.push("-v", `${gitconfigPath}:/heyhank-host-gitconfig:ro`);
193
+ }
194
+
195
+ // Port mappings: -p [hostIp:]0:{containerPort}
196
+ for (const portSpec of config.ports) {
197
+ const port = typeof portSpec === "number" ? portSpec : portSpec.port;
198
+ const hostIp = typeof portSpec === "number" ? undefined : portSpec.hostIp;
199
+ args.push("-p", hostIp ? `${hostIp}:0:${port}` : `0:${port}`);
200
+ }
201
+
202
+ // Extra volumes
203
+ if (config.volumes) {
204
+ for (const vol of config.volumes) {
205
+ args.push("-v", vol);
206
+ }
207
+ }
208
+
209
+ // Environment variables
210
+ if (config.env) {
211
+ for (const [k, v] of Object.entries(config.env)) {
212
+ args.push("-e", `${k}=${v}`);
213
+ }
214
+ }
215
+
216
+ // Image + default command (keep container alive)
217
+ args.push(config.image, "sleep", "infinity");
218
+
219
+ const info: ContainerInfo = {
220
+ containerId: "",
221
+ name,
222
+ image: config.image,
223
+ portMappings: [],
224
+ hostCwd,
225
+ containerCwd: "/workspace",
226
+ state: "creating",
227
+ volumeName,
228
+ };
229
+
230
+ try {
231
+ // Create
232
+ const containerId = exec(args.map(shellEscape).join(" "), {
233
+ encoding: "utf-8",
234
+ timeout: CONTAINER_BOOT_TIMEOUT_MS,
235
+ });
236
+ info.containerId = containerId;
237
+
238
+ // Start
239
+ exec(`docker start ${shellEscape(containerId)}`, {
240
+ encoding: "utf-8",
241
+ timeout: CONTAINER_BOOT_TIMEOUT_MS,
242
+ });
243
+ info.state = "running";
244
+
245
+ this.seedAuthFiles(containerId);
246
+ this.seedCodexFiles(containerId);
247
+ this.seedGitAuth(containerId);
248
+
249
+ // Resolve actual port mappings
250
+ info.portMappings = this.resolvePortMappings(containerId, config.ports);
251
+
252
+ this.containers.set(sessionId, info);
253
+ console.log(
254
+ `[container-manager] Created container ${name} (${containerId.slice(0, 12)}) ` +
255
+ `ports: ${info.portMappings.map((p) => `${p.containerPort}->${p.hostPort}`).join(", ")}`,
256
+ );
257
+
258
+ return info;
259
+ } catch (e) {
260
+ // Cleanup partial creation (container + volume)
261
+ try { exec(`docker rm -f ${shellEscape(name)}`); } catch { /* ignore */ }
262
+ try { exec(`docker volume rm ${shellEscape(volumeName)}`); } catch { /* ignore */ }
263
+ info.state = "removed";
264
+ throw new Error(
265
+ `Failed to create container: ${e instanceof Error ? e.message : String(e)}`,
266
+ );
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Copy auth & config files from the read-only bind mount into the tmpfs home dir.
272
+ * Called after both initial create and restart (tmpfs is wiped on stop).
273
+ *
274
+ * Uses semicolons (not &&) deliberately: individual file copies are best-effort
275
+ * because not all auth files exist on every system. The trailing `true` ensures
276
+ * the overall exec succeeds even when some `cp` commands fail for missing files.
277
+ */
278
+ private seedAuthFiles(containerId: string): void {
279
+ try {
280
+ this.execInContainer(containerId, [
281
+ "sh", "-lc",
282
+ [
283
+ "mkdir -p /root/.claude",
284
+ "for f in .credentials.json auth.json .auth.json credentials.json; do " +
285
+ "[ -f /heyhank-host-claude/$f ] && cp /heyhank-host-claude/$f /root/.claude/$f 2>/dev/null; done",
286
+ "for f in settings.json settings.local.json; do " +
287
+ "[ -f /heyhank-host-claude/$f ] && cp /heyhank-host-claude/$f /root/.claude/$f 2>/dev/null; done",
288
+ "[ -d /heyhank-host-claude/skills ] && cp -r /heyhank-host-claude/skills /root/.claude/skills 2>/dev/null",
289
+ "true",
290
+ ].join("; "),
291
+ ]);
292
+ } catch { /* best-effort — container may not have /heyhank-host-claude mounted */ }
293
+ }
294
+
295
+ /**
296
+ * Copy Codex auth & config files from the read-only bind mount into the
297
+ * tmpfs home dir. Similar to seedAuthFiles but for Codex's ~/.codex directory.
298
+ * Called after both initial create and restart (tmpfs is wiped on stop).
299
+ */
300
+ private seedCodexFiles(containerId: string): void {
301
+ try {
302
+ this.execInContainer(containerId, [
303
+ "sh", "-lc",
304
+ [
305
+ "[ -d /heyhank-host-codex ] || exit 0",
306
+ "mkdir -p /root/.codex",
307
+ "for f in auth.json config.toml models_cache.json version.json; do " +
308
+ "[ -f /heyhank-host-codex/$f ] && cp /heyhank-host-codex/$f /root/.codex/$f 2>/dev/null; done",
309
+ "for d in skills vendor_imports prompts rules; do " +
310
+ "[ -d /heyhank-host-codex/$d ] && cp -r /heyhank-host-codex/$d /root/.codex/$d 2>/dev/null; done",
311
+ "true",
312
+ ].join("; "),
313
+ ]);
314
+ } catch { /* best-effort — container may not have /heyhank-host-codex mounted */ }
315
+ }
316
+
317
+ /**
318
+ * Seed git authentication inside the container.
319
+ * - Extracts GitHub CLI token from host keyring and logs in inside container
320
+ * - Always sets up `gh` as the git credential helper for HTTPS operations
321
+ * - Disables GPG commit signing (host tools like 1Password aren't available)
322
+ *
323
+ * Called after both initial create and restart (tmpfs wipes gh config on stop).
324
+ */
325
+ private seedGitAuth(containerId: string): void {
326
+ // Track whether we could read the host token. Containers may still have gh
327
+ // auth via copied files, so setup-git must run even when this is unavailable.
328
+ let token = "";
329
+
330
+ // Extract GitHub token from host (may be stored in macOS keyring)
331
+ try {
332
+ token = exec("gh auth token 2>/dev/null", {
333
+ encoding: "utf-8",
334
+ timeout: QUICK_EXEC_TIMEOUT_MS,
335
+ });
336
+ } catch { /* best-effort — gh may not be installed on host */ }
337
+
338
+ // If host token exists, seed gh auth state in the container.
339
+ if (token) {
340
+ try {
341
+ this.execInContainer(containerId, [
342
+ "sh", "-lc",
343
+ `printf '%s\n' ${shellEscape(token)} | gh auth login --with-token 2>/dev/null; true`,
344
+ ]);
345
+ } catch { /* best-effort */ }
346
+ }
347
+
348
+ // Always wire git credentials to gh token flow.
349
+ try {
350
+ this.execInContainer(containerId, [
351
+ "sh", "-lc",
352
+ "gh auth setup-git 2>/dev/null; true",
353
+ ]);
354
+ } catch { /* best-effort */ }
355
+
356
+ // Copy host git identity (user.name, user.email) from the staged
357
+ // read-only .gitconfig into the container's writable global config,
358
+ // then apply container-specific overrides (disable GPG signing, mark
359
+ // /workspace as safe, rewrite SSH remotes to HTTPS since containers
360
+ // lack host SSH keys).
361
+ try {
362
+ this.execInContainer(containerId, [
363
+ "sh", "-lc",
364
+ [
365
+ // Import user.name and user.email from host gitconfig (if mounted)
366
+ "if [ -f /heyhank-host-gitconfig ]; then " +
367
+ "NAME=$(git config -f /heyhank-host-gitconfig user.name 2>/dev/null); " +
368
+ "EMAIL=$(git config -f /heyhank-host-gitconfig user.email 2>/dev/null); " +
369
+ '[ -n "$NAME" ] && git config --global user.name "$NAME"; ' +
370
+ '[ -n "$EMAIL" ] && git config --global user.email "$EMAIL"; ' +
371
+ "fi",
372
+ // Disable GPG/SSH commit signing — host tools (1Password, GPG agent)
373
+ // aren't available inside the container.
374
+ "git config --global commit.gpgsign false 2>/dev/null",
375
+ // Mark /workspace as safe — the workspace volume may be owned by a
376
+ // different uid (e.g. ubuntu) than the container user (root), which
377
+ // triggers git's "dubious ownership" check.
378
+ "git config --global safe.directory /workspace 2>/dev/null",
379
+ // Rewrite git@github.com:org/repo → https://github.com/org/repo for all remotes
380
+ "cd /workspace 2>/dev/null && " +
381
+ "git remote -v 2>/dev/null | grep 'git@github.com:' | awk '{print $1}' | sort -u | " +
382
+ "while read remote; do " +
383
+ "url=$(git remote get-url \"$remote\" 2>/dev/null); " +
384
+ "https_url=$(echo \"$url\" | sed 's|git@github.com:|https://github.com/|'); " +
385
+ "git remote set-url \"$remote\" \"$https_url\" 2>/dev/null; " +
386
+ "done",
387
+ "true",
388
+ ].join("; "),
389
+ ]);
390
+ } catch { /* best-effort */ }
391
+ }
392
+
393
+ /**
394
+ * Copy host workspace files into a running container's /workspace volume.
395
+ * Uses a tar stream piped into `docker exec` for better throughput on Docker
396
+ * Desktop (macOS) while preserving file structure and dotfiles.
397
+ */
398
+ async copyWorkspaceToContainer(
399
+ containerId: string,
400
+ hostCwd: string,
401
+ ): Promise<void> {
402
+ validateContainerId(containerId);
403
+
404
+ const cmd = [
405
+ "set -o pipefail",
406
+ `COPYFILE_DISABLE=1 tar -C ${shellEscape(hostCwd)} -cf - . | ` +
407
+ `docker exec -i ${shellEscape(containerId)} tar -xf - -C /workspace`,
408
+ ].join("; ");
409
+
410
+ const proc = Bun.spawn(["bash", "-lc", cmd], {
411
+ stdout: "pipe",
412
+ stderr: "pipe",
413
+ });
414
+
415
+ const timeout = new Promise<number>((resolve) => {
416
+ setTimeout(() => resolve(-1), WORKSPACE_COPY_TIMEOUT_MS);
417
+ });
418
+
419
+ const stderrPromise = new Response(proc.stderr).text();
420
+ const exitCode = await Promise.race([proc.exited, timeout]);
421
+
422
+ if (exitCode === -1) {
423
+ try { proc.kill(); } catch { /* best-effort */ }
424
+ throw new Error(`workspace copy timed out after ${Math.floor(WORKSPACE_COPY_TIMEOUT_MS / 1000)}s`);
425
+ }
426
+
427
+ if (exitCode !== 0) {
428
+ const stderrText = await stderrPromise;
429
+ throw new Error(
430
+ `workspace copy failed (exit ${exitCode}): ${stderrText.trim() || "unknown error"}`,
431
+ );
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Re-seed git auth inside a container. Call this after workspace files have
437
+ * been copied so SSH→HTTPS remote rewriting can find the `.git` directory.
438
+ */
439
+ reseedGitAuth(containerId: string): void {
440
+ this.seedGitAuth(containerId);
441
+ }
442
+
443
+ /**
444
+ * Run git fetch/checkout/pull inside a running container at /workspace.
445
+ * Call after copyWorkspaceToContainer + reseedGitAuth so credentials are available.
446
+ * Fetch and pull failures are non-fatal (warnings), matching host-side behavior.
447
+ */
448
+ gitOpsInContainer(
449
+ containerId: string,
450
+ opts: {
451
+ branch: string;
452
+ currentBranch: string;
453
+ createBranch?: boolean;
454
+ defaultBranch?: string;
455
+ },
456
+ ): { fetchOk: boolean; checkoutOk: boolean; pullOk: boolean; errors: string[] } {
457
+ const errors: string[] = [];
458
+ const branch = shellEscape(opts.branch);
459
+
460
+ // 1. git fetch --prune
461
+ let fetchOk = false;
462
+ try {
463
+ this.execInContainer(containerId, [
464
+ "sh", "-lc", "cd /workspace && git fetch --prune",
465
+ ]);
466
+ fetchOk = true;
467
+ } catch (e) {
468
+ errors.push(`fetch: ${e instanceof Error ? e.message : String(e)}`);
469
+ }
470
+
471
+ // 2. git checkout (only if different branch requested)
472
+ let checkoutOk = true;
473
+ if (opts.currentBranch !== opts.branch) {
474
+ checkoutOk = false;
475
+ try {
476
+ this.execInContainer(containerId, [
477
+ "sh", "-lc", `cd /workspace && git checkout ${branch}`,
478
+ ]);
479
+ checkoutOk = true;
480
+ } catch {
481
+ if (opts.createBranch) {
482
+ const base = shellEscape(opts.defaultBranch || "main");
483
+ try {
484
+ this.execInContainer(containerId, [
485
+ "sh", "-lc",
486
+ `cd /workspace && git checkout -b ${branch} origin/${base} 2>/dev/null || git checkout -b ${branch} ${base}`,
487
+ ]);
488
+ checkoutOk = true;
489
+ } catch (e2) {
490
+ errors.push(`checkout-create: ${e2 instanceof Error ? e2.message : String(e2)}`);
491
+ }
492
+ } else {
493
+ errors.push(`checkout: branch "${opts.branch}" does not exist`);
494
+ }
495
+ }
496
+ }
497
+
498
+ // 3. git pull
499
+ let pullOk = false;
500
+ try {
501
+ this.execInContainer(containerId, [
502
+ "sh", "-lc", "cd /workspace && git pull",
503
+ ]);
504
+ pullOk = true;
505
+ } catch (e) {
506
+ errors.push(`pull: ${e instanceof Error ? e.message : String(e)}`);
507
+ }
508
+
509
+ return { fetchOk, checkoutOk, pullOk, errors };
510
+ }
511
+
512
+ /** Parse `docker port` output to get host port mappings. */
513
+ private resolvePortMappings(containerId: string, ports: (number | ContainerPortSpec)[]): PortMapping[] {
514
+ const mappings: PortMapping[] = [];
515
+ for (const portSpec of ports) {
516
+ const containerPort = typeof portSpec === "number" ? portSpec : portSpec.port;
517
+ try {
518
+ const raw = exec(
519
+ `docker port ${shellEscape(containerId)} ${containerPort}`,
520
+ );
521
+ // Output like "0.0.0.0:49152" or "127.0.0.1:49152" or "[::]:49152"
522
+ const match = raw.match(/:(\d+)$/m);
523
+ if (match) {
524
+ mappings.push({
525
+ containerPort,
526
+ hostPort: parseInt(match[1], 10),
527
+ });
528
+ }
529
+ } catch {
530
+ console.warn(
531
+ `[container-manager] Could not resolve port ${containerPort} for ${containerId.slice(0, 12)}`,
532
+ );
533
+ }
534
+ }
535
+ return mappings;
536
+ }
537
+
538
+ /**
539
+ * Execute a command inside a running container.
540
+ * Returns the stdout output. Throws on failure.
541
+ */
542
+ execInContainer(containerId: string, cmd: string[], timeout = STANDARD_EXEC_TIMEOUT_MS): string {
543
+ validateContainerId(containerId);
544
+ const dockerCmd = [
545
+ "docker", "exec",
546
+ shellEscape(containerId),
547
+ ...cmd.map(shellEscape),
548
+ ].join(" ");
549
+ return exec(dockerCmd, { encoding: "utf-8", timeout });
550
+ }
551
+
552
+ /**
553
+ * Execute a command inside a running container asynchronously.
554
+ * Uses Bun.spawn for longer-running operations (like init scripts).
555
+ * Returns exit code and combined stdout+stderr output.
556
+ */
557
+ async execInContainerAsync(
558
+ containerId: string,
559
+ cmd: string[],
560
+ opts?: { timeout?: number; onOutput?: (line: string) => void },
561
+ ): Promise<{ exitCode: number; output: string }> {
562
+ validateContainerId(containerId);
563
+ const timeout = opts?.timeout ?? 120_000;
564
+ const dockerCmd = [
565
+ "docker", "exec",
566
+ containerId,
567
+ ...cmd,
568
+ ];
569
+
570
+ const proc = Bun.spawn(dockerCmd, {
571
+ stdout: "pipe",
572
+ stderr: "pipe",
573
+ });
574
+
575
+ const lines: string[] = [];
576
+ const decoder = new TextDecoder();
577
+
578
+ // Read stdout
579
+ const stdoutReader = proc.stdout.getReader();
580
+ let stdoutBuffer = "";
581
+ const readStdout = (async () => {
582
+ try {
583
+ while (true) {
584
+ const { done, value } = await stdoutReader.read();
585
+ if (done) break;
586
+ stdoutBuffer += decoder.decode(value, { stream: true });
587
+ const parts = stdoutBuffer.split("\n");
588
+ stdoutBuffer = parts.pop() || "";
589
+ for (const line of parts) {
590
+ lines.push(line);
591
+ opts?.onOutput?.(line);
592
+ }
593
+ }
594
+ if (stdoutBuffer.trim()) {
595
+ lines.push(stdoutBuffer);
596
+ opts?.onOutput?.(stdoutBuffer);
597
+ }
598
+ } finally {
599
+ stdoutReader.releaseLock();
600
+ }
601
+ })();
602
+
603
+ // Read stderr
604
+ const stderrPromise = new Response(proc.stderr).text();
605
+
606
+ // Apply timeout — capture timer ID so we can clear it on normal exit
607
+ const exitPromise = proc.exited;
608
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
609
+ const timeoutPromise = new Promise<never>((_, reject) => {
610
+ timeoutId = setTimeout(() => {
611
+ proc.kill();
612
+ reject(new Error(`Command timed out after ${timeout}ms`));
613
+ }, timeout);
614
+ });
615
+
616
+ try {
617
+ const exitCode = await Promise.race([exitPromise, timeoutPromise]);
618
+ clearTimeout(timeoutId);
619
+ await readStdout;
620
+ const stderrText = await stderrPromise;
621
+ if (stderrText.trim()) {
622
+ for (const line of stderrText.split("\n")) {
623
+ if (line.trim()) {
624
+ lines.push(line);
625
+ opts?.onOutput?.(line);
626
+ }
627
+ }
628
+ }
629
+ return { exitCode, output: lines.join("\n") };
630
+ } catch (e) {
631
+ clearTimeout(timeoutId);
632
+ await readStdout.catch(() => {});
633
+ throw e;
634
+ }
635
+ }
636
+
637
+ /**
638
+ * Re-track a container under a new key (e.g. when the real sessionId
639
+ * is assigned after container creation).
640
+ */
641
+ retrack(containerId: string, newSessionId: string): void {
642
+ for (const [oldKey, info] of this.containers) {
643
+ if (info.containerId === containerId) {
644
+ this.containers.delete(oldKey);
645
+ this.containers.set(newSessionId, info);
646
+ return;
647
+ }
648
+ }
649
+ }
650
+
651
+ /** Stop and remove a container. */
652
+ removeContainer(sessionId: string): void {
653
+ const info = this.containers.get(sessionId);
654
+ if (!info) return;
655
+
656
+ try {
657
+ exec(`docker rm -f ${shellEscape(info.containerId)}`);
658
+ info.state = "removed";
659
+ console.log(
660
+ `[container-manager] Removed container ${info.name} (${info.containerId.slice(0, 12)})`,
661
+ );
662
+ } catch (e) {
663
+ console.warn(
664
+ `[container-manager] Failed to remove container ${info.name}:`,
665
+ e instanceof Error ? e.message : String(e),
666
+ );
667
+ }
668
+
669
+ // Clean up the named workspace volume if one was created
670
+ if (info.volumeName) {
671
+ try {
672
+ exec(`docker volume rm ${shellEscape(info.volumeName)}`, {
673
+ encoding: "utf-8",
674
+ timeout: QUICK_EXEC_TIMEOUT_MS,
675
+ });
676
+ console.log(`[container-manager] Removed volume ${info.volumeName}`);
677
+ } catch (e) {
678
+ console.warn(
679
+ `[container-manager] Failed to remove volume ${info.volumeName}:`,
680
+ e instanceof Error ? e.message : String(e),
681
+ );
682
+ }
683
+ }
684
+
685
+ this.containers.delete(sessionId);
686
+ }
687
+
688
+ /** Get container info for a session. */
689
+ getContainer(sessionId: string): ContainerInfo | undefined {
690
+ return this.containers.get(sessionId);
691
+ }
692
+
693
+ /** Get container info by Docker container ID. */
694
+ getContainerById(containerId: string): ContainerInfo | undefined {
695
+ for (const info of this.containers.values()) {
696
+ if (info.containerId === containerId) return info;
697
+ }
698
+ return undefined;
699
+ }
700
+
701
+ /** List all tracked containers. */
702
+ listContainers(): ContainerInfo[] {
703
+ return Array.from(this.containers.values());
704
+ }
705
+
706
+ /** Attempt to start a stopped container. Re-seeds auth files (tmpfs is wiped on stop). */
707
+ startContainer(containerId: string): void {
708
+ validateContainerId(containerId);
709
+ exec(`docker start ${shellEscape(containerId)}`, {
710
+ encoding: "utf-8",
711
+ timeout: CONTAINER_BOOT_TIMEOUT_MS,
712
+ });
713
+ this.seedAuthFiles(containerId);
714
+ this.seedCodexFiles(containerId);
715
+ this.seedGitAuth(containerId);
716
+ }
717
+
718
+ /**
719
+ * Check whether a Docker container exists and its running state.
720
+ * Returns "running", "stopped", or "missing".
721
+ */
722
+ isContainerAlive(containerId: string): "running" | "stopped" | "missing" {
723
+ validateContainerId(containerId);
724
+ try {
725
+ const state = exec(
726
+ `docker inspect --format '{{.State.Running}}' ${shellEscape(containerId)}`,
727
+ { encoding: "utf-8", timeout: QUICK_EXEC_TIMEOUT_MS },
728
+ );
729
+ return state === "true" ? "running" : "stopped";
730
+ } catch {
731
+ return "missing";
732
+ }
733
+ }
734
+
735
+ /**
736
+ * Check if a binary is available inside a running container.
737
+ * Uses `bash -lc` so PATH includes nvm/bun/deno/etc.
738
+ */
739
+ hasBinaryInContainer(containerId: string, binary: string): boolean {
740
+ validateContainerId(containerId);
741
+ try {
742
+ exec(
743
+ `docker exec ${shellEscape(containerId)} bash -lc 'which ${shellEscape(binary)}'`,
744
+ { encoding: "utf-8", timeout: QUICK_EXEC_TIMEOUT_MS },
745
+ );
746
+ return true;
747
+ } catch {
748
+ return false;
749
+ }
750
+ }
751
+
752
+ /**
753
+ * Re-register a container that was persisted across a server restart.
754
+ * Verifies the container still exists in Docker before tracking it.
755
+ */
756
+ restoreContainer(sessionId: string, info: ContainerInfo): boolean {
757
+ try {
758
+ const state = exec(
759
+ `docker inspect --format '{{.State.Running}}' ${shellEscape(info.containerId)}`,
760
+ );
761
+ if (state === "true") {
762
+ info.state = "running";
763
+ } else {
764
+ info.state = "stopped";
765
+ }
766
+ this.containers.set(sessionId, info);
767
+ console.log(
768
+ `[container-manager] Restored container ${info.name} (${info.containerId.slice(0, 12)}) state=${info.state}`,
769
+ );
770
+ return true;
771
+ } catch {
772
+ // Container no longer exists in Docker
773
+ console.warn(
774
+ `[container-manager] Container ${info.name} (${info.containerId.slice(0, 12)}) no longer exists, skipping restore`,
775
+ );
776
+ return false;
777
+ }
778
+ }
779
+
780
+ // ---------------------------------------------------------------------------
781
+ // Persistence — survive server restarts
782
+ // ---------------------------------------------------------------------------
783
+
784
+ /** Persist all tracked container info to disk. */
785
+ persistState(filePath: string): void {
786
+ try {
787
+ const entries: { sessionId: string; info: ContainerInfo }[] = [];
788
+ for (const [sessionId, info] of this.containers) {
789
+ if (info.state !== "removed") {
790
+ entries.push({ sessionId, info });
791
+ }
792
+ }
793
+ writeFileSync(filePath, JSON.stringify(entries, null, 2), "utf-8");
794
+ } catch (e) {
795
+ console.warn(
796
+ "[container-manager] Failed to persist state:",
797
+ e instanceof Error ? e.message : String(e),
798
+ );
799
+ }
800
+ }
801
+
802
+ /** Restore container tracking from disk, verifying each container still exists. */
803
+ restoreState(filePath: string): number {
804
+ if (!existsSync(filePath)) return 0;
805
+ try {
806
+ const raw = readFileSync(filePath, "utf-8");
807
+ const entries: { sessionId: string; info: ContainerInfo }[] = JSON.parse(raw);
808
+ let restored = 0;
809
+ for (const { sessionId, info } of entries) {
810
+ if (this.restoreContainer(sessionId, info)) {
811
+ restored++;
812
+ }
813
+ }
814
+ if (restored > 0) {
815
+ console.log(`[container-manager] Restored ${restored} container(s) from disk`);
816
+ }
817
+ return restored;
818
+ } catch (e) {
819
+ console.warn(
820
+ "[container-manager] Failed to restore state:",
821
+ e instanceof Error ? e.message : String(e),
822
+ );
823
+ return 0;
824
+ }
825
+ }
826
+
827
+ // ---------------------------------------------------------------------------
828
+ // Image building
829
+ // ---------------------------------------------------------------------------
830
+
831
+ /**
832
+ * Build a Docker image from a provided Dockerfile path.
833
+ * Returns the build output log. Throws on failure.
834
+ */
835
+ buildImage(dockerfilePath: string, tag: string = "the-companion:latest"): string {
836
+ const contextDir = dockerfilePath.replace(/\/[^/]+$/, "") || ".";
837
+ try {
838
+ const output = exec(
839
+ `docker build -t ${shellEscape(tag)} -f ${shellEscape(dockerfilePath)} ${shellEscape(contextDir)}`,
840
+ { encoding: "utf-8", timeout: 300_000 }, // 5 min for image builds
841
+ );
842
+ console.log(`[container-manager] Built image ${tag}`);
843
+ return output;
844
+ } catch (e) {
845
+ throw new Error(
846
+ `Failed to build image ${tag}: ${e instanceof Error ? e.message : String(e)}`,
847
+ );
848
+ }
849
+ }
850
+
851
+ /**
852
+ * Build a Docker image from inline Dockerfile content using Bun.spawn for streaming output.
853
+ * Writes the Dockerfile to a temp directory and runs docker build.
854
+ */
855
+ async buildImageStreaming(
856
+ dockerfileContent: string,
857
+ tag: string,
858
+ onProgress?: (line: string) => void,
859
+ ): Promise<{ success: boolean; log: string }> {
860
+ // Write Dockerfile to temp dir
861
+ const buildDir = join(tmpdir(), `heyhank-build-${Date.now()}`);
862
+ mkdirSync(buildDir, { recursive: true });
863
+ const dockerfilePath = join(buildDir, "Dockerfile");
864
+ writeFileSync(dockerfilePath, dockerfileContent, "utf-8");
865
+
866
+ try {
867
+ const args = [
868
+ "docker", "build",
869
+ "-t", tag,
870
+ "-f", dockerfilePath,
871
+ buildDir,
872
+ ];
873
+
874
+ const proc = Bun.spawn(args, {
875
+ stdout: "pipe",
876
+ stderr: "pipe",
877
+ });
878
+
879
+ const lines: string[] = [];
880
+
881
+ // Read stdout and stderr concurrently to avoid deadlock.
882
+ // Docker BuildKit sends build progress to stderr; if we read them
883
+ // sequentially, the stderr pipe buffer fills up and blocks Docker
884
+ // while we're still waiting on stdout.
885
+ const readStdout = (async () => {
886
+ const reader = proc.stdout.getReader();
887
+ const decoder = new TextDecoder();
888
+ let buffer = "";
889
+ try {
890
+ while (true) {
891
+ const { done, value } = await reader.read();
892
+ if (done) break;
893
+ buffer += decoder.decode(value, { stream: true });
894
+ const parts = buffer.split("\n");
895
+ buffer = parts.pop() || "";
896
+ for (const line of parts) {
897
+ if (line.trim()) {
898
+ lines.push(line);
899
+ onProgress?.(line);
900
+ }
901
+ }
902
+ }
903
+ if (buffer.trim()) {
904
+ lines.push(buffer);
905
+ onProgress?.(buffer);
906
+ }
907
+ } finally {
908
+ reader.releaseLock();
909
+ }
910
+ })();
911
+
912
+ const readStderr = (async () => {
913
+ const text = await new Response(proc.stderr).text();
914
+ if (text.trim()) {
915
+ for (const line of text.split("\n")) {
916
+ if (line.trim()) {
917
+ lines.push(line);
918
+ onProgress?.(line);
919
+ }
920
+ }
921
+ }
922
+ })();
923
+
924
+ await Promise.all([readStdout, readStderr]);
925
+ const exitCode = await proc.exited;
926
+ const log = lines.join("\n");
927
+
928
+ if (exitCode === 0) {
929
+ console.log(`[container-manager] Built image ${tag} (streaming)`);
930
+ return { success: true, log };
931
+ }
932
+
933
+ return { success: false, log };
934
+ } finally {
935
+ // Clean up temp build directory
936
+ try { rmSync(buildDir, { recursive: true, force: true }); } catch { /* ignore */ }
937
+ }
938
+ }
939
+
940
+ /**
941
+ * Return the Docker Hub remote path for a default image, or null for non-default images.
942
+ */
943
+ static getRegistryImage(localTag: string): string | null {
944
+ if (localTag === "the-companion:latest") {
945
+ return `${DOCKER_REGISTRY}/the-companion:latest`;
946
+ }
947
+ return null;
948
+ }
949
+
950
+ /**
951
+ * Pull a Docker image from a registry and optionally tag it locally.
952
+ * Returns true on success, false on failure (never throws).
953
+ */
954
+ async pullImage(
955
+ remoteImage: string,
956
+ localTag: string,
957
+ onProgress?: (line: string) => void,
958
+ ): Promise<boolean> {
959
+ try {
960
+ const proc = Bun.spawn(["docker", "pull", remoteImage], {
961
+ stdout: "pipe",
962
+ stderr: "pipe",
963
+ });
964
+
965
+ const readOutput = async (stream: ReadableStream<Uint8Array>) => {
966
+ const reader = stream.getReader();
967
+ const decoder = new TextDecoder();
968
+ let buffer = "";
969
+ try {
970
+ while (true) {
971
+ const { done, value } = await reader.read();
972
+ if (done) break;
973
+ buffer += decoder.decode(value, { stream: true });
974
+ const parts = buffer.split("\n");
975
+ buffer = parts.pop() || "";
976
+ for (const line of parts) {
977
+ if (line.trim()) onProgress?.(line);
978
+ }
979
+ }
980
+ if (buffer.trim()) onProgress?.(buffer);
981
+ } finally {
982
+ reader.releaseLock();
983
+ }
984
+ };
985
+
986
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
987
+ const timeoutPromise = new Promise<never>((_, reject) => {
988
+ timeoutId = setTimeout(() => {
989
+ proc.kill();
990
+ reject(new Error("Pull timed out"));
991
+ }, IMAGE_PULL_TIMEOUT_MS);
992
+ });
993
+
994
+ const exitPromise = (async () => {
995
+ await Promise.all([readOutput(proc.stdout), readOutput(proc.stderr)]);
996
+ return proc.exited;
997
+ })();
998
+
999
+ const exitCode = await Promise.race([exitPromise, timeoutPromise]);
1000
+ clearTimeout(timeoutId);
1001
+
1002
+ if (exitCode !== 0) {
1003
+ console.warn(`[container-manager] docker pull ${remoteImage} failed (exit ${exitCode})`);
1004
+ return false;
1005
+ }
1006
+
1007
+ // Tag as local name if different
1008
+ if (remoteImage !== localTag) {
1009
+ exec(`docker tag ${shellEscape(remoteImage)} ${shellEscape(localTag)}`, {
1010
+ encoding: "utf-8",
1011
+ timeout: QUICK_EXEC_TIMEOUT_MS,
1012
+ });
1013
+ }
1014
+
1015
+ console.log(`[container-manager] Pulled ${remoteImage} → ${localTag}`);
1016
+ return true;
1017
+ } catch (e) {
1018
+ console.warn(
1019
+ `[container-manager] Pull failed for ${remoteImage}:`,
1020
+ e instanceof Error ? e.message : String(e),
1021
+ );
1022
+ return false;
1023
+ }
1024
+ }
1025
+
1026
+ /** Clean up all tracked containers (e.g. on server shutdown). */
1027
+ cleanupAll(): void {
1028
+ for (const [sessionId] of this.containers) {
1029
+ this.removeContainer(sessionId);
1030
+ }
1031
+ }
1032
+ }
1033
+
1034
+ // ---------------------------------------------------------------------------
1035
+ // Shell escape helper
1036
+ // ---------------------------------------------------------------------------
1037
+
1038
+ function shellEscape(s: string): string {
1039
+ if (/^[a-zA-Z0-9._\-/:=@]+$/.test(s)) return s;
1040
+ return `'${s.replace(/'/g, "'\\''")}'`;
1041
+ }
1042
+
1043
+ /** Validate that a container ID is a hex string (Docker format) or a safe container name. */
1044
+ function validateContainerId(id: string): void {
1045
+ // Docker container IDs are 64-char hex, but we accept short IDs too.
1046
+ // Container names are alphanumeric with hyphens and underscores.
1047
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_.\-]*$/.test(id)) {
1048
+ throw new Error(`Invalid container ID or name: ${id.slice(0, 40)}`);
1049
+ }
1050
+ }
1051
+
1052
+ // Singleton
1053
+ export const containerManager = new ContainerManager();