@getforgeai/cli 0.1.0-beta.1 → 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/mcp/mcp-probe.d.ts +20 -0
- package/dist/mcp/mcp-probe.js +24 -4
- 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,228 @@
|
|
|
1
|
+
import { vmLog } from "../vm/log.js";
|
|
2
|
+
import { extractResetTimestampFromTexts, matchesBillingPatterns, } from "./billing-detect.js";
|
|
3
|
+
import { TOOL_DETAIL_EXTRACTORS } from "./stream-json-extractor.js";
|
|
4
|
+
/**
|
|
5
|
+
* Maps OpenCode's lowercase built-in tool names to the display names the
|
|
6
|
+
* Cloud UI already knows from Claude Code, with per-tool detail extraction.
|
|
7
|
+
*/
|
|
8
|
+
const OPENCODE_TOOL_MAP = {
|
|
9
|
+
bash: { name: "Bash", detail: (i) => firstLine(i.command, 80) },
|
|
10
|
+
read: { name: "Read", detail: (i) => truncatePath(i.filePath, 60) },
|
|
11
|
+
edit: { name: "Edit", detail: (i) => truncatePath(i.filePath, 60) },
|
|
12
|
+
write: { name: "Write", detail: (i) => truncatePath(i.filePath, 60) },
|
|
13
|
+
patch: { name: "Edit", detail: (i) => truncatePath(i.filePath, 60) },
|
|
14
|
+
grep: { name: "Grep", detail: (i) => truncate(i.pattern, 40) },
|
|
15
|
+
glob: { name: "Glob", detail: (i) => truncate(i.pattern, 60) },
|
|
16
|
+
webfetch: { name: "WebFetch", detail: (i) => truncate(i.url, 60) },
|
|
17
|
+
websearch: { name: "WebSearch", detail: (i) => truncate(i.query, 60) },
|
|
18
|
+
task: { name: "Agent", detail: (i) => truncate(i.description, 60) },
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Extracts normalized events from OpenCode's `opencode run --format json`
|
|
22
|
+
* output.
|
|
23
|
+
*
|
|
24
|
+
* OpenCode emits newline-delimited JSON events, each carrying a `sessionID`
|
|
25
|
+
* and a `part` object:
|
|
26
|
+
* {"type":"step_start","part":{...}}
|
|
27
|
+
* {"type":"text","part":{"id":"prt_...","type":"text","text":"..."}}
|
|
28
|
+
* {"type":"tool_use","part":{"callID":"...","tool":"bash","state":{"status":"completed","input":{...}}}}
|
|
29
|
+
* {"type":"step_finish","part":{"tokens":{"input":N,"output":N,"reasoning":N,"cache":{"read":N,"write":N}}}}
|
|
30
|
+
* {"type":"error",...}
|
|
31
|
+
*
|
|
32
|
+
* Exposes the same callback surface as StreamJsonExtractor so the spawn
|
|
33
|
+
* pipeline (StreamBatcher → Socket.io) is unchanged. OpenCode has no
|
|
34
|
+
* equivalent of Claude's AskUserQuestion tool — structured questions reach
|
|
35
|
+
* the Cloud via the forge_ask_human MCP tool instead.
|
|
36
|
+
*/
|
|
37
|
+
export class OpenCodeJsonlExtractor {
|
|
38
|
+
onText;
|
|
39
|
+
lineBuf = "";
|
|
40
|
+
/** Emitted text length per text part id (suffix dedup across re-emits). */
|
|
41
|
+
emittedTextLengths = new Map();
|
|
42
|
+
/** Tool call ids already reported as activity. */
|
|
43
|
+
emittedCallIds = new Set();
|
|
44
|
+
hasEmittedText = false;
|
|
45
|
+
turnIndex = 0;
|
|
46
|
+
lastError = null;
|
|
47
|
+
/** OpenCode session id (ses_...) — usable with `opencode run --session`. */
|
|
48
|
+
openCodeSessionId = null;
|
|
49
|
+
inputTokens = 0;
|
|
50
|
+
outputTokens = 0;
|
|
51
|
+
onToolActivity;
|
|
52
|
+
onTokenUsage;
|
|
53
|
+
sessionId;
|
|
54
|
+
constructor(onText, _onQuestion, onToolActivity, onTokenUsage, sessionId) {
|
|
55
|
+
this.onText = onText;
|
|
56
|
+
this.onToolActivity = onToolActivity;
|
|
57
|
+
this.onTokenUsage = onTokenUsage;
|
|
58
|
+
this.sessionId = sessionId;
|
|
59
|
+
}
|
|
60
|
+
write(data) {
|
|
61
|
+
this.lineBuf += data;
|
|
62
|
+
let newlineIdx;
|
|
63
|
+
while ((newlineIdx = this.lineBuf.indexOf("\n")) !== -1) {
|
|
64
|
+
const line = this.lineBuf.slice(0, newlineIdx).trim();
|
|
65
|
+
this.lineBuf = this.lineBuf.slice(newlineIdx + 1);
|
|
66
|
+
if (line)
|
|
67
|
+
this.parseLine(line);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
flush() {
|
|
71
|
+
const line = this.lineBuf.trim();
|
|
72
|
+
this.lineBuf = "";
|
|
73
|
+
if (line)
|
|
74
|
+
this.parseLine(line);
|
|
75
|
+
}
|
|
76
|
+
parseLine(line) {
|
|
77
|
+
let obj;
|
|
78
|
+
try {
|
|
79
|
+
obj = JSON.parse(line);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
vmLog.dim("opencode-jsonl", "Non-JSON line skipped", this.sessionId);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (typeof obj.sessionID === "string" && !this.openCodeSessionId) {
|
|
86
|
+
this.openCodeSessionId = obj.sessionID;
|
|
87
|
+
}
|
|
88
|
+
const part = obj.part;
|
|
89
|
+
switch (obj.type) {
|
|
90
|
+
case "step_start":
|
|
91
|
+
this.turnIndex++;
|
|
92
|
+
break;
|
|
93
|
+
case "text":
|
|
94
|
+
this.handleText(part);
|
|
95
|
+
break;
|
|
96
|
+
case "tool_use":
|
|
97
|
+
this.handleToolUse(part);
|
|
98
|
+
break;
|
|
99
|
+
case "step_finish":
|
|
100
|
+
this.handleTokens(part);
|
|
101
|
+
break;
|
|
102
|
+
case "error":
|
|
103
|
+
this.captureError(obj);
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
handleText(part) {
|
|
108
|
+
if (!part || typeof part.text !== "string")
|
|
109
|
+
return;
|
|
110
|
+
const partId = typeof part.id === "string" ? part.id : "__single__";
|
|
111
|
+
const emitted = this.emittedTextLengths.get(partId) ?? 0;
|
|
112
|
+
if (part.text.length <= emitted)
|
|
113
|
+
return;
|
|
114
|
+
const delta = part.text.slice(emitted);
|
|
115
|
+
this.emittedTextLengths.set(partId, part.text.length);
|
|
116
|
+
// Separate consecutive text parts with a blank line
|
|
117
|
+
const prefix = emitted === 0 && this.hasEmittedText ? "\n\n" : "";
|
|
118
|
+
this.hasEmittedText = true;
|
|
119
|
+
this.onText(prefix + delta);
|
|
120
|
+
}
|
|
121
|
+
handleToolUse(part) {
|
|
122
|
+
if (!this.onToolActivity || !part)
|
|
123
|
+
return;
|
|
124
|
+
const callId = typeof part.callID === "string"
|
|
125
|
+
? part.callID
|
|
126
|
+
: typeof part.id === "string"
|
|
127
|
+
? part.id
|
|
128
|
+
: null;
|
|
129
|
+
if (!callId || this.emittedCallIds.has(callId))
|
|
130
|
+
return;
|
|
131
|
+
const tool = typeof part.tool === "string" ? part.tool : null;
|
|
132
|
+
if (!tool)
|
|
133
|
+
return;
|
|
134
|
+
const state = part.state;
|
|
135
|
+
const status = state?.status;
|
|
136
|
+
// Emit as soon as the tool is running (or completed if we only see that)
|
|
137
|
+
if (status !== undefined && status === "pending")
|
|
138
|
+
return;
|
|
139
|
+
const input = (state?.input ?? {});
|
|
140
|
+
const mapped = OPENCODE_TOOL_MAP[tool];
|
|
141
|
+
if (mapped) {
|
|
142
|
+
this.emittedCallIds.add(callId);
|
|
143
|
+
this.onToolActivity({
|
|
144
|
+
toolName: mapped.name,
|
|
145
|
+
toolDetail: mapped.detail(input) ?? mapped.name,
|
|
146
|
+
});
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
// MCP tools surface under their exposed name, possibly prefixed with the
|
|
150
|
+
// server name (e.g. "forgeai_forge_create_task"). Try the forge_* detail
|
|
151
|
+
// extractors after stripping a server prefix.
|
|
152
|
+
const bareName = tool.startsWith("forgeai_")
|
|
153
|
+
? tool.slice("forgeai_".length)
|
|
154
|
+
: tool;
|
|
155
|
+
const extractor = TOOL_DETAIL_EXTRACTORS[bareName];
|
|
156
|
+
this.emittedCallIds.add(callId);
|
|
157
|
+
this.onToolActivity({
|
|
158
|
+
toolName: bareName,
|
|
159
|
+
toolDetail: (extractor ? extractor(input) : null) ?? bareName,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
handleTokens(part) {
|
|
163
|
+
if (!this.onTokenUsage || !part)
|
|
164
|
+
return;
|
|
165
|
+
const tokens = part.tokens;
|
|
166
|
+
if (!tokens)
|
|
167
|
+
return;
|
|
168
|
+
const cache = (tokens.cache ?? {});
|
|
169
|
+
const input = num(tokens.input) + num(cache.read) + num(cache.write);
|
|
170
|
+
const output = num(tokens.output) + num(tokens.reasoning);
|
|
171
|
+
if (input === 0 && output === 0)
|
|
172
|
+
return;
|
|
173
|
+
this.inputTokens += input;
|
|
174
|
+
this.outputTokens += output;
|
|
175
|
+
this.onTokenUsage({
|
|
176
|
+
inputTokens: this.inputTokens,
|
|
177
|
+
outputTokens: this.outputTokens,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
captureError(obj) {
|
|
181
|
+
const error = obj.error ?? obj.part ?? obj;
|
|
182
|
+
if (typeof error === "string") {
|
|
183
|
+
this.lastError = error;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (error && typeof error === "object") {
|
|
187
|
+
const rec = error;
|
|
188
|
+
if (typeof rec.message === "string") {
|
|
189
|
+
this.lastError = rec.message;
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const data = rec.data;
|
|
193
|
+
if (data && typeof data.message === "string") {
|
|
194
|
+
this.lastError = data.message;
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
this.lastError = JSON.stringify(rec).slice(0, 1000);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
isBillingError(extraText) {
|
|
201
|
+
return matchesBillingPatterns([this.lastError, extraText]);
|
|
202
|
+
}
|
|
203
|
+
extractResetTimestamp(extraText) {
|
|
204
|
+
return extractResetTimestampFromTexts([this.lastError, extraText]);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function num(value) {
|
|
208
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
209
|
+
}
|
|
210
|
+
function firstLine(value, maxLen) {
|
|
211
|
+
if (typeof value !== "string" || !value)
|
|
212
|
+
return null;
|
|
213
|
+
const line = value.split("\n")[0] ?? value;
|
|
214
|
+
return line.length > maxLen ? line.slice(0, maxLen - 3) + "..." : line;
|
|
215
|
+
}
|
|
216
|
+
function truncate(value, maxLen) {
|
|
217
|
+
if (typeof value !== "string" || !value)
|
|
218
|
+
return null;
|
|
219
|
+
return value.length > maxLen ? value.slice(0, maxLen - 3) + "..." : value;
|
|
220
|
+
}
|
|
221
|
+
function truncatePath(value, maxLen) {
|
|
222
|
+
if (typeof value !== "string" || !value)
|
|
223
|
+
return null;
|
|
224
|
+
if (value.length <= maxLen)
|
|
225
|
+
return value;
|
|
226
|
+
return "..." + value.slice(-(maxLen - 3));
|
|
227
|
+
}
|
|
228
|
+
//# sourceMappingURL=opencode-jsonl-extractor.js.map
|
|
@@ -105,6 +105,7 @@ export async function handleResume(payload, ctx) {
|
|
|
105
105
|
mcpConfigPath: "/workspace/.claude/mcp_config.json",
|
|
106
106
|
skillName: skill?.name ?? resumeSkillName,
|
|
107
107
|
prompt: validatedPayload.taskCommand,
|
|
108
|
+
agentType: ctx.agentType,
|
|
108
109
|
repoUrl: payload.repoUrl,
|
|
109
110
|
taskBranch,
|
|
110
111
|
skill: skill ? { name: skill.name, files: skill.files } : undefined,
|
|
@@ -149,6 +150,7 @@ export async function handleResume(payload, ctx) {
|
|
|
149
150
|
spawnAgentFn: boundSpawnAgent,
|
|
150
151
|
label: "resume",
|
|
151
152
|
agentSlots: ctx.agentSlots,
|
|
153
|
+
agentType: ctx.agentType,
|
|
152
154
|
});
|
|
153
155
|
}
|
|
154
156
|
catch (err) {
|
|
@@ -20,6 +20,44 @@ export type TokenUsageData = {
|
|
|
20
20
|
inputTokens: number;
|
|
21
21
|
outputTokens: number;
|
|
22
22
|
};
|
|
23
|
+
/**
|
|
24
|
+
* Common surface every agent stream extractor exposes to the spawner.
|
|
25
|
+
* StreamJsonExtractor (Claude Code), CodexJsonlExtractor and
|
|
26
|
+
* OpenCodeJsonlExtractor all satisfy this shape, so the spawn pipeline is
|
|
27
|
+
* agnostic to which agent engine produced the stdout stream.
|
|
28
|
+
*/
|
|
29
|
+
export type AgentStreamExtractor = {
|
|
30
|
+
/** Groups text chunks from the same API turn. */
|
|
31
|
+
turnIndex: number;
|
|
32
|
+
/** Error text captured from the agent's terminal error event, if any. */
|
|
33
|
+
lastError: string | null;
|
|
34
|
+
write(data: string): void;
|
|
35
|
+
/** Flush any remaining partial line on process exit. */
|
|
36
|
+
flush(): void;
|
|
37
|
+
isBillingError(extraText?: string): boolean;
|
|
38
|
+
extractResetTimestamp(extraText?: string): string | null;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Extracts text deltas from Claude Code's `--output-format stream-json` output.
|
|
42
|
+
*
|
|
43
|
+
* With `--include-partial-messages`, Claude emits newline-delimited JSON objects.
|
|
44
|
+
* Text tokens arrive as `stream_event` objects with `event.delta.type === "text_delta"`.
|
|
45
|
+
*
|
|
46
|
+
* Official filter pattern (from docs):
|
|
47
|
+
* select(.type == "stream_event" and .event.delta.type? == "text_delta") | .event.delta.text
|
|
48
|
+
*
|
|
49
|
+
* This extractor also handles:
|
|
50
|
+
* - `assistant` snapshot messages containing AskUserQuestion tool_use blocks:
|
|
51
|
+
* extracts the question text and emits it so the wizard chat user can see it.
|
|
52
|
+
* When `onQuestion` is provided, emits structured data instead of formatted text.
|
|
53
|
+
* - Ignores: thinking, other tool_use, system events, rate limits, etc.
|
|
54
|
+
*/
|
|
55
|
+
/**
|
|
56
|
+
* Tools we extract activity from, with their detail extractor.
|
|
57
|
+
* Exported so the Codex/OpenCode extractors reuse the forge_* MCP entries
|
|
58
|
+
* (the ForgeAI MCP tool names are identical across host agents).
|
|
59
|
+
*/
|
|
60
|
+
export declare const TOOL_DETAIL_EXTRACTORS: Record<string, (input: Record<string, unknown>) => string | null>;
|
|
23
61
|
export declare class StreamJsonExtractor {
|
|
24
62
|
private readonly onText;
|
|
25
63
|
private lineBuf;
|
|
@@ -72,7 +110,6 @@ export declare class StreamJsonExtractor {
|
|
|
72
110
|
private formatOptionLine;
|
|
73
111
|
private formatQuestionOptions;
|
|
74
112
|
private formatQuestions;
|
|
75
|
-
private static readonly BILLING_PATTERNS;
|
|
76
113
|
/**
|
|
77
114
|
* Check if the captured error (from result event or accumulated stderr)
|
|
78
115
|
* indicates a billing/token exhaustion issue.
|
|
@@ -81,17 +118,8 @@ export declare class StreamJsonExtractor {
|
|
|
81
118
|
isBillingError(extraText?: string): boolean;
|
|
82
119
|
/**
|
|
83
120
|
* Extract the usage-limit reset timestamp from error text.
|
|
84
|
-
* Handles multiple formats:
|
|
85
|
-
* - "reset at 2026-03-08T15:00:00+00:00" (ISO datetime)
|
|
86
|
-
* - "resets 7pm (Europe/Paris)" (time + timezone)
|
|
87
|
-
* - "resets in 2 hours" (relative duration)
|
|
88
121
|
* Returns an ISO string if a valid future timestamp is found, otherwise null.
|
|
89
122
|
*/
|
|
90
123
|
extractResetTimestamp(extraText?: string): string | null;
|
|
91
|
-
/**
|
|
92
|
-
* Resolve a time-of-day (e.g. "7pm") in a named timezone to an ISO string.
|
|
93
|
-
* Uses Intl.DateTimeFormat to avoid external dependencies.
|
|
94
|
-
*/
|
|
95
|
-
private static resolveTimeInTimezone;
|
|
96
124
|
}
|
|
97
125
|
//# sourceMappingURL=stream-json-extractor.d.ts.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { vmLog } from "../vm/log.js";
|
|
2
|
+
import { extractResetTimestampFromTexts, matchesBillingPatterns, } from "./billing-detect.js";
|
|
2
3
|
/**
|
|
3
4
|
* Extracts text deltas from Claude Code's `--output-format stream-json` output.
|
|
4
5
|
*
|
|
@@ -14,8 +15,12 @@ import { vmLog } from "../vm/log.js";
|
|
|
14
15
|
* When `onQuestion` is provided, emits structured data instead of formatted text.
|
|
15
16
|
* - Ignores: thinking, other tool_use, system events, rate limits, etc.
|
|
16
17
|
*/
|
|
17
|
-
/**
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Tools we extract activity from, with their detail extractor.
|
|
20
|
+
* Exported so the Codex/OpenCode extractors reuse the forge_* MCP entries
|
|
21
|
+
* (the ForgeAI MCP tool names are identical across host agents).
|
|
22
|
+
*/
|
|
23
|
+
export const TOOL_DETAIL_EXTRACTORS = {
|
|
19
24
|
Read: (input) => truncateEnd(input.file_path, 60),
|
|
20
25
|
Edit: (input) => truncateEnd(input.file_path, 60),
|
|
21
26
|
Write: (input) => truncateEnd(input.file_path, 60),
|
|
@@ -459,121 +464,21 @@ export class StreamJsonExtractor {
|
|
|
459
464
|
}
|
|
460
465
|
return parts.length > 0 ? "\n" + parts.join("\n") + "\n" : "";
|
|
461
466
|
}
|
|
462
|
-
// --- Billing / token error detection ---
|
|
463
|
-
static BILLING_PATTERNS = [
|
|
464
|
-
/credit.{0,20}balance/i,
|
|
465
|
-
/insufficient.{0,20}(?:credit|fund|balance|quota)/i,
|
|
466
|
-
/exceed.{0,20}(?:quota|budget|limit)/i,
|
|
467
|
-
/usage.{0,20}limit.{0,20}reached/i,
|
|
468
|
-
/limit.{0,20}(?:will )?reset/i,
|
|
469
|
-
/(?:hit|reached).{0,10}(?:your )?limit/i,
|
|
470
|
-
/billing/i,
|
|
471
|
-
/payment.{0,20}(?:required|failed|method)/i,
|
|
472
|
-
/account.{0,20}(?:suspended|deactivated)/i,
|
|
473
|
-
/out of (?:credit|token)/i,
|
|
474
|
-
];
|
|
467
|
+
// --- Billing / token error detection (shared with other extractors) ---
|
|
475
468
|
/**
|
|
476
469
|
* Check if the captured error (from result event or accumulated stderr)
|
|
477
470
|
* indicates a billing/token exhaustion issue.
|
|
478
471
|
* Optionally accepts extra text (e.g. stderr buffer) to scan.
|
|
479
472
|
*/
|
|
480
473
|
isBillingError(extraText) {
|
|
481
|
-
|
|
482
|
-
return texts.some((text) => StreamJsonExtractor.BILLING_PATTERNS.some((re) => re.test(text)));
|
|
474
|
+
return matchesBillingPatterns([this.lastError, extraText]);
|
|
483
475
|
}
|
|
484
476
|
/**
|
|
485
477
|
* Extract the usage-limit reset timestamp from error text.
|
|
486
|
-
* Handles multiple formats:
|
|
487
|
-
* - "reset at 2026-03-08T15:00:00+00:00" (ISO datetime)
|
|
488
|
-
* - "resets 7pm (Europe/Paris)" (time + timezone)
|
|
489
|
-
* - "resets in 2 hours" (relative duration)
|
|
490
478
|
* Returns an ISO string if a valid future timestamp is found, otherwise null.
|
|
491
479
|
*/
|
|
492
480
|
extractResetTimestamp(extraText) {
|
|
493
|
-
|
|
494
|
-
for (const text of texts) {
|
|
495
|
-
// Pattern 1: "reset at <ISO datetime>"
|
|
496
|
-
const isoMatch = /reset\s+at\s+(.+?)(?:\.|$)/i.exec(text);
|
|
497
|
-
if (isoMatch) {
|
|
498
|
-
const dateStr = isoMatch[1].trim();
|
|
499
|
-
const parsed = new Date(dateStr);
|
|
500
|
-
if (!Number.isNaN(parsed.getTime()) && parsed.getTime() > Date.now()) {
|
|
501
|
-
return parsed.toISOString();
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
// Pattern 2: "resets 7pm (Europe/Paris)" or "resets 19:00 (Europe/Paris)"
|
|
505
|
-
const tzMatch = /resets?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*\(([^)]+)\)/i.exec(text);
|
|
506
|
-
if (tzMatch) {
|
|
507
|
-
const result = StreamJsonExtractor.resolveTimeInTimezone(tzMatch[1], tzMatch[2], tzMatch[3], tzMatch[4]);
|
|
508
|
-
if (result)
|
|
509
|
-
return result;
|
|
510
|
-
}
|
|
511
|
-
// Pattern 3: "resets in 2 hours" or "resets in 30 minutes"
|
|
512
|
-
const relMatch = /resets?\s+in\s+(\d+)\s*(hour|hr|minute|min)s?/i.exec(text);
|
|
513
|
-
if (relMatch) {
|
|
514
|
-
const amount = parseInt(relMatch[1], 10);
|
|
515
|
-
const unit = relMatch[2].toLowerCase();
|
|
516
|
-
const ms = unit.startsWith("hour") || unit === "hr"
|
|
517
|
-
? amount * 3600_000
|
|
518
|
-
: amount * 60_000;
|
|
519
|
-
const future = new Date(Date.now() + ms);
|
|
520
|
-
return future.toISOString();
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
return null;
|
|
524
|
-
}
|
|
525
|
-
/**
|
|
526
|
-
* Resolve a time-of-day (e.g. "7pm") in a named timezone to an ISO string.
|
|
527
|
-
* Uses Intl.DateTimeFormat to avoid external dependencies.
|
|
528
|
-
*/
|
|
529
|
-
static resolveTimeInTimezone(hourStr, minuteStr, ampm, timezone) {
|
|
530
|
-
try {
|
|
531
|
-
let hour = parseInt(hourStr, 10);
|
|
532
|
-
const minute = minuteStr ? parseInt(minuteStr, 10) : 0;
|
|
533
|
-
if (ampm) {
|
|
534
|
-
const isPm = ampm.toLowerCase() === "pm";
|
|
535
|
-
if (isPm && hour < 12)
|
|
536
|
-
hour += 12;
|
|
537
|
-
if (!isPm && hour === 12)
|
|
538
|
-
hour = 0;
|
|
539
|
-
}
|
|
540
|
-
// Get current date in the target timezone
|
|
541
|
-
const now = new Date();
|
|
542
|
-
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
543
|
-
timeZone: timezone,
|
|
544
|
-
year: "numeric",
|
|
545
|
-
month: "2-digit",
|
|
546
|
-
day: "2-digit",
|
|
547
|
-
hour: "2-digit",
|
|
548
|
-
minute: "2-digit",
|
|
549
|
-
hour12: false,
|
|
550
|
-
});
|
|
551
|
-
const parts = formatter.formatToParts(now);
|
|
552
|
-
const get = (type) => parseInt(parts.find((p) => p.type === type)?.value ?? "0", 10);
|
|
553
|
-
const tzYear = get("year");
|
|
554
|
-
const tzMonth = get("month");
|
|
555
|
-
const tzDay = get("day");
|
|
556
|
-
const tzHour = get("hour");
|
|
557
|
-
const tzMinute = get("minute");
|
|
558
|
-
// Build target time in the timezone's "local" frame
|
|
559
|
-
// Then compute the UTC offset to convert to absolute time
|
|
560
|
-
const localNowMs = Date.UTC(tzYear, tzMonth - 1, tzDay, tzHour, tzMinute);
|
|
561
|
-
const offsetMs = localNowMs - now.getTime();
|
|
562
|
-
// Target time today in timezone-local frame
|
|
563
|
-
let targetLocalMs = Date.UTC(tzYear, tzMonth - 1, tzDay, hour, minute);
|
|
564
|
-
// If already past, try tomorrow
|
|
565
|
-
if (targetLocalMs - offsetMs <= now.getTime()) {
|
|
566
|
-
targetLocalMs += 86_400_000;
|
|
567
|
-
}
|
|
568
|
-
const result = new Date(targetLocalMs - offsetMs);
|
|
569
|
-
if (result.getTime() > Date.now()) {
|
|
570
|
-
return result.toISOString();
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
catch {
|
|
574
|
-
// Invalid timezone or parse error — fall through
|
|
575
|
-
}
|
|
576
|
-
return null;
|
|
481
|
+
return extractResetTimestampFromTexts([this.lastError, extraText]);
|
|
577
482
|
}
|
|
578
483
|
}
|
|
579
484
|
//# sourceMappingURL=stream-json-extractor.js.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth storage for Codex and OpenCode agent engines.
|
|
3
|
+
* (Claude Code keeps its dedicated claude-token-manager.ts.)
|
|
4
|
+
*
|
|
5
|
+
* Two credential kinds:
|
|
6
|
+
* - "api_key": a provider API key. For Codex it is fed to
|
|
7
|
+
* `codex login --with-api-key` inside the container; for OpenCode it is
|
|
8
|
+
* injected as the provider's standard env var (envVar) at agent spawn.
|
|
9
|
+
* - "auth_json": a full auth.json file content imported from the host
|
|
10
|
+
* (ChatGPT-plan login for Codex, OAuth/Pro-Max login for OpenCode),
|
|
11
|
+
* written into the container home before spawning.
|
|
12
|
+
*
|
|
13
|
+
* Storage: macOS Keychain / encrypted file via secure-store.ts. The payload
|
|
14
|
+
* is stored as a JSON string.
|
|
15
|
+
*/
|
|
16
|
+
export type AuthAgent = "CODEX" | "OPENCODE";
|
|
17
|
+
export type AgentAuthPayload = {
|
|
18
|
+
kind: "api_key";
|
|
19
|
+
value: string;
|
|
20
|
+
envVar?: string;
|
|
21
|
+
} | {
|
|
22
|
+
kind: "auth_json";
|
|
23
|
+
value: string;
|
|
24
|
+
};
|
|
25
|
+
/** Container path where each engine expects its auth.json. */
|
|
26
|
+
export declare const AGENT_AUTH_JSON_PATHS: Record<AuthAgent, string>;
|
|
27
|
+
/** Host path each engine's own CLI stores its auth.json at (for import). */
|
|
28
|
+
export declare function getHostAuthJsonPath(agent: AuthAgent): string;
|
|
29
|
+
export declare function saveAgentAuth(agent: AuthAgent, payload: AgentAuthPayload): Promise<void>;
|
|
30
|
+
export declare function loadAgentAuth(agent: AuthAgent): Promise<AgentAuthPayload | null>;
|
|
31
|
+
export declare function hasAgentAuth(agent: AuthAgent): Promise<boolean>;
|
|
32
|
+
//# sourceMappingURL=agent-auth-manager.d.ts.map
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth storage for Codex and OpenCode agent engines.
|
|
3
|
+
* (Claude Code keeps its dedicated claude-token-manager.ts.)
|
|
4
|
+
*
|
|
5
|
+
* Two credential kinds:
|
|
6
|
+
* - "api_key": a provider API key. For Codex it is fed to
|
|
7
|
+
* `codex login --with-api-key` inside the container; for OpenCode it is
|
|
8
|
+
* injected as the provider's standard env var (envVar) at agent spawn.
|
|
9
|
+
* - "auth_json": a full auth.json file content imported from the host
|
|
10
|
+
* (ChatGPT-plan login for Codex, OAuth/Pro-Max login for OpenCode),
|
|
11
|
+
* written into the container home before spawning.
|
|
12
|
+
*
|
|
13
|
+
* Storage: macOS Keychain / encrypted file via secure-store.ts. The payload
|
|
14
|
+
* is stored as a JSON string.
|
|
15
|
+
*/
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { vmLog } from "./log.js";
|
|
19
|
+
import { loadSecret, saveSecret } from "./secure-store.js";
|
|
20
|
+
const STORE_LOCATIONS = {
|
|
21
|
+
CODEX: {
|
|
22
|
+
keychainService: "ForgeAI-codex-auth",
|
|
23
|
+
keychainAccount: "codex-auth",
|
|
24
|
+
fileName: "codex-auth.json",
|
|
25
|
+
fileSalt: "forgeai-codex-auth-salt",
|
|
26
|
+
},
|
|
27
|
+
OPENCODE: {
|
|
28
|
+
keychainService: "ForgeAI-opencode-auth",
|
|
29
|
+
keychainAccount: "opencode-auth",
|
|
30
|
+
fileName: "opencode-auth.json",
|
|
31
|
+
fileSalt: "forgeai-opencode-auth-salt",
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
/** Container path where each engine expects its auth.json. */
|
|
35
|
+
export const AGENT_AUTH_JSON_PATHS = {
|
|
36
|
+
CODEX: "/home/agent/.codex/auth.json",
|
|
37
|
+
OPENCODE: "/home/agent/.local/share/opencode/auth.json",
|
|
38
|
+
};
|
|
39
|
+
/** Host path each engine's own CLI stores its auth.json at (for import). */
|
|
40
|
+
export function getHostAuthJsonPath(agent) {
|
|
41
|
+
return agent === "CODEX"
|
|
42
|
+
? join(homedir(), ".codex", "auth.json")
|
|
43
|
+
: join(homedir(), ".local", "share", "opencode", "auth.json");
|
|
44
|
+
}
|
|
45
|
+
export async function saveAgentAuth(agent, payload) {
|
|
46
|
+
await saveSecret(STORE_LOCATIONS[agent], JSON.stringify(payload));
|
|
47
|
+
vmLog.info("agent-auth", `${agent} credentials stored securely (${payload.kind})`);
|
|
48
|
+
}
|
|
49
|
+
export async function loadAgentAuth(agent) {
|
|
50
|
+
const raw = await loadSecret(STORE_LOCATIONS[agent]);
|
|
51
|
+
if (!raw)
|
|
52
|
+
return null;
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(raw);
|
|
55
|
+
if (parsed.kind !== "api_key" && parsed.kind !== "auth_json")
|
|
56
|
+
return null;
|
|
57
|
+
if (typeof parsed.value !== "string" || !parsed.value)
|
|
58
|
+
return null;
|
|
59
|
+
return parsed;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export async function hasAgentAuth(agent) {
|
|
66
|
+
return (await loadAgentAuth(agent)) !== null;
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=agent-auth-manager.js.map
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { EventEmitter } from "node:events";
|
|
9
9
|
import { PassThrough } from "node:stream";
|
|
10
10
|
import type { ChildProcess } from "node:child_process";
|
|
11
|
+
import type { AgentAuthErrorPatterns } from "../orchestrator/agent-profiles.js";
|
|
11
12
|
import type { AgentProcess } from "../orchestrator/connectors/connector.js";
|
|
12
13
|
export declare class DockerProcessProxy extends EventEmitter implements AgentProcess {
|
|
13
14
|
private readonly sessionId;
|
|
@@ -20,7 +21,7 @@ export declare class DockerProcessProxy extends EventEmitter implements AgentPro
|
|
|
20
21
|
killed: boolean;
|
|
21
22
|
/** Set to true when exit was caused by an authentication error */
|
|
22
23
|
isAuthError: boolean;
|
|
23
|
-
constructor(sessionId: string, dockerExecProcess: ChildProcess, killFn: (signal?: string) => Promise<void
|
|
24
|
+
constructor(sessionId: string, dockerExecProcess: ChildProcess, killFn: (signal?: string) => Promise<void>, authPatterns?: AgentAuthErrorPatterns, authSetupHint?: string);
|
|
24
25
|
/**
|
|
25
26
|
* Kill the agent process via the injected kill function.
|
|
26
27
|
* Returns true if the kill signal was sent, false if the process was already dead.
|
|
@@ -8,6 +8,15 @@
|
|
|
8
8
|
import { EventEmitter } from "node:events";
|
|
9
9
|
import { PassThrough } from "node:stream";
|
|
10
10
|
import { vmLog } from "./log.js";
|
|
11
|
+
/** Fallback when no profile is supplied (historical Claude Code behavior). */
|
|
12
|
+
const DEFAULT_AUTH_PATTERNS = {
|
|
13
|
+
stderr: [
|
|
14
|
+
"Not logged in",
|
|
15
|
+
"Invalid or expired token",
|
|
16
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
17
|
+
],
|
|
18
|
+
combined: ['"authentication_error"', "Failed to authenticate"],
|
|
19
|
+
};
|
|
11
20
|
export class DockerProcessProxy extends EventEmitter {
|
|
12
21
|
sessionId;
|
|
13
22
|
dockerExecProcess;
|
|
@@ -19,7 +28,7 @@ export class DockerProcessProxy extends EventEmitter {
|
|
|
19
28
|
killed = false;
|
|
20
29
|
/** Set to true when exit was caused by an authentication error */
|
|
21
30
|
isAuthError = false;
|
|
22
|
-
constructor(sessionId, dockerExecProcess, killFn) {
|
|
31
|
+
constructor(sessionId, dockerExecProcess, killFn, authPatterns = DEFAULT_AUTH_PATTERNS, authSetupHint = "forge auth setup-claude") {
|
|
23
32
|
super();
|
|
24
33
|
this.sessionId = sessionId;
|
|
25
34
|
this.dockerExecProcess = dockerExecProcess;
|
|
@@ -62,18 +71,16 @@ export class DockerProcessProxy extends EventEmitter {
|
|
|
62
71
|
if (combined) {
|
|
63
72
|
// Always log the actual error first for debugging
|
|
64
73
|
vmLog.error("agent-stderr", `Session ${sessionId} exit=${this.exitCode}: ${combined.slice(0, 500)}`, this.sessionId);
|
|
65
|
-
// Flag as auth error on specific patterns:
|
|
66
|
-
// - stderr: explicit auth failures from
|
|
67
|
-
// - combined:
|
|
74
|
+
// Flag as auth error on the engine's specific patterns:
|
|
75
|
+
// - stderr: explicit auth failures from the agent CLI itself
|
|
76
|
+
// - combined: authentication errors from the provider API (arrive
|
|
77
|
+
// via the stdout JSON stream)
|
|
68
78
|
const stderrOnly = stderrBuffer.trim();
|
|
69
|
-
const isAuthError = stderrOnly.includes(
|
|
70
|
-
|
|
71
|
-
stderrOnly.includes("CLAUDE_CODE_OAUTH_TOKEN") ||
|
|
72
|
-
combined.includes('"authentication_error"') ||
|
|
73
|
-
combined.includes("Failed to authenticate");
|
|
79
|
+
const isAuthError = authPatterns.stderr.some((p) => stderrOnly.includes(p)) ||
|
|
80
|
+
authPatterns.combined.some((p) => combined.includes(p));
|
|
74
81
|
if (isAuthError) {
|
|
75
82
|
this.isAuthError = true;
|
|
76
|
-
vmLog.error("agent-auth",
|
|
83
|
+
vmLog.error("agent-auth", `Agent credentials expired or invalid. Run: ${authSetupHint}`, this.sessionId);
|
|
77
84
|
}
|
|
78
85
|
}
|
|
79
86
|
else {
|
package/dist/vm/image-manager.js
CHANGED
|
@@ -5,7 +5,7 @@ import { promisify } from "node:util";
|
|
|
5
5
|
import chalk from "chalk";
|
|
6
6
|
const execFileAsync = promisify(execFile);
|
|
7
7
|
/** Docker image name for ForgeAI agent containers */
|
|
8
|
-
export const FORGEAI_IMAGE = process.env.FORGEAI_DOCKER_IMAGE ?? "
|
|
8
|
+
export const FORGEAI_IMAGE = process.env.FORGEAI_DOCKER_IMAGE ?? "getforgeai/agent:latest";
|
|
9
9
|
/** Path to the Dockerfile for building the agent image */
|
|
10
10
|
function getDockerfilePath() {
|
|
11
11
|
// From dist/vm/ or src/vm/, go up to cli root, then rootfs/
|
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
export type SecureStoreLocation = {
|
|
11
|
+
/** macOS Keychain service name (e.g. "ForgeAI-codex-auth"). */
|
|
12
|
+
keychainService: string;
|
|
13
|
+
/** macOS Keychain account name. */
|
|
14
|
+
keychainAccount: string;
|
|
15
|
+
/** Encrypted-file fallback name under ~/.forgeai/auth/ (Linux/Windows). */
|
|
16
|
+
fileName: string;
|
|
17
|
+
/** Per-store scrypt salt for the file encryption key. */
|
|
18
|
+
fileSalt: string;
|
|
19
|
+
};
|
|
20
|
+
/** Store a secret. Keychain on macOS, encrypted file elsewhere. */
|
|
21
|
+
export declare function saveSecret(location: SecureStoreLocation, value: string): Promise<void>;
|
|
22
|
+
/** Load a secret. Returns null if not found or unreadable. */
|
|
23
|
+
export declare function loadSecret(location: SecureStoreLocation): Promise<string | null>;
|
|
24
|
+
//# sourceMappingURL=secure-store.d.ts.map
|