@getforgeai/cli 0.1.0-beta.2 → 0.1.0-beta.3
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/dist/cli.js +10 -1
- package/dist/commands/auth-setup-agent.d.ts +15 -0
- package/dist/commands/auth-setup-agent.js +98 -0
- package/dist/commands/config-set.js +14 -3
- package/dist/commands/connect.js +23 -7
- package/dist/config/config-manager.d.ts +2 -0
- package/dist/lib/connection-manager.d.ts +11 -0
- package/dist/lib/connection-manager.js +48 -25
- package/dist/lib/daemon-entry.js +23 -6
- package/dist/orchestrator/agent-profiles.d.ts +65 -0
- package/dist/orchestrator/agent-profiles.js +236 -0
- package/dist/orchestrator/agent-spawner.d.ts +2 -0
- package/dist/orchestrator/agent-spawner.js +25 -14
- package/dist/orchestrator/billing-detect.d.ts +20 -0
- package/dist/orchestrator/billing-detect.js +120 -0
- package/dist/orchestrator/codex-jsonl-extractor.d.ts +52 -0
- package/dist/orchestrator/codex-jsonl-extractor.js +210 -0
- package/dist/orchestrator/connectors/connector.d.ts +2 -0
- package/dist/orchestrator/connectors/docker-connector.js +192 -175
- package/dist/orchestrator/opencode-jsonl-extractor.d.ts +47 -0
- package/dist/orchestrator/opencode-jsonl-extractor.js +228 -0
- package/dist/orchestrator/resume-handler.js +2 -0
- package/dist/orchestrator/stream-json-extractor.d.ts +38 -10
- package/dist/orchestrator/stream-json-extractor.js +10 -105
- package/dist/vm/agent-auth-manager.d.ts +32 -0
- package/dist/vm/agent-auth-manager.js +68 -0
- package/dist/vm/docker-process-proxy.d.ts +2 -1
- package/dist/vm/docker-process-proxy.js +17 -10
- package/dist/vm/image-manager.js +1 -1
- package/dist/vm/secure-store.d.ts +24 -0
- package/dist/vm/secure-store.js +110 -0
- package/dist/vm/session-manager.d.ts +33 -5
- package/dist/vm/session-manager.js +129 -19
- package/dist/vm/session-snapshot.d.ts +13 -8
- package/dist/vm/session-snapshot.js +52 -36
- package/package.json +2 -2
- 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
|
|
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
|
|
110
|
-
*
|
|
111
|
-
* Returns true if
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
88
|
-
|
|
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
|
-
|
|
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
|
|
295
|
-
|
|
296
|
-
|
|
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}
|
|
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
|
-
//
|
|
304
|
-
//
|
|
305
|
-
secretEnv:
|
|
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
|
|
398
|
-
*
|
|
399
|
-
* Returns true if
|
|
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
|
|
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
|
|
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
|
|
12
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
12
|
-
*
|
|
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
|
|
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
|
|
98
|
-
// --continue needs
|
|
99
|
-
// Exclude
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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,
|
|
130
|
+
const tarPath = join(session.sessionDir, SNAPSHOT_TAR);
|
|
120
131
|
if (!existsSync(tarPath)) {
|
|
121
|
-
vmLog.dim("session-snapshot",
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
"
|
|
152
|
-
"
|
|
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
|
-
|
|
186
|
+
stateDir,
|
|
171
187
|
]);
|
|
172
|
-
vmLog.cyan("session-snapshot",
|
|
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.
|
|
3
|
+
"version": "0.1.0-beta.3",
|
|
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.
|
|
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
|
|
22
|
-
|
|
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
|