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.
- package/README.md +40 -0
- 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-BPhirnCe.js +7 -0
- package/dist/assets/AssistantPage-DJ-cMQfb.js +1 -0
- package/dist/assets/CronManager-DDbz-yiT.js +1 -0
- package/dist/assets/HelpPage-DMfkzERp.js +1 -0
- package/dist/assets/IntegrationsPage-CrOitCmJ.js +1 -0
- package/dist/assets/MediaPage-CE5rdvkC.js +1 -0
- package/dist/assets/PlatformDashboard-Do6F0O2p.js +1 -0
- package/dist/assets/Playground-Fc5cdc5p.js +109 -0
- package/dist/assets/ProcessPanel-CslEiZkI.js +2 -0
- package/dist/assets/PromptsPage-D2EhsdNO.js +4 -0
- package/dist/assets/RunsPage-C5BZF5Rx.js +1 -0
- package/dist/assets/SandboxManager-a1AVI5q2.js +8 -0
- package/dist/assets/SettingsPage-DirhjQrJ.js +51 -0
- package/dist/assets/SocialMediaPage-DBuM28vD.js +1 -0
- package/dist/assets/TailscalePage-CHiFhZXF.js +1 -0
- package/dist/assets/TelephonyPage-x0VV0fOo.js +1 -0
- package/dist/assets/TerminalPage-Drwyrnfd.js +1 -0
- package/dist/assets/gemini-audio-t-TSU-To.js +17 -0
- package/dist/assets/gemini-live-client-C7rqAW7G.js +166 -0
- package/dist/assets/index-C8M_PUmX.css +32 -0
- package/dist/assets/index-CEqZnThB.js +204 -0
- package/dist/assets/sw-register-LSSpj6RU.js +1 -0
- package/dist/assets/time-ago-B6r_l9u1.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon-32-original.png +0 -0
- package/dist/favicon-32.png +0 -0
- package/dist/favicon.ico +0 -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/heyhank-mascot-poster.png +0 -0
- package/dist/heyhank-mascot.mp4 +0 -0
- package/dist/heyhank-mascot.webm +0 -0
- package/dist/icon-192-original.png +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512-original.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +21 -0
- package/dist/logo-192.png +0 -0
- package/dist/logo-512.png +0 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo-original.png +0 -0
- package/dist/logo.png +0 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/push-sw.js +34 -0
- package/dist/sw.js +1 -0
- package/dist/workbox-d2a0910a.js +1 -0
- package/package.json +109 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.ts +357 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-timeout.ts +107 -0
- package/server/agent-types.ts +122 -0
- package/server/ai-validation-settings.ts +37 -0
- package/server/ai-validator.ts +181 -0
- package/server/anthropic-provider-migration.ts +48 -0
- package/server/assistant-store.ts +272 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-approve.ts +153 -0
- package/server/auto-namer.ts +36 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.ts +61 -0
- package/server/calendar-service.ts +434 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.ts +1303 -0
- package/server/codex-adapter.ts +3027 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.ts +27 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.ts +1053 -0
- package/server/cost-tracker.ts +222 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/email-service.ts +354 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +75 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.ts +170 -0
- package/server/federation/node-connection.ts +190 -0
- package/server/federation/node-manager.ts +366 -0
- package/server/federation/node-store.ts +86 -0
- package/server/federation/node-types.ts +121 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.ts +379 -0
- package/server/google-media.ts +342 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +491 -0
- package/server/internal-ai.ts +237 -0
- package/server/kill-switch.ts +99 -0
- package/server/llm-providers.ts +342 -0
- package/server/logger.ts +259 -0
- package/server/mcp-registry.ts +401 -0
- package/server/message-bus.ts +271 -0
- package/server/message-delivery.ts +128 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.ts +13 -0
- package/server/pr-poller.ts +162 -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/provider-manager.ts +111 -0
- package/server/provider-registry.ts +393 -0
- package/server/push-notifications.ts +221 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.ts +320 -0
- package/server/reminder-scheduler.ts +38 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.ts +264 -0
- package/server/routes/assistant-routes.ts +90 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/federation-routes.ts +76 -0
- package/server/routes/fs-routes.ts +622 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/llm-routes.ts +166 -0
- package/server/routes/media-routes.ts +135 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/platform-routes.ts +1379 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/provider-routes.ts +109 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +285 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/socialmedia-routes.ts +208 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes/telephony-routes.ts +259 -0
- package/server/routes.ts +1379 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.ts +457 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.ts +824 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +511 -0
- package/server/settings-manager.ts +149 -0
- package/server/shared-context.ts +157 -0
- package/server/socialmedia/adapter.ts +15 -0
- package/server/socialmedia/adapters/ayrshare-adapter.ts +169 -0
- package/server/socialmedia/adapters/buffer-adapter.ts +299 -0
- package/server/socialmedia/adapters/postiz-adapter.ts +298 -0
- package/server/socialmedia/manager.ts +227 -0
- package/server/socialmedia/store.ts +98 -0
- package/server/socialmedia/types.ts +89 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/telephony/audio-bridge.ts +331 -0
- package/server/telephony/call-manager.ts +457 -0
- package/server/telephony/call-types.ts +108 -0
- package/server/telephony/telephony-store.ts +119 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.ts +192 -0
- package/server/usage-limits.ts +225 -0
- package/server/web-push.d.ts +51 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +121 -0
- 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();
|