@getforgeai/cli 0.1.0-beta.2 → 0.1.0-beta.4

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 (37) hide show
  1. package/dist/cli.js +10 -1
  2. package/dist/commands/auth-setup-agent.d.ts +15 -0
  3. package/dist/commands/auth-setup-agent.js +98 -0
  4. package/dist/commands/config-set.js +14 -3
  5. package/dist/commands/connect.js +23 -7
  6. package/dist/config/config-manager.d.ts +2 -0
  7. package/dist/lib/connection-manager.d.ts +11 -0
  8. package/dist/lib/connection-manager.js +48 -25
  9. package/dist/lib/daemon-entry.js +23 -6
  10. package/dist/orchestrator/agent-profiles.d.ts +80 -0
  11. package/dist/orchestrator/agent-profiles.js +253 -0
  12. package/dist/orchestrator/agent-spawner.d.ts +2 -0
  13. package/dist/orchestrator/agent-spawner.js +25 -14
  14. package/dist/orchestrator/billing-detect.d.ts +20 -0
  15. package/dist/orchestrator/billing-detect.js +120 -0
  16. package/dist/orchestrator/codex-jsonl-extractor.d.ts +52 -0
  17. package/dist/orchestrator/codex-jsonl-extractor.js +210 -0
  18. package/dist/orchestrator/connectors/connector.d.ts +2 -0
  19. package/dist/orchestrator/connectors/docker-connector.js +197 -175
  20. package/dist/orchestrator/opencode-jsonl-extractor.d.ts +47 -0
  21. package/dist/orchestrator/opencode-jsonl-extractor.js +228 -0
  22. package/dist/orchestrator/resume-handler.js +2 -0
  23. package/dist/orchestrator/stream-json-extractor.d.ts +38 -10
  24. package/dist/orchestrator/stream-json-extractor.js +10 -105
  25. package/dist/vm/agent-auth-manager.d.ts +32 -0
  26. package/dist/vm/agent-auth-manager.js +68 -0
  27. package/dist/vm/docker-process-proxy.d.ts +2 -1
  28. package/dist/vm/docker-process-proxy.js +17 -10
  29. package/dist/vm/image-manager.js +1 -1
  30. package/dist/vm/secure-store.d.ts +24 -0
  31. package/dist/vm/secure-store.js +110 -0
  32. package/dist/vm/session-manager.d.ts +33 -5
  33. package/dist/vm/session-manager.js +129 -19
  34. package/dist/vm/session-snapshot.d.ts +13 -8
  35. package/dist/vm/session-snapshot.js +52 -36
  36. package/package.json +2 -2
  37. package/rootfs/Dockerfile +5 -2
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Generic secure secret storage, parameterized by Keychain service / file path.
3
+ *
4
+ * Same scheme as claude-token-manager.ts:
5
+ * - macOS: Keychain via the `security` CLI
6
+ * - Linux/Windows: AES-256-GCM file encrypted with a machine-identity key
7
+ *
8
+ * Used by agent-auth-manager.ts for Codex / OpenCode credentials.
9
+ */
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { homedir, hostname, userInfo } from "node:os";
12
+ import { join } from "node:path";
13
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync, } from "node:crypto";
14
+ import { execFile } from "node:child_process";
15
+ import { promisify } from "node:util";
16
+ const execFileAsync = promisify(execFile);
17
+ const ALGORITHM = "aes-256-gcm";
18
+ function filePath(location) {
19
+ return join(homedir(), ".forgeai", "auth", location.fileName);
20
+ }
21
+ function deriveKey(location) {
22
+ const machineId = `${hostname()}:${userInfo().username}`;
23
+ return scryptSync(machineId, location.fileSalt, 32);
24
+ }
25
+ async function saveToKeychain(location, value) {
26
+ try {
27
+ await execFileAsync("security", [
28
+ "delete-generic-password",
29
+ "-s",
30
+ location.keychainService,
31
+ "-a",
32
+ location.keychainAccount,
33
+ ], { timeout: 5_000 });
34
+ }
35
+ catch {
36
+ /* not found — OK */
37
+ }
38
+ await execFileAsync("security", [
39
+ "add-generic-password",
40
+ "-s",
41
+ location.keychainService,
42
+ "-a",
43
+ location.keychainAccount,
44
+ "-w",
45
+ value,
46
+ "-U",
47
+ ], { timeout: 5_000 });
48
+ }
49
+ async function loadFromKeychain(location) {
50
+ try {
51
+ const { stdout } = await execFileAsync("security", [
52
+ "find-generic-password",
53
+ "-s",
54
+ location.keychainService,
55
+ "-a",
56
+ location.keychainAccount,
57
+ "-w",
58
+ ], { timeout: 5_000 });
59
+ const value = stdout.trim();
60
+ return value || null;
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ function saveToFile(location, value) {
67
+ const key = deriveKey(location);
68
+ const iv = randomBytes(16);
69
+ const cipher = createCipheriv(ALGORITHM, key, iv);
70
+ let encrypted = cipher.update(value, "utf-8", "hex");
71
+ encrypted += cipher.final("hex");
72
+ const authTag = cipher.getAuthTag().toString("hex");
73
+ const dir = join(homedir(), ".forgeai", "auth");
74
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
75
+ writeFileSync(filePath(location), JSON.stringify({ iv: iv.toString("hex"), authTag, encrypted }), { mode: 0o600 });
76
+ }
77
+ function loadFromFile(location) {
78
+ const path = filePath(location);
79
+ if (!existsSync(path))
80
+ return null;
81
+ try {
82
+ const data = JSON.parse(readFileSync(path, "utf-8"));
83
+ const key = deriveKey(location);
84
+ const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(data.iv, "hex"));
85
+ decipher.setAuthTag(Buffer.from(data.authTag, "hex"));
86
+ let decrypted = decipher.update(data.encrypted, "hex", "utf-8");
87
+ decrypted += decipher.final("utf-8");
88
+ return decrypted;
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ }
94
+ /** Store a secret. Keychain on macOS, encrypted file elsewhere. */
95
+ export async function saveSecret(location, value) {
96
+ if (process.platform === "darwin") {
97
+ await saveToKeychain(location, value);
98
+ }
99
+ else {
100
+ saveToFile(location, value);
101
+ }
102
+ }
103
+ /** Load a secret. Returns null if not found or unreadable. */
104
+ export async function loadSecret(location) {
105
+ if (process.platform === "darwin") {
106
+ return loadFromKeychain(location);
107
+ }
108
+ return loadFromFile(location);
109
+ }
110
+ //# sourceMappingURL=secure-store.js.map
@@ -32,6 +32,13 @@ export type SessionState = {
32
32
  * never logged. Passed to the in-container relay via FORGEAI_MCP_TOKEN.
33
33
  */
34
34
  mcpSecret: string;
35
+ /**
36
+ * Agent engine this session's container was provisioned for (config file,
37
+ * credentials, on-disk session state). Set at first spawn; same-container
38
+ * resumes must keep using it even if the CLI's configured engine changed
39
+ * in the meantime.
40
+ */
41
+ agentType?: string;
35
42
  /**
36
43
  * Git credential for this session's repo, resolved from the host credential
37
44
  * helper at clone time. Kept in memory only (never written to disk or
@@ -49,6 +56,8 @@ export declare class SessionManager {
49
56
  private dockerReady;
50
57
  private claudeOAuthToken;
51
58
  private tokenLoadedAt;
59
+ /** Cached Codex/OpenCode credentials, re-read hourly and on refreshToken(). */
60
+ private agentAuthCache;
52
61
  /**
53
62
  * Ensure Docker is available and image is ready.
54
63
  * Idempotent: if already checked, returns immediately.
@@ -89,7 +98,26 @@ export declare class SessionManager {
89
98
  * Spawn an agent inside the container.
90
99
  * Returns a DockerProcessProxy that satisfies the AgentProcess interface.
91
100
  */
92
- spawnAgent(taskId: string, cmd: string, args: string[], env?: Record<string, string>): Promise<DockerProcessProxy>;
101
+ spawnAgent(taskId: string, cmd: string, args: string[], env?: Record<string, string>, agentType?: string): Promise<DockerProcessProxy>;
102
+ /**
103
+ * Secret env vars for an agent spawn, per engine:
104
+ * - CLAUDE_CODE: long-lived OAuth token via CLAUDE_CODE_OAUTH_TOKEN
105
+ * - OPENCODE (api_key): the provider's standard env var (ANTHROPIC_API_KEY, ...)
106
+ * - CODEX / OPENCODE (auth_json): nothing — credentials were materialized
107
+ * into the container home by materializeAgentAuth()
108
+ */
109
+ private getAgentSecretEnv;
110
+ /** Load (and cache) stored Codex/OpenCode credentials, or throw with a fix hint. */
111
+ private getAgentAuth;
112
+ /**
113
+ * Materialize agent credentials inside the container home when the engine
114
+ * reads them from disk rather than env vars:
115
+ * - CODEX api_key: `codex login --with-api-key` (writes ~/.codex/auth.json)
116
+ * - CODEX / OPENCODE auth_json: write the imported auth.json
117
+ * The secret travels via secretEnv (name-only -e), never via argv, and is
118
+ * written inside the container filesystem — not on the /session host volume.
119
+ */
120
+ materializeAgentAuth(taskId: string, agentType: string): Promise<void>;
93
121
  /**
94
122
  * Kill an agent process.
95
123
  */
@@ -106,11 +134,11 @@ export declare class SessionManager {
106
134
  getAllSessions(): SessionState[];
107
135
  isVmRunning(): boolean;
108
136
  /**
109
- * Reload token from Keychain/file. Called after auth errors to pick up
110
- * a refreshed token without restarting the CLI.
111
- * Returns true if a new token was loaded.
137
+ * Reload credentials from Keychain/file. Called after auth errors to pick up
138
+ * refreshed credentials without restarting the CLI.
139
+ * Returns true if new credentials were loaded.
112
140
  */
113
- refreshToken(): Promise<boolean>;
141
+ refreshToken(agentType?: string): Promise<boolean>;
114
142
  /**
115
143
  * Look up a session by taskId (primary key) or by sessionId (fallback).
116
144
  */
@@ -24,6 +24,8 @@ import { gitCredentialConfigArgs, gitCredentialSecretEnv, } from "./git-credenti
24
24
  import { ensureImage, FORGEAI_IMAGE } from "./image-manager.js";
25
25
  import { vmLog } from "./log.js";
26
26
  import { ensureClaudeToken } from "./claude-token-manager.js";
27
+ import { AGENT_AUTH_JSON_PATHS, loadAgentAuth, } from "./agent-auth-manager.js";
28
+ import { getAgentProfile, resolveAgentType, } from "../orchestrator/agent-profiles.js";
27
29
  // ---------------------------------------------------------------------------
28
30
  // Paths
29
31
  // ---------------------------------------------------------------------------
@@ -59,6 +61,8 @@ export class SessionManager {
59
61
  dockerReady = false;
60
62
  claudeOAuthToken = "";
61
63
  tokenLoadedAt = 0;
64
+ /** Cached Codex/OpenCode credentials, re-read hourly and on refreshToken(). */
65
+ agentAuthCache = {};
62
66
  // -------------------------------------------------------------------------
63
67
  // VM lifecycle (now Docker)
64
68
  // -------------------------------------------------------------------------
@@ -67,11 +71,16 @@ export class SessionManager {
67
71
  * Idempotent: if already checked, returns immediately.
68
72
  */
69
73
  async ensureVmRunning() {
70
- // Re-read token from Keychain every hour (catches external refresh via setup-claude)
74
+ // Re-read credentials from Keychain every hour (catches external refresh
75
+ // via forge auth setup-claude / setup-codex / setup-opencode)
71
76
  if (this.dockerReady) {
72
77
  const TOKEN_REFRESH_MS = 60 * 60 * 1000; // 1 hour
73
78
  if (Date.now() - this.tokenLoadedAt > TOKEN_REFRESH_MS) {
74
- await this.refreshToken();
79
+ this.agentAuthCache = {};
80
+ if (this.claudeOAuthToken) {
81
+ await this.refreshToken();
82
+ }
83
+ this.tokenLoadedAt = Date.now();
75
84
  }
76
85
  return;
77
86
  }
@@ -84,8 +93,8 @@ export class SessionManager {
84
93
  await ensureImage();
85
94
  // Create host directories
86
95
  mkdirSync(SESSIONS_DIR, { recursive: true, mode: 0o700 });
87
- // Obtain Claude OAuth token for container authentication
88
- this.claudeOAuthToken = await ensureClaudeToken();
96
+ // Agent credentials are loaded lazily per engine at spawn time
97
+ // (getAgentSecretEnv / materializeAgentAuth).
89
98
  this.tokenLoadedAt = Date.now();
90
99
  this.dockerReady = true;
91
100
  vmLog.cyan("session-manager", "Docker ready");
@@ -284,25 +293,29 @@ export class SessionManager {
284
293
  * Spawn an agent inside the container.
285
294
  * Returns a DockerProcessProxy that satisfies the AgentProcess interface.
286
295
  */
287
- async spawnAgent(taskId, cmd, args, env = {}) {
296
+ async spawnAgent(taskId, cmd, args, env = {}, agentType = "CLAUDE_CODE") {
288
297
  const session = this.getSession(taskId);
289
298
  if (!session)
290
299
  throw new Error(`Session for task ${taskId} not found`);
300
+ const profile = getAgentProfile(agentType);
291
301
  session.status = "SPAWNING";
292
- // Log the full command for debugging
302
+ session.agentType = profile.type;
303
+ // Log the full command for debugging. The prompt is either the value of
304
+ // -p (Claude Code) or the last positional argument (Codex / OpenCode).
293
305
  const promptArgIdx = args.indexOf("-p");
294
- const promptPreview = promptArgIdx >= 0 && args[promptArgIdx + 1]
295
- ? `"${args[promptArgIdx + 1].slice(0, 100)}${args[promptArgIdx + 1].length > 100 ? "..." : ""}"`
296
- : "(no -p)";
306
+ const promptArg = promptArgIdx >= 0 ? args[promptArgIdx + 1] : args[args.length - 1];
307
+ const promptPreview = promptArg
308
+ ? `"${promptArg.slice(0, 100)}${promptArg.length > 100 ? "..." : ""}"`
309
+ : "(no prompt)";
297
310
  const flags = args.filter((a) => a.startsWith("--")).join(" ");
298
- vmLog.magenta("session-manager", `${cmd} ${flags} -p ${promptPreview}`, session.id);
311
+ vmLog.magenta("session-manager", `${cmd} ${flags} ${promptPreview}`, session.id);
299
312
  // Spawn the process in the container
300
313
  const dockerExecProcess = spawnInContainer(session.containerId, [cmd, ...args], {
301
314
  cwd: "/session/workspace",
302
- env,
303
- // The long-lived Claude token goes through secretEnv (name-only -e) so
304
- // it never lands in the host process table / `ps` output.
305
- secretEnv: { CLAUDE_CODE_OAUTH_TOKEN: this.claudeOAuthToken },
315
+ env: { ...profile.spawnEnv, ...env },
316
+ // Credentials go through secretEnv (name-only -e) so they never land
317
+ // in the host process table / `ps` output.
318
+ secretEnv: await this.getAgentSecretEnv(resolveAgentType(agentType)),
306
319
  user: "agent",
307
320
  });
308
321
  // Create a kill function that removes the container when the process exits
@@ -315,7 +328,7 @@ export class SessionManager {
315
328
  }
316
329
  };
317
330
  // Create proxy wrapping the docker exec process
318
- const proxy = new DockerProcessProxy(session.id, dockerExecProcess, killFn);
331
+ const proxy = new DockerProcessProxy(session.id, dockerExecProcess, killFn, profile.authErrorPatterns, profile.authSetupHint);
319
332
  session.agentPid = proxy.pid;
320
333
  session.agentProcess = dockerExecProcess;
321
334
  session.status = "RUNNING";
@@ -323,6 +336,87 @@ export class SessionManager {
323
336
  return proxy;
324
337
  }
325
338
  // -------------------------------------------------------------------------
339
+ // Agent credentials
340
+ // -------------------------------------------------------------------------
341
+ /**
342
+ * Secret env vars for an agent spawn, per engine:
343
+ * - CLAUDE_CODE: long-lived OAuth token via CLAUDE_CODE_OAUTH_TOKEN
344
+ * - OPENCODE (api_key): the provider's standard env var (ANTHROPIC_API_KEY, ...)
345
+ * - CODEX / OPENCODE (auth_json): nothing — credentials were materialized
346
+ * into the container home by materializeAgentAuth()
347
+ */
348
+ async getAgentSecretEnv(agentType) {
349
+ if (agentType === "CLAUDE_CODE") {
350
+ if (!this.claudeOAuthToken) {
351
+ this.claudeOAuthToken = await ensureClaudeToken();
352
+ this.tokenLoadedAt = Date.now();
353
+ }
354
+ return { CLAUDE_CODE_OAUTH_TOKEN: this.claudeOAuthToken };
355
+ }
356
+ if (agentType === "OPENCODE") {
357
+ const auth = await this.getAgentAuth("OPENCODE");
358
+ if (auth.kind === "api_key") {
359
+ return { [auth.envVar ?? "ANTHROPIC_API_KEY"]: auth.value };
360
+ }
361
+ }
362
+ return {};
363
+ }
364
+ /** Load (and cache) stored Codex/OpenCode credentials, or throw with a fix hint. */
365
+ async getAgentAuth(agent) {
366
+ const cached = this.agentAuthCache[agent];
367
+ if (cached)
368
+ return cached;
369
+ const loaded = await loadAgentAuth(agent);
370
+ if (!loaded) {
371
+ throw new Error(`No ${agent} credentials stored. Run: ${getAgentProfile(agent).authSetupHint}`);
372
+ }
373
+ this.agentAuthCache[agent] = loaded;
374
+ return loaded;
375
+ }
376
+ /**
377
+ * Materialize agent credentials inside the container home when the engine
378
+ * reads them from disk rather than env vars:
379
+ * - CODEX api_key: `codex login --with-api-key` (writes ~/.codex/auth.json)
380
+ * - CODEX / OPENCODE auth_json: write the imported auth.json
381
+ * The secret travels via secretEnv (name-only -e), never via argv, and is
382
+ * written inside the container filesystem — not on the /session host volume.
383
+ */
384
+ async materializeAgentAuth(taskId, agentType) {
385
+ const resolved = resolveAgentType(agentType);
386
+ if (resolved === "CLAUDE_CODE")
387
+ return;
388
+ const session = this.getSession(taskId);
389
+ if (!session)
390
+ throw new Error(`Session for task ${taskId} not found`);
391
+ const auth = await this.getAgentAuth(resolved);
392
+ if (resolved === "CODEX" && auth.kind === "api_key") {
393
+ const result = await execInContainer(session.containerId, [
394
+ "bash",
395
+ "-c",
396
+ "printenv FORGE_AGENT_SECRET | codex login --with-api-key",
397
+ ], { secretEnv: { FORGE_AGENT_SECRET: auth.value }, timeout: 30_000 });
398
+ if (result.exitCode !== 0) {
399
+ throw new Error(`codex login failed (exit ${result.exitCode}): ${result.stderr.slice(0, 200)}`);
400
+ }
401
+ vmLog.dim("session-manager", "Codex API key configured", session.id);
402
+ return;
403
+ }
404
+ if (auth.kind === "auth_json") {
405
+ const targetPath = AGENT_AUTH_JSON_PATHS[resolved];
406
+ const targetDir = targetPath.slice(0, targetPath.lastIndexOf("/"));
407
+ const result = await execInContainer(session.containerId, [
408
+ "bash",
409
+ "-c",
410
+ `mkdir -p ${targetDir} && printenv FORGE_AGENT_SECRET > ${targetPath} && chmod 600 ${targetPath}`,
411
+ ], { secretEnv: { FORGE_AGENT_SECRET: auth.value }, timeout: 15_000 });
412
+ if (result.exitCode !== 0) {
413
+ throw new Error(`Failed to install ${resolved} auth.json (exit ${result.exitCode}): ${result.stderr.slice(0, 200)}`);
414
+ }
415
+ vmLog.dim("session-manager", `${resolved} auth.json installed`, session.id);
416
+ }
417
+ // OPENCODE api_key: injected as env var at spawn — nothing to materialize.
418
+ }
419
+ // -------------------------------------------------------------------------
326
420
  // Agent lifecycle
327
421
  // -------------------------------------------------------------------------
328
422
  /**
@@ -394,11 +488,27 @@ export class SessionManager {
394
488
  return this.dockerReady;
395
489
  }
396
490
  /**
397
- * Reload token from Keychain/file. Called after auth errors to pick up
398
- * a refreshed token without restarting the CLI.
399
- * Returns true if a new token was loaded.
491
+ * Reload credentials from Keychain/file. Called after auth errors to pick up
492
+ * refreshed credentials without restarting the CLI.
493
+ * Returns true if new credentials were loaded.
400
494
  */
401
- async refreshToken() {
495
+ async refreshToken(agentType = "CLAUDE_CODE") {
496
+ const resolved = resolveAgentType(agentType);
497
+ if (resolved === "CODEX" || resolved === "OPENCODE") {
498
+ const previous = this.agentAuthCache[resolved];
499
+ delete this.agentAuthCache[resolved];
500
+ const fresh = await loadAgentAuth(resolved);
501
+ if (!fresh) {
502
+ vmLog.warn("session-manager", `No ${resolved} credentials found. Run: ${getAgentProfile(resolved).authSetupHint}`);
503
+ return false;
504
+ }
505
+ this.agentAuthCache[resolved] = fresh;
506
+ const changed = !previous || JSON.stringify(previous) !== JSON.stringify(fresh);
507
+ if (!changed) {
508
+ vmLog.warn("session-manager", `${resolved} credentials unchanged — same invalid credentials. Run: ${getAgentProfile(resolved).authSetupHint}`);
509
+ }
510
+ return changed;
511
+ }
402
512
  const { loadClaudeToken } = await import("./claude-token-manager.js");
403
513
  const fresh = await loadClaudeToken();
404
514
  const preview = (t) => `${t.slice(0, 15)}...${t.slice(-4)}`;
@@ -1,16 +1,19 @@
1
1
  /**
2
- * Session snapshot: save and restore Claude Code session files for workflow resume.
2
+ * Session snapshot: save and restore agent session state for workflow resume.
3
3
  *
4
4
  * Before destroying a container after idle timeout:
5
5
  * 1. Auto-save code: git add -A && git commit && git push
6
- * 2. Tar Claude Code session files (~/.claude/projects/)
6
+ * 2. Tar the agent's session state dir (profile.stateDir, e.g. ~/.claude,
7
+ * ~/.codex, ~/.local/share/opencode) minus credentials/junk
7
8
  * 3. Upload encrypted snapshot to Cloud via socket event
8
9
  *
9
10
  * On resume in a new container:
10
11
  * 1. Download snapshot from Cloud
11
- * 2. Restore Claude Code session files
12
- * 3. Launch `claude --continue` (full context restored)
12
+ * 2. Restore the state dir (only if the snapshot was produced by the same
13
+ * agent engine see the .forgeai-agent-type marker)
14
+ * 3. Launch the engine's continue command (full context restored)
13
15
  */
16
+ import type { AgentProfile } from "../orchestrator/agent-profiles.js";
14
17
  import { type GitCredentials } from "./git-credentials.js";
15
18
  import type { SessionState } from "./session-manager.js";
16
19
  /**
@@ -19,13 +22,15 @@ import type { SessionState } from "./session-manager.js";
19
22
  */
20
23
  export declare function autoSaveCode(containerId: string, sessionId?: string, credentials?: GitCredentials): Promise<void>;
21
24
  /**
22
- * Save Claude Code session files from the container to a base64 tar.gz string.
25
+ * Save agent session state from the container to a base64 tar.gz string.
23
26
  *
24
27
  * Returns the base64-encoded tar.gz, or null if snapshot is unavailable or too large.
25
28
  */
26
- export declare function createSessionSnapshot(session: SessionState): Promise<string | null>;
29
+ export declare function createSessionSnapshot(session: SessionState, profile: AgentProfile): Promise<string | null>;
27
30
  /**
28
- * Restore Claude Code session files into a container from a base64 tar.gz string.
31
+ * Restore agent session state into a container from a base64 tar.gz string.
32
+ * Returns false (without extracting into the live state dir) when the
33
+ * snapshot was produced by a different agent engine.
29
34
  */
30
- export declare function restoreSessionSnapshot(session: SessionState, snapshotBase64: string): Promise<boolean>;
35
+ export declare function restoreSessionSnapshot(session: SessionState, snapshotBase64: string, profile: AgentProfile): Promise<boolean>;
31
36
  //# sourceMappingURL=session-snapshot.d.ts.map
@@ -1,15 +1,17 @@
1
1
  /**
2
- * Session snapshot: save and restore Claude Code session files for workflow resume.
2
+ * Session snapshot: save and restore agent session state for workflow resume.
3
3
  *
4
4
  * Before destroying a container after idle timeout:
5
5
  * 1. Auto-save code: git add -A && git commit && git push
6
- * 2. Tar Claude Code session files (~/.claude/projects/)
6
+ * 2. Tar the agent's session state dir (profile.stateDir, e.g. ~/.claude,
7
+ * ~/.codex, ~/.local/share/opencode) minus credentials/junk
7
8
  * 3. Upload encrypted snapshot to Cloud via socket event
8
9
  *
9
10
  * On resume in a new container:
10
11
  * 1. Download snapshot from Cloud
11
- * 2. Restore Claude Code session files
12
- * 3. Launch `claude --continue` (full context restored)
12
+ * 2. Restore the state dir (only if the snapshot was produced by the same
13
+ * agent engine see the .forgeai-agent-type marker)
14
+ * 3. Launch the engine's continue command (full context restored)
13
15
  */
14
16
  import { readFileSync, existsSync } from "node:fs";
15
17
  import { join } from "node:path";
@@ -18,6 +20,14 @@ import { gitCredentialConfigShellFragment, gitCredentialSecretEnv, } from "./git
18
20
  import { vmLog } from "./log.js";
19
21
  /** Max snapshot size: 5MB compressed */
20
22
  const MAX_SNAPSHOT_SIZE = 5 * 1024 * 1024;
23
+ /** Tarball transport name on the /session volume. */
24
+ const SNAPSHOT_TAR = "agent-session.tar.gz";
25
+ /**
26
+ * Marker file inside the snapshot recording which engine produced it, so a
27
+ * snapshot taken under one agent type is never restored into another engine's
28
+ * state dir (the restore falls back to the prompt-based resume instead).
29
+ */
30
+ const AGENT_TYPE_MARKER = ".forgeai-agent-type";
21
31
  /**
22
32
  * Auto-save code changes in the container (best-effort git commit + push).
23
33
  * Does not throw — failures are logged and ignored.
@@ -87,28 +97,29 @@ export async function autoSaveCode(containerId, sessionId, credentials) {
87
97
  }
88
98
  }
89
99
  /**
90
- * Save Claude Code session files from the container to a base64 tar.gz string.
100
+ * Save agent session state from the container to a base64 tar.gz string.
91
101
  *
92
102
  * Returns the base64-encoded tar.gz, or null if snapshot is unavailable or too large.
93
103
  */
94
- export async function createSessionSnapshot(session) {
104
+ export async function createSessionSnapshot(session, profile) {
95
105
  const sessionId = session.id;
106
+ const stateDir = profile.stateDir;
96
107
  try {
97
- // Tar the entire Claude Code config directory (~/.claude/).
98
- // --continue needs: history.jsonl (global session index) + projects/<encoded-path>/ (session JSONL files)
99
- // Exclude settings.json and other non-session files to keep size small.
108
+ // Tar the engine's session state dir (e.g. ~/.claude for Claude Code:
109
+ // --continue needs history.jsonl + projects/<encoded-path>/ session files).
110
+ // Exclude credentials and non-session files to keep size small and to
111
+ // never ship secrets to the Cloud. A marker file records the engine so a
112
+ // restore under a different agent type is rejected.
113
+ const excludes = profile.snapshotExcludes
114
+ .map((pattern) => `--exclude='${pattern}'`)
115
+ .join(" ");
100
116
  const tarResult = await execInContainer(session.containerId, [
101
117
  "bash",
102
118
  "-c",
103
119
  [
104
- "test -d /home/agent/.claude || exit 0",
105
- "tar czf /session/claude-session.tar.gz -C /home/agent/.claude" +
106
- " --exclude='settings.json'" +
107
- " --exclude='credentials.json'" +
108
- " --exclude='statsig'" +
109
- " --exclude='commands'" +
110
- " --exclude='todos'" +
111
- " .",
120
+ `test -d ${stateDir} || exit 0`,
121
+ `printf '%s' '${profile.type}' > ${stateDir}/${AGENT_TYPE_MARKER}`,
122
+ `tar czf /session/${SNAPSHOT_TAR} -C ${stateDir} ${excludes} .`,
112
123
  ].join(" && "),
113
124
  ], { timeout: 30_000 });
114
125
  if (tarResult.exitCode !== 0) {
@@ -116,9 +127,9 @@ export async function createSessionSnapshot(session) {
116
127
  return null;
117
128
  }
118
129
  // Read the tar from the host volume
119
- const tarPath = join(session.sessionDir, "claude-session.tar.gz");
130
+ const tarPath = join(session.sessionDir, SNAPSHOT_TAR);
120
131
  if (!existsSync(tarPath)) {
121
- vmLog.dim("session-snapshot", "No Claude session directory found in container, skipping snapshot", sessionId);
132
+ vmLog.dim("session-snapshot", `No ${profile.type} session directory found in container, skipping snapshot`, sessionId);
122
133
  return null;
123
134
  }
124
135
  const tarBuffer = readFileSync(tarPath);
@@ -136,28 +147,33 @@ export async function createSessionSnapshot(session) {
136
147
  }
137
148
  }
138
149
  /**
139
- * Restore Claude Code session files into a container from a base64 tar.gz string.
150
+ * Restore agent session state into a container from a base64 tar.gz string.
151
+ * Returns false (without extracting into the live state dir) when the
152
+ * snapshot was produced by a different agent engine.
140
153
  */
141
- export async function restoreSessionSnapshot(session, snapshotBase64) {
154
+ export async function restoreSessionSnapshot(session, snapshotBase64, profile) {
142
155
  const sessionId = session.id;
156
+ const stateDir = profile.stateDir;
143
157
  try {
144
158
  // Write the tar to the host volume
145
- const tarPath = join(session.sessionDir, "claude-session.tar.gz");
159
+ const tarPath = join(session.sessionDir, SNAPSHOT_TAR);
146
160
  const { writeFileSync } = await import("node:fs");
147
161
  writeFileSync(tarPath, Buffer.from(snapshotBase64, "base64"));
148
- // Create the target directory and extract (entire ~/.claude/)
149
- await execInContainer(session.containerId, [
150
- "mkdir",
151
- "-p",
152
- "/home/agent/.claude",
153
- ]);
154
- const extractResult = await execInContainer(session.containerId, [
155
- "tar",
156
- "xzf",
157
- "/session/claude-session.tar.gz",
158
- "-C",
159
- "/home/agent/.claude",
162
+ // Check the engine marker before touching the state dir. Snapshots from
163
+ // before the marker existed were always Claude Code.
164
+ const markerResult = await execInContainer(session.containerId, [
165
+ "bash",
166
+ "-c",
167
+ `tar -xzf /session/${SNAPSHOT_TAR} -O ./${AGENT_TYPE_MARKER} 2>/dev/null || printf 'CLAUDE_CODE'`,
160
168
  ], { timeout: 30_000 });
169
+ const snapshotAgentType = markerResult.stdout.trim() || "CLAUDE_CODE";
170
+ if (snapshotAgentType !== profile.type) {
171
+ vmLog.warn("session-snapshot", `Snapshot was taken with ${snapshotAgentType}, current engine is ${profile.type} — skipping restore`, sessionId);
172
+ return false;
173
+ }
174
+ // Create the target directory and extract
175
+ await execInContainer(session.containerId, ["mkdir", "-p", stateDir]);
176
+ const extractResult = await execInContainer(session.containerId, ["tar", "xzf", `/session/${SNAPSHOT_TAR}`, "-C", stateDir], { timeout: 30_000 });
161
177
  if (extractResult.exitCode !== 0) {
162
178
  vmLog.warn("session-snapshot", `Tar extract failed (code ${extractResult.exitCode}): ${extractResult.stderr.slice(0, 200)}`, sessionId);
163
179
  return false;
@@ -167,9 +183,9 @@ export async function restoreSessionSnapshot(session, snapshotBase64) {
167
183
  "chown",
168
184
  "-R",
169
185
  "agent:agent",
170
- "/home/agent/.claude",
186
+ stateDir,
171
187
  ]);
172
- vmLog.cyan("session-snapshot", "Restored Claude session snapshot", sessionId);
188
+ vmLog.cyan("session-snapshot", `Restored ${profile.type} session snapshot`, sessionId);
173
189
  return true;
174
190
  }
175
191
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getforgeai/cli",
3
- "version": "0.1.0-beta.2",
3
+ "version": "0.1.0-beta.4",
4
4
  "description": "ForgeAI CLI — runs AI coding agents in local Docker containers with your own AI tokens, and connects them to the ForgeAI cockpit.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -47,7 +47,7 @@
47
47
  "ora": "^8.2.0",
48
48
  "socket.io-client": "^4.8.3",
49
49
  "zod": "^4.3.6",
50
- "@getforgeai/protocol": "0.1.0-beta.1"
50
+ "@getforgeai/protocol": "0.1.0-beta.2"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/node": "^25.0.2",
package/rootfs/Dockerfile CHANGED
@@ -18,8 +18,11 @@ RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | \
18
18
  apt-get install -y -qq nodejs && \
19
19
  rm -rf /var/lib/apt/lists/*
20
20
 
21
- # Claude Code CLI
22
- RUN npm install -g @anthropic-ai/claude-code 2>&1 | tail -3
21
+ # Agent CLIs: Claude Code (primary), Codex and OpenCode (alternative engines).
22
+ # No output pipe: `| tail` would mask npm failures (pipeline exit = tail's).
23
+ # The `command -v` checks make a partial install fail the build loudly.
24
+ RUN npm install -g --loglevel=error @anthropic-ai/claude-code @openai/codex opencode-ai && \
25
+ command -v claude && command -v codex && command -v opencode
23
26
 
24
27
  # ForgeAI MCP relay (baked-in copy for reference only — at runtime the CLI
25
28
  # stages its own copy into /session/forge-mcp-relay.mjs so the relay version