aegis-desktop 1.0.7 → 1.0.8
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/lib/account-key.js +5 -0
- package/lib/local/claude-pro.js +755 -0
- package/lib/local/engine.js +329 -12
- package/package.json +1 -1
- package/vendor/aegis.js +116 -3
- package/vendor/auto-provision.js +42 -3
- package/vendor/credentials.js +8 -2
- package/vendor/env-file.js +262 -8
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Niklas Borneklint — AEGIS Code (aegiscode) / Aegiscloud.
|
|
3
|
+
// AEGIS Code (aegiscode) — source: https://github.com/aegisinfo/aegiscode-plugin
|
|
4
|
+
// Conveying a modified copy, or running one as a network service, owes the
|
|
5
|
+
// corresponding source under this licence (§5/§13) — see LICENSING.md.
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* claude-pro.js — the Claude Code Pro class: the user's own Claude
|
|
10
|
+
* subscription, driven through the `claude` CLI.
|
|
11
|
+
*
|
|
12
|
+
* ── why this is a CLASS and not a provider row ──────────────────────────────
|
|
13
|
+
*
|
|
14
|
+
* The other three classes reach a model over an HTTP API: `aegis` and `byok`
|
|
15
|
+
* post to the AEGIS relay, `local` posts to a daemon on loopback. This one
|
|
16
|
+
* reaches no API at all. A Pro/Max subscription is not an API key and is
|
|
17
|
+
* deliberately not usable as one — Claude Code's OAuth session is bound to the
|
|
18
|
+
* `claude` binary, and the only supported way for another program to spend that
|
|
19
|
+
* subscription is to spawn that binary. `~/aegiscodex-dev` proved the shape
|
|
20
|
+
* (src/backend.js runClaude): `claude -p <prompt> --output-format stream-json
|
|
21
|
+
* --include-partial-messages --verbose`, reading the stream off stdout.
|
|
22
|
+
*
|
|
23
|
+
* So the class has no baseURL, no key row, and no relay. It has an ARGV.
|
|
24
|
+
*
|
|
25
|
+
* ── the two rules this file exists to keep ──────────────────────────────────
|
|
26
|
+
*
|
|
27
|
+
* 1. **The argv is DERIVED, never hand-typed.** `one/src/child.js` already owns
|
|
28
|
+
* that decision (AEGISONE decision D8 rule 4) and states why: a spawn site
|
|
29
|
+
* that writes its own flags is a second policy, and the child's permission
|
|
30
|
+
* system is the one nobody on this side can answer. The only argv builder is
|
|
31
|
+
* `claudeArgv(descriptor)`, fed a posture record from
|
|
32
|
+
* `one/src/permissions.js resolvePosture()`. This module therefore does NOT
|
|
33
|
+
* build flags; it asks for a posture record and hands over a descriptor.
|
|
34
|
+
*
|
|
35
|
+
* 2. **Auth is the CLI's, not ours.** ~/.claude/.credentials.json is read to
|
|
36
|
+
* ANSWER "is there a subscription here", and never written. That is the same
|
|
37
|
+
* read-only contract the rest of the repo states (client/credentials.js's
|
|
38
|
+
* "another product's file"), and it is why `claudeProStatus()` is an
|
|
39
|
+
* inspector rather than a login.
|
|
40
|
+
*
|
|
41
|
+
* Resolution of both sibling modules is by EXISTENCE in the two layouts the
|
|
42
|
+
* repo already uses (a checkout, and the staged vendor tree), and a missing
|
|
43
|
+
* child.js is a thrown error rather than a local re-implementation — a second
|
|
44
|
+
* argv builder is exactly the drift rule 4 forbids.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
const fs = require('node:fs');
|
|
48
|
+
const os = require('node:os');
|
|
49
|
+
const path = require('node:path');
|
|
50
|
+
|
|
51
|
+
// client/env-file.js, resolved the same two ways engine.js already resolves
|
|
52
|
+
// it (a checkout, and a packaged desktop build's flat vendor dir) — the CLI's
|
|
53
|
+
// mirrored tree preserves this file's own relative position, so the same
|
|
54
|
+
// checkout path also resolves there.
|
|
55
|
+
let envFileLib = null;
|
|
56
|
+
try {
|
|
57
|
+
envFileLib = require('../../../client/env-file.js');
|
|
58
|
+
} catch {
|
|
59
|
+
try {
|
|
60
|
+
envFileLib = require('../../vendor/env-file.js');
|
|
61
|
+
} catch {
|
|
62
|
+
envFileLib = null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Claude Code's own config dir; `$CLAUDE_CONFIG_DIR` wins, as the CLI's does. */
|
|
67
|
+
function claudeConfigDir(env) {
|
|
68
|
+
const e = env || process.env;
|
|
69
|
+
const override = e && e.CLAUDE_CONFIG_DIR;
|
|
70
|
+
if (override && String(override).trim()) return String(override).trim();
|
|
71
|
+
return path.join(os.homedir(), '.claude');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The user's Claude Code OAuth token, read the way the `claude` binary itself
|
|
76
|
+
* wants it handed to it: into the CHILD's environment only, never exported
|
|
77
|
+
* into this process's own `process.env`.
|
|
78
|
+
*
|
|
79
|
+
* `CLAUDE_CODE_OAUTH_TOKEN` is on client/env-file.js's FOREIGN_SECRET_NAMES —
|
|
80
|
+
* a credential for a different CLI, deliberately left OUT of the ambient
|
|
81
|
+
* `~/.aegiscode/.env` load every host does at start-up, because until this
|
|
82
|
+
* class existed no host in this repo read it. This class is now that one
|
|
83
|
+
* reader, and it reads it the way the split was designed for — explicitly,
|
|
84
|
+
* for the one process that needs it, never ambient to every other process on
|
|
85
|
+
* the machine.
|
|
86
|
+
*
|
|
87
|
+
* Checked in the order that keeps a real shell export authoritative:
|
|
88
|
+
* 1. the caller's own env bag (an explicit `CLAUDE_CODE_OAUTH_TOKEN=…`
|
|
89
|
+
* export always wins — the same rule `loadEnvFile`'s rule 1 states).
|
|
90
|
+
* 2. `~/.aegiscode/secrets.env` — the file the split moves this name to.
|
|
91
|
+
* 3. `~/.aegiscode/.env`, parsed directly and never applied to any env
|
|
92
|
+
* object — covers a token that has not been migrated to secrets.env yet
|
|
93
|
+
* (`tools/split-env-secrets.mjs` is the migration), without this class
|
|
94
|
+
* having to make the value ambient to get there.
|
|
95
|
+
*
|
|
96
|
+
* @param {object} [env] defaults to process.env
|
|
97
|
+
* @returns {string} '' when no source has it
|
|
98
|
+
*/
|
|
99
|
+
function claudeOAuthToken(env) {
|
|
100
|
+
// client/env-file.js owns the deny list AND the secrets-file resolution, so
|
|
101
|
+
// it is the one place that answers "does a foreign secret exist anywhere" —
|
|
102
|
+
// the CLI's model picker (cli/src/models.js) reads the SAME function to
|
|
103
|
+
// decide whether `/model` still needs to collect this token, and a second
|
|
104
|
+
// implementation here is exactly the drift that would let the two disagree.
|
|
105
|
+
if (!envFileLib) return '';
|
|
106
|
+
return envFileLib.resolveForeignSecret('CLAUDE_CODE_OAUTH_TOKEN', { env });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The env names that SHADOW the subscription when the `claude` CLI starts.
|
|
111
|
+
*
|
|
112
|
+
* Claude Code authenticates with `ANTHROPIC_API_KEY` (or `ANTHROPIC_AUTH_TOKEN`)
|
|
113
|
+
* when either is set, and that choice wins over the `claudeAiOauth` entry the
|
|
114
|
+
* subscription lives in. So a value that is not a credential at all does not
|
|
115
|
+
* merely fail on its own — it takes the plan down with it.
|
|
116
|
+
*/
|
|
117
|
+
const SHADOWING_ENV_VARS = Object.freeze(['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN']);
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The env for one `claude` child, with a shadowing value that cannot be a
|
|
121
|
+
* credential removed.
|
|
122
|
+
*
|
|
123
|
+
* ── why this exists (measured, 2026-10-14) ──────────────────────────────────
|
|
124
|
+
*
|
|
125
|
+
* `claude auth status` in the shell that launched this host:
|
|
126
|
+
*
|
|
127
|
+
* { "loggedIn": true, "authMethod": "claude.ai", "apiProvider": "firstParty",
|
|
128
|
+
* "apiKeySource": "ANTHROPIC_API_KEY", "email": null, "orgId": null,
|
|
129
|
+
* "subscriptionType": null } ← the two-character state
|
|
130
|
+
*
|
|
131
|
+
* and the identical command with the one variable unset (`env -u
|
|
132
|
+
* ANTHROPIC_API_KEY claude auth status`):
|
|
133
|
+
*
|
|
134
|
+
* { … "email": "…", "orgId": "9d5e56e1-…", "orgName": "…",
|
|
135
|
+
* "subscriptionType": "pro" } ← the plan this user pays for
|
|
136
|
+
*
|
|
137
|
+
* The session was signed in the whole time; `~/.claude/.credentials.json` holds
|
|
138
|
+
* a valid `claudeAiOauth` entry naming `pro` (verified read-only). What was
|
|
139
|
+
* missing was only that the shell exported `ANTHROPIC_API_KEY=he` — two
|
|
140
|
+
* characters, the residue of a paste that lost its payload — so every `claude`
|
|
141
|
+
* child this host spawned authenticated as "API key: he" instead of as the
|
|
142
|
+
* subscription, and Claude Pro was unavailable in exactly the state a user is
|
|
143
|
+
* most likely to be in: the shell where they once pasted a key.
|
|
144
|
+
*
|
|
145
|
+
* ── what it does NOT do ─────────────────────────────────────────────────────
|
|
146
|
+
*
|
|
147
|
+
* It does not strip a real key. The floor is `client/env-file.js`'s
|
|
148
|
+
* `credentialLooksUsable` — the one place this repo decides "too short to be a
|
|
149
|
+
* credential" — so a genuine `ANTHROPIC_API_KEY` keeps its precedence, because
|
|
150
|
+
* a user who set one deliberately is asking for the pay-per-token path. Only a
|
|
151
|
+
* value that provably cannot be a key is dropped, and the drop is REPORTED
|
|
152
|
+
* rather than silent (see `envDroppedLine`).
|
|
153
|
+
*
|
|
154
|
+
* @param {object} [baseEnv] the caller's env bag
|
|
155
|
+
* @param {string} [token] a `CLAUDE_CODE_OAUTH_TOKEN` to hand the child
|
|
156
|
+
* @returns {{env: object, dropped: Array<{name: string, length: number}>}}
|
|
157
|
+
*/
|
|
158
|
+
function childEnvPlan(baseEnv, token) {
|
|
159
|
+
const env = { ...(baseEnv || {}) };
|
|
160
|
+
const dropped = [];
|
|
161
|
+
const usable = envFileLib && typeof envFileLib.credentialLooksUsable === 'function'
|
|
162
|
+
? envFileLib.credentialLooksUsable
|
|
163
|
+
: null;
|
|
164
|
+
// A host whose env module predates the floor keeps today's behaviour (the
|
|
165
|
+
// variable passes through): a second copy of the floor here is the drift
|
|
166
|
+
// client/env-file.js exists to prevent.
|
|
167
|
+
if (usable) {
|
|
168
|
+
for (const name of SHADOWING_ENV_VARS) {
|
|
169
|
+
const value = env[name];
|
|
170
|
+
if (typeof value !== 'string' || usable(value)) continue;
|
|
171
|
+
delete env[name];
|
|
172
|
+
// A variable set to '' shadows nothing worth reporting — it is removed
|
|
173
|
+
// (harmless) but the user is not told about a value they never gave.
|
|
174
|
+
const length = value.trim().length;
|
|
175
|
+
if (length > 0) dropped.push({ name, length });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (token) env.CLAUDE_CODE_OAUTH_TOKEN = token;
|
|
179
|
+
return { env, dropped };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The one-line report for a child that started without a shadowing value. */
|
|
183
|
+
function envDroppedLine(dropped) {
|
|
184
|
+
const list = Array.isArray(dropped) ? dropped : [];
|
|
185
|
+
if (!list.length) return null;
|
|
186
|
+
const names = list.map((d) => `${d.name} (${d.length} character${d.length === 1 ? '' : 's'})`).join(', ');
|
|
187
|
+
return `aegis: not passed to the claude child — ${names}: too short to be a credential, and the CLI would have spent it instead of your subscription`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The shadowing value in `env` that cannot be a credential, if any (see above). */
|
|
191
|
+
function shadowingValue(env) {
|
|
192
|
+
const bag = env || {};
|
|
193
|
+
const usable = envFileLib && typeof envFileLib.credentialLooksUsable === 'function'
|
|
194
|
+
? envFileLib.credentialLooksUsable
|
|
195
|
+
: null;
|
|
196
|
+
if (!usable) return null;
|
|
197
|
+
for (const name of SHADOWING_ENV_VARS) {
|
|
198
|
+
const value = bag[name];
|
|
199
|
+
if (typeof value === 'string' && !usable(value)) return { name, length: value.trim().length };
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Read Claude Code's credential file, without ever writing it.
|
|
206
|
+
*
|
|
207
|
+
* `kind` is 'oauth' (a `claudeAiOauth` entry — the Pro/Max subscription),
|
|
208
|
+
* 'apikey', or 'env-token' (see below). `subscription` is the plan the file
|
|
209
|
+
* itself names ('pro', 'max'), so the picker can say which subscription is
|
|
210
|
+
* being spent instead of guessing from the token's shape.
|
|
211
|
+
*
|
|
212
|
+
* `loggedIn` is deliberately NOT `!expired`: access tokens are short-lived and
|
|
213
|
+
* refreshed by the CLI on its next run, so a file holding a refresh token is a
|
|
214
|
+
* working subscription even when the access token has lapsed. Only a file with
|
|
215
|
+
* NO token at all is "not signed in" — anything stricter would tell a signed-in
|
|
216
|
+
* user to sign in again.
|
|
217
|
+
*
|
|
218
|
+
* A file that genuinely has no usable token (never logged in, or a refresh
|
|
219
|
+
* token that outright expired — the CLI's own `claude auth status` agrees in
|
|
220
|
+
* that case, there is no stricter-than-the-CLI check being applied here) falls
|
|
221
|
+
* back to `claudeOAuthToken()`: a `CLAUDE_CODE_OAUTH_TOKEN` the user has set
|
|
222
|
+
* aside (shell export, `secrets.env`, or an unmigrated `.env` line) is handed
|
|
223
|
+
* to the `claude` child directly by `runClaudePro`, and the CLI authenticates
|
|
224
|
+
* with it regardless of what is on disk. Reporting "not logged in" while that
|
|
225
|
+
* token would actually work is the gap this closes — the file is read-only
|
|
226
|
+
* state, the token is a second, equally real way to be signed in.
|
|
227
|
+
*/
|
|
228
|
+
function claudeProStatus(opts) {
|
|
229
|
+
const o = opts || {};
|
|
230
|
+
const env = o.env || process.env;
|
|
231
|
+
const file = path.join(o.dir || claudeConfigDir(env), '.credentials.json');
|
|
232
|
+
const out = {
|
|
233
|
+
claude: o.claudeVersion || null,
|
|
234
|
+
file,
|
|
235
|
+
exists: false,
|
|
236
|
+
kind: null,
|
|
237
|
+
subscription: null,
|
|
238
|
+
expiresAt: 0,
|
|
239
|
+
loggedIn: false,
|
|
240
|
+
};
|
|
241
|
+
let data;
|
|
242
|
+
try {
|
|
243
|
+
data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
244
|
+
out.exists = true;
|
|
245
|
+
} catch {
|
|
246
|
+
data = null;
|
|
247
|
+
}
|
|
248
|
+
const oauth = data && data.claudeAiOauth;
|
|
249
|
+
if (oauth && (oauth.accessToken || oauth.refreshToken)) {
|
|
250
|
+
out.kind = 'oauth';
|
|
251
|
+
out.subscription = oauth.subscriptionType || null;
|
|
252
|
+
out.expiresAt = Number(oauth.refreshTokenExpiresAt) || 0;
|
|
253
|
+
out.loggedIn = true;
|
|
254
|
+
} else if (data && typeof data.token === 'string' && data.token) {
|
|
255
|
+
out.kind = 'apikey';
|
|
256
|
+
out.loggedIn = true;
|
|
257
|
+
} else if (claudeOAuthToken(env)) {
|
|
258
|
+
out.kind = 'env-token';
|
|
259
|
+
out.loggedIn = true;
|
|
260
|
+
}
|
|
261
|
+
// A shadowing env var that cannot be a credential does NOT change `loggedIn`
|
|
262
|
+
// — the subscription is there — but it is the one state where "signed in" and
|
|
263
|
+
// "actually spends the plan" differ, because the CLI prefers that name. The
|
|
264
|
+
// child is fixed regardless (childEnvPlan); this reports it so a status
|
|
265
|
+
// surface can say why, rather than leaving `apiKeySource: ANTHROPIC_API_KEY`
|
|
266
|
+
// to be discovered from `claude auth status` by hand.
|
|
267
|
+
out.shadowed = shadowingValue(env);
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The model aliases the `claude` CLI itself accepts (`claude --model <alias>`). */
|
|
272
|
+
const PRO_MODELS = Object.freeze([
|
|
273
|
+
{ id: 'default', label: 'Default (recommended)', note: 'the CLI picks for the subscription' },
|
|
274
|
+
{ id: 'sonnet', label: 'Sonnet', note: 'alias the CLI resolves' },
|
|
275
|
+
{ id: 'opus', label: 'Opus', note: 'alias the CLI resolves' },
|
|
276
|
+
{ id: 'haiku', label: 'Haiku', note: 'alias the CLI resolves' },
|
|
277
|
+
]);
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The extra argv this class owns (what the child is told about the turn),
|
|
281
|
+
* separate from the argv `one/src/child.js` owns (what the child is allowed to
|
|
282
|
+
* do). Keeping the two apart is the point of rule 4: the permission half is
|
|
283
|
+
* derived from the posture record, and this half is derived from the payload.
|
|
284
|
+
*
|
|
285
|
+
* NOTE: the session flags are NOT here even though they are "about the turn".
|
|
286
|
+
* `--resume`/`--session-id` decide whether this turn is the same conversation
|
|
287
|
+
* as the previous one — that is a policy about continuity, and it lives with
|
|
288
|
+
* the other flags in the one argv builder, so a second spawn site cannot grow
|
|
289
|
+
* its own opinion about it. `runClaudePro` passes `resumeSessionId`/
|
|
290
|
+
* `sessionId` through the descriptor instead.
|
|
291
|
+
*/
|
|
292
|
+
function turnArgs(payload) {
|
|
293
|
+
const args = [];
|
|
294
|
+
const p = payload || {};
|
|
295
|
+
if (p.system && String(p.system).trim()) {
|
|
296
|
+
args.push('--append-system-prompt', String(p.system));
|
|
297
|
+
}
|
|
298
|
+
return args;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// The stream-json reader
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Line-oriented parser for `claude --output-format stream-json`. Pure: it feeds
|
|
307
|
+
* callbacks and keeps only the tool bookkeeping between lines, so a test can
|
|
308
|
+
* replay a captured transcript without spawning anything.
|
|
309
|
+
*
|
|
310
|
+
* Ported from ~/aegiscodex-dev src/backend.js createStreamParser(), which is
|
|
311
|
+
* the reference implementation on this machine and carries the field names the
|
|
312
|
+
* protocol actually uses. Two details there are load-bearing and are kept
|
|
313
|
+
* verbatim:
|
|
314
|
+
*
|
|
315
|
+
* - `input_json_delta` fragments arrive routed BY BLOCK INDEX. Parallel
|
|
316
|
+
* same-name tool calls interleave their argument JSON, and matching on
|
|
317
|
+
* "the first collecting block" pairs a fragment with the wrong call.
|
|
318
|
+
* - one `user` line can carry a tool_result for EVERY call in a parallel
|
|
319
|
+
* round, so all of them are resolved, not just the first.
|
|
320
|
+
*/
|
|
321
|
+
function createStreamParser(handlers) {
|
|
322
|
+
const h = handlers || {};
|
|
323
|
+
const tools = new Map(); // id -> {id, index, name, args, startedAt, phase}
|
|
324
|
+
let text = '';
|
|
325
|
+
let resultLine = null;
|
|
326
|
+
|
|
327
|
+
const emitRun = (block) => {
|
|
328
|
+
let args = block.args;
|
|
329
|
+
try {
|
|
330
|
+
args = block.args ? JSON.parse(block.args) : {};
|
|
331
|
+
} catch {
|
|
332
|
+
args = {};
|
|
333
|
+
}
|
|
334
|
+
tools.set(block.id, { ...block, args, phase: 'run' });
|
|
335
|
+
if (h.onTool) h.onTool({ id: block.id, name: block.name, args, phase: 'run' });
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const collecting = (index) =>
|
|
339
|
+
[...tools.values()].find((t) => t.phase === 'collecting' && t.index === index) ||
|
|
340
|
+
[...tools.values()].find((t) => t.phase === 'collecting');
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
line(raw) {
|
|
344
|
+
let j;
|
|
345
|
+
try {
|
|
346
|
+
j = JSON.parse(raw);
|
|
347
|
+
} catch {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (j.type === 'stream_event' && j.event) {
|
|
352
|
+
const e = j.event;
|
|
353
|
+
if (e.type === 'content_block_start' && e.content_block) {
|
|
354
|
+
if (e.content_block.type === 'tool_use') {
|
|
355
|
+
tools.set(e.content_block.id, {
|
|
356
|
+
id: e.content_block.id,
|
|
357
|
+
index: e.index == null ? 0 : e.index,
|
|
358
|
+
name: e.content_block.name,
|
|
359
|
+
args: '',
|
|
360
|
+
startedAt: Date.now(),
|
|
361
|
+
phase: 'collecting',
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
} else if (e.type === 'content_block_delta' && e.delta) {
|
|
365
|
+
if (e.delta.type === 'text_delta') {
|
|
366
|
+
const t = (e.delta.text || '').replace(/\x00/g, '');
|
|
367
|
+
text += t;
|
|
368
|
+
if (h.onText && t) h.onText(t);
|
|
369
|
+
} else if (e.delta.type === 'input_json_delta') {
|
|
370
|
+
const t = collecting(e.index == null ? 0 : e.index);
|
|
371
|
+
if (t) t.args += e.delta.partial_json || '';
|
|
372
|
+
}
|
|
373
|
+
} else if (e.type === 'content_block_stop') {
|
|
374
|
+
const t = collecting(e.index == null ? 0 : e.index);
|
|
375
|
+
if (t) emitRun(t);
|
|
376
|
+
}
|
|
377
|
+
} else if (j.type === 'user' && j.message) {
|
|
378
|
+
const content = Array.isArray(j.message.content) ? j.message.content : [];
|
|
379
|
+
for (const tr of content) {
|
|
380
|
+
if (!tr || tr.type !== 'tool_result' || !tr.tool_use_id) continue;
|
|
381
|
+
const t = tools.get(tr.tool_use_id);
|
|
382
|
+
if (!t) continue;
|
|
383
|
+
const elapsed = Math.max(0, Date.now() - (t.startedAt || Date.now()));
|
|
384
|
+
if (h.onTool) {
|
|
385
|
+
h.onTool({ id: t.id, name: t.name, phase: 'done', elapsed, error: !!tr.is_error });
|
|
386
|
+
}
|
|
387
|
+
tools.delete(tr.tool_use_id);
|
|
388
|
+
}
|
|
389
|
+
} else if (j.type === 'result') {
|
|
390
|
+
resultLine = j;
|
|
391
|
+
if (h.onDone) {
|
|
392
|
+
h.onDone({
|
|
393
|
+
text: text || j.result || '',
|
|
394
|
+
error: j.is_error ? j.result || 'api error' : undefined,
|
|
395
|
+
usage: j.usage || null,
|
|
396
|
+
costUsd: typeof j.total_cost_usd === 'number' ? j.total_cost_usd : undefined,
|
|
397
|
+
durationMs: j.duration_ms,
|
|
398
|
+
sessionId: j.session_id,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// system / assistant / rate_limit_event lines carry no UI data.
|
|
403
|
+
},
|
|
404
|
+
get text() {
|
|
405
|
+
return text;
|
|
406
|
+
},
|
|
407
|
+
get resultLine() {
|
|
408
|
+
return resultLine;
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Resolve `one/src/child.js` and `one/src/permissions.js` in the two layouts the
|
|
415
|
+
* repo uses, or throw naming the file. No fallback implementation: rule 4's
|
|
416
|
+
* whole guarantee is that there is exactly ONE argv builder, so a local copy
|
|
417
|
+
* here would be the drift the decision forbids.
|
|
418
|
+
*/
|
|
419
|
+
function loadSeam(dir) {
|
|
420
|
+
const here = dir || __dirname;
|
|
421
|
+
const candidates = [
|
|
422
|
+
// A checkout: desktop/lib/local → <repo>/one/src
|
|
423
|
+
path.join(here, '..', '..', '..', 'one', 'src'),
|
|
424
|
+
// A packaged desktop app: desktop/lib/local → desktop/vendor/one/src
|
|
425
|
+
path.join(here, '..', '..', 'vendor', 'one', 'src'),
|
|
426
|
+
// The published CLI's mirrored tree: cli/vendor/desktop/lib/local →
|
|
427
|
+
// cli/vendor/one/src (the manifest stages one/src there under 'mirror').
|
|
428
|
+
path.join(here, '..', '..', '..', '..', 'one', 'src'),
|
|
429
|
+
];
|
|
430
|
+
for (const base of candidates) {
|
|
431
|
+
try {
|
|
432
|
+
const child = require(path.join(base, 'child.js'));
|
|
433
|
+
const permissions = require(path.join(base, 'permissions.js'));
|
|
434
|
+
if (typeof child.claudeArgv === 'function' && typeof permissions.resolvePosture === 'function') {
|
|
435
|
+
return { child, permissions };
|
|
436
|
+
}
|
|
437
|
+
} catch {
|
|
438
|
+
/* try the next layout */
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
throw new Error(
|
|
442
|
+
'aegis: the Claude child argv builder (one/src/child.js + one/src/permissions.js) is missing ' +
|
|
443
|
+
'from this install — refusing to build a second argv, because a second argv is a second policy.'
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Whether the argv seam resolves here, without throwing. For status panels. */
|
|
448
|
+
function seamAvailable(dir) {
|
|
449
|
+
try {
|
|
450
|
+
loadSeam(dir);
|
|
451
|
+
return { ok: true, error: null };
|
|
452
|
+
} catch (e) {
|
|
453
|
+
return { ok: false, error: (e && e.message) || String(e) };
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Run one turn on the user's Claude Code subscription.
|
|
459
|
+
*
|
|
460
|
+
* Returns the SAME shape the other three transports return, because the engine
|
|
461
|
+
* loop reads one shape (`choices[0].message.content`, `usage`, `stop_reason`) —
|
|
462
|
+
* `extractToolCalls`, `assistantText`, `finishReasonOf` and `addUsage` all read
|
|
463
|
+
* it. Returns NO tool_calls, and that is the design rather than an omission:
|
|
464
|
+
* the `claude` child runs its own agent loop with its own tools, so the work is
|
|
465
|
+
* already done by the time this resolves. Emitting tool_calls as well would
|
|
466
|
+
* hand a second, parallel loop the same task and run every tool twice.
|
|
467
|
+
*
|
|
468
|
+
* @param {object} opts
|
|
469
|
+
* @param {string} opts.prompt
|
|
470
|
+
* @param {string} [opts.model] a `claude --model` alias (PRO_MODELS)
|
|
471
|
+
* @param {string} [opts.system] appended to the child's own prompt
|
|
472
|
+
* @param {string} [opts.cwd] the directory the child works in
|
|
473
|
+
* @param {string[]} [opts.addDir] extra directories the child may work in.
|
|
474
|
+
* The child resolves every relative path
|
|
475
|
+
* and every tool's root against `cwd`
|
|
476
|
+
* alone, so a turn started in a
|
|
477
|
+
* subdirectory cannot see the tree above
|
|
478
|
+
* it; this is what widens that. Spelled
|
|
479
|
+
* by the ONE argv builder (one/src/child.js
|
|
480
|
+
* addDirArgs), never typed here.
|
|
481
|
+
* @param {object} [opts.rules] CLI permission rules (cli/src/config.js)
|
|
482
|
+
* @param {boolean} [opts.gate] the stored /confirm posture, if known
|
|
483
|
+
* @param {string} [opts.permissionMode] explicit passthrough, validated in child.js
|
|
484
|
+
* @param {string} [opts.resumeSessionId] reattach to the session a prior turn
|
|
485
|
+
* reported → the turn keeps its context
|
|
486
|
+
* @param {string} [opts.sessionId] name a NEW session (UUID) for a first turn
|
|
487
|
+
* @param {AbortSignal} [opts.signal]
|
|
488
|
+
* @param {Function} [opts.onSpawn] receives the child the moment it exists
|
|
489
|
+
* @param {number} [opts.idleTimeoutMs] silence budget (default 10 min)
|
|
490
|
+
* @returns {Promise<object>} an engine-shaped completion
|
|
491
|
+
*/
|
|
492
|
+
function runClaudePro(opts, deps) {
|
|
493
|
+
const o = opts || {};
|
|
494
|
+
const d = deps || {};
|
|
495
|
+
const seam = d.seam || loadSeam(d.dir);
|
|
496
|
+
const spawnFn = d.spawn || require('node:child_process').spawn;
|
|
497
|
+
|
|
498
|
+
// One posture per turn, from the single resolver — never re-derived here.
|
|
499
|
+
const posture = seam.permissions.resolvePosture({
|
|
500
|
+
rules: o.rules,
|
|
501
|
+
gate: o.gate,
|
|
502
|
+
interactive: false,
|
|
503
|
+
childKind: seam.permissions.CHILD_KINDS.HEADLESS,
|
|
504
|
+
env: o.env || process.env,
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
const built = seam.child.claudeArgv({
|
|
508
|
+
posture,
|
|
509
|
+
prompt: o.prompt,
|
|
510
|
+
model: o.model || undefined,
|
|
511
|
+
permissionMode: o.permissionMode,
|
|
512
|
+
// Continuity, handed to the ONE argv builder rather than typed as `extra`.
|
|
513
|
+
// Without these the child starts a brand-new conversation every turn: the
|
|
514
|
+
// engine does not replay the transcript into it (`pro` runs its own agent
|
|
515
|
+
// loop), so the CLI's own session store is the ONLY thing that can carry
|
|
516
|
+
// context across turns. `resumeSessionId` reattaches to the session a
|
|
517
|
+
// previous turn reported; `sessionId` names a new one for a later resume.
|
|
518
|
+
resumeSessionId: o.resumeSessionId,
|
|
519
|
+
sessionId: o.sessionId,
|
|
520
|
+
// The widened sandbox for this turn, if the caller asked for one. It rides
|
|
521
|
+
// the descriptor rather than `extra` because `--add-dir` is variadic and its
|
|
522
|
+
// correct spelling is a decision (child.js addDirArgs), not a flag a caller
|
|
523
|
+
// may type.
|
|
524
|
+
addDir: o.addDir,
|
|
525
|
+
// `--include-partial-messages` is what makes `stream_event` lines arrive at
|
|
526
|
+
// all, and `--verbose` is required by the CLI alongside stream-json in print
|
|
527
|
+
// mode. Both belong to the transport (this file), not to the posture.
|
|
528
|
+
extra: [...turnArgs(o), '--include-partial-messages', '--verbose'],
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
return new Promise((resolve) => {
|
|
532
|
+
if (o.signal && o.signal.aborted) {
|
|
533
|
+
resolve({ choices: [{ message: { content: '' }, finish_reason: 'stop' }], stopped: true });
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// `CLAUDE_CODE_OAUTH_TOKEN` is merged into the CHILD's env only — never
|
|
538
|
+
// written back to `o.env`/`process.env` — so a token the on-disk
|
|
539
|
+
// credential file lacks (or has let expire) still reaches the one process
|
|
540
|
+
// that can spend it, without making the value ambient to this host or any
|
|
541
|
+
// other child it spawns. See claudeOAuthToken() for where it comes from.
|
|
542
|
+
//
|
|
543
|
+
// `childEnvPlan` additionally refuses to hand over a shadowing var that
|
|
544
|
+
// cannot be a credential. Without it a signed-in machine runs the child as
|
|
545
|
+
// "API key: he" and the subscription is unreachable — see childEnvPlan.
|
|
546
|
+
const baseEnv = o.env || process.env;
|
|
547
|
+
const token = claudeOAuthToken(baseEnv);
|
|
548
|
+
const plan = childEnvPlan(baseEnv, token);
|
|
549
|
+
const childEnv = plan.env;
|
|
550
|
+
const droppedLine = envDroppedLine(plan.dropped);
|
|
551
|
+
if (droppedLine && o.onDelta) {
|
|
552
|
+
// Same channel the transcript already paints notes on, and emitted BEFORE
|
|
553
|
+
// the spawn so the line lands above the child's own tool rows.
|
|
554
|
+
try {
|
|
555
|
+
o.onDelta({ delta: '', note: droppedLine });
|
|
556
|
+
} catch {
|
|
557
|
+
/* a throwing renderer must not take the turn down */
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
let child;
|
|
562
|
+
try {
|
|
563
|
+
child = spawnFn(d.executable || 'claude', built.argv, {
|
|
564
|
+
cwd: o.cwd || process.cwd(),
|
|
565
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
566
|
+
env: childEnv,
|
|
567
|
+
});
|
|
568
|
+
} catch (e) {
|
|
569
|
+
resolve({
|
|
570
|
+
choices: [{ message: { content: '' }, finish_reason: 'error' }],
|
|
571
|
+
error: `claude: could not start the child (${(e && e.message) || e})`,
|
|
572
|
+
...(plan.dropped.length ? { envDropped: plan.dropped } : {}),
|
|
573
|
+
});
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (o.onSpawn) {
|
|
578
|
+
try {
|
|
579
|
+
o.onSpawn(child, built.record);
|
|
580
|
+
} catch {
|
|
581
|
+
/* a throwing hook must not take the turn down */
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
let finished = false;
|
|
586
|
+
let stderr = '';
|
|
587
|
+
let buffer = '';
|
|
588
|
+
let idle = null;
|
|
589
|
+
const idleMs = Number(o.idleTimeoutMs) || 10 * 60 * 1000;
|
|
590
|
+
|
|
591
|
+
const settle = (result) => {
|
|
592
|
+
if (finished) return;
|
|
593
|
+
finished = true;
|
|
594
|
+
if (idle) clearTimeout(idle);
|
|
595
|
+
if (o.signal) o.signal.removeEventListener('abort', kill);
|
|
596
|
+
// Recorded on every settle path, the same way `childRecord` is: what the
|
|
597
|
+
// child was NOT given is part of what this turn did, and a caller logging
|
|
598
|
+
// only the argv would show a turn that "should" have failed as a mystery.
|
|
599
|
+
resolve(plan.dropped.length ? { ...result, envDropped: plan.dropped } : result);
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
const kill = () => {
|
|
603
|
+
try {
|
|
604
|
+
child.kill('SIGTERM');
|
|
605
|
+
} catch {
|
|
606
|
+
/* already gone */
|
|
607
|
+
}
|
|
608
|
+
setTimeout(() => {
|
|
609
|
+
try {
|
|
610
|
+
child.kill('SIGKILL');
|
|
611
|
+
} catch {
|
|
612
|
+
/* already gone */
|
|
613
|
+
}
|
|
614
|
+
}, 1500);
|
|
615
|
+
};
|
|
616
|
+
if (o.signal) o.signal.addEventListener('abort', kill, { once: true });
|
|
617
|
+
|
|
618
|
+
// Idle watchdog, not a flat turn cap: it resets on every byte, so it fires
|
|
619
|
+
// only on true silence — which is what a lapsed OAuth session looks like
|
|
620
|
+
// when the child opens a re-auth prompt nobody on this side can answer.
|
|
621
|
+
const armIdle = () => {
|
|
622
|
+
if (idle) clearTimeout(idle);
|
|
623
|
+
idle = setTimeout(() => {
|
|
624
|
+
kill();
|
|
625
|
+
settle({
|
|
626
|
+
choices: [{ message: { content: '' }, finish_reason: 'stop' }],
|
|
627
|
+
error: `claude: the child went silent for ${Math.round(idleMs / 1000)}s and was stopped`,
|
|
628
|
+
});
|
|
629
|
+
}, idleMs);
|
|
630
|
+
};
|
|
631
|
+
armIdle();
|
|
632
|
+
|
|
633
|
+
const parser = createStreamParser({
|
|
634
|
+
onText: (t) => {
|
|
635
|
+
armIdle();
|
|
636
|
+
if (o.onDelta) o.onDelta({ delta: t });
|
|
637
|
+
},
|
|
638
|
+
onTool: (info) => {
|
|
639
|
+
armIdle();
|
|
640
|
+
if (!o.onDelta) return;
|
|
641
|
+
// Same tool frame the engine's own loop emits, so the renderers paint a
|
|
642
|
+
// subscription turn's tool rows with no second code path.
|
|
643
|
+
o.onDelta({
|
|
644
|
+
delta: '',
|
|
645
|
+
tool: {
|
|
646
|
+
name: info.name,
|
|
647
|
+
args: info.args,
|
|
648
|
+
ok: info.phase === 'done' ? !info.error : undefined,
|
|
649
|
+
id: info.id,
|
|
650
|
+
cwd: o.cwd,
|
|
651
|
+
phase: info.phase,
|
|
652
|
+
},
|
|
653
|
+
});
|
|
654
|
+
},
|
|
655
|
+
onDone: (r) => {
|
|
656
|
+
if (r.error) {
|
|
657
|
+
settle({
|
|
658
|
+
choices: [{ message: { content: r.text || '' }, finish_reason: 'error' }],
|
|
659
|
+
usage: r.usage || undefined,
|
|
660
|
+
error: r.error,
|
|
661
|
+
});
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
settle({
|
|
665
|
+
choices: [{ message: { content: r.text || '' }, finish_reason: 'stop' }],
|
|
666
|
+
usage: r.usage || undefined,
|
|
667
|
+
costUsd: r.costUsd,
|
|
668
|
+
durationMs: r.durationMs,
|
|
669
|
+
sessionId: r.sessionId,
|
|
670
|
+
// Carried so a caller can log WHICH flags the child actually got —
|
|
671
|
+
// rule 4's "recorded, never inferred".
|
|
672
|
+
childRecord: built.record,
|
|
673
|
+
});
|
|
674
|
+
},
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
child.stdout.on('data', (chunk) => {
|
|
678
|
+
armIdle();
|
|
679
|
+
buffer += chunk.toString('utf8');
|
|
680
|
+
const lines = buffer.split('\n');
|
|
681
|
+
buffer = lines.pop() || '';
|
|
682
|
+
for (const line of lines) {
|
|
683
|
+
const t = line.trim();
|
|
684
|
+
if (t) parser.line(t);
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
child.stderr.on('data', (chunk) => {
|
|
688
|
+
armIdle();
|
|
689
|
+
if (stderr.length < 8192) stderr += chunk.toString('utf8');
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
child.on('error', (e) => {
|
|
693
|
+
settle({
|
|
694
|
+
choices: [{ message: { content: '' }, finish_reason: 'error' }],
|
|
695
|
+
error:
|
|
696
|
+
`claude: could not run the child (${(e && e.message) || e}). ` +
|
|
697
|
+
'Install it with `npm i -g @anthropic-ai/claude-code`, then sign in with `claude auth login`.',
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
child.on('close', (code) => {
|
|
702
|
+
if (buffer.trim()) parser.line(buffer.trim());
|
|
703
|
+
// Only reached when no `result` line arrived — a clean exit always emits
|
|
704
|
+
// one. Anything else is a real failure and is reported as one rather than
|
|
705
|
+
// as an empty successful answer.
|
|
706
|
+
settle({
|
|
707
|
+
choices: [{ message: { content: parser.text }, finish_reason: 'stop' }],
|
|
708
|
+
usage: (parser.resultLine && parser.resultLine.usage) || undefined,
|
|
709
|
+
childRecord: built.record,
|
|
710
|
+
error:
|
|
711
|
+
parser.text || code === 0
|
|
712
|
+
? undefined
|
|
713
|
+
: `claude: the child exited with status ${code}${
|
|
714
|
+
stderr.trim() ? ` — ${stderr.trim().split('\n').slice(-3).join(' ')}` : ''
|
|
715
|
+
}`,
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* The run-log line for a child argv, from the seam that owns the wording. A
|
|
723
|
+
* missing seam degrades to a short local summary rather than throwing — this is
|
|
724
|
+
* a reporting path, and a turn that already ran must not fail to describe
|
|
725
|
+
* itself.
|
|
726
|
+
*/
|
|
727
|
+
function childLine(record, dir) {
|
|
728
|
+
if (!record) return '';
|
|
729
|
+
try {
|
|
730
|
+
return loadSeam(dir).child.childRecordLine(record);
|
|
731
|
+
} catch {
|
|
732
|
+
const flag = record.skipFlag || 'no permission flag';
|
|
733
|
+
return `aegis: claude child [${record.kind}] ${flag}`;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
module.exports = {
|
|
738
|
+
claudeConfigDir,
|
|
739
|
+
claudeOAuthToken,
|
|
740
|
+
claudeProStatus,
|
|
741
|
+
// The child-env half of "the subscription actually gets spent": the plan for
|
|
742
|
+
// what the `claude` child is given (and what a shadowing value cost), the
|
|
743
|
+
// one-line report of it, and the inspector `claudeProStatus` reports.
|
|
744
|
+
childEnvPlan,
|
|
745
|
+
envDroppedLine,
|
|
746
|
+
shadowingValue,
|
|
747
|
+
SHADOWING_ENV_VARS,
|
|
748
|
+
PRO_MODELS,
|
|
749
|
+
turnArgs,
|
|
750
|
+
createStreamParser,
|
|
751
|
+
loadSeam,
|
|
752
|
+
seamAvailable,
|
|
753
|
+
childLine,
|
|
754
|
+
runClaudePro,
|
|
755
|
+
};
|