@commonlyai/cli 0.1.72 → 0.1.74
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/package.json +1 -1
- package/src/commands/agent.js +130 -14
- package/src/commands/daemon.js +27 -4
- package/src/lib/adapters/index.js +15 -0
- package/src/lib/adapters/pi.js +8 -0
- package/src/lib/daemon-service.js +131 -7
- package/src/lib/spawn-credential.js +375 -0
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -17,6 +17,13 @@ import { homedir, tmpdir } from 'os';
|
|
|
17
17
|
import { fileURLToPath } from 'url';
|
|
18
18
|
|
|
19
19
|
import { createClient } from '../lib/api.js';
|
|
20
|
+
import {
|
|
21
|
+
buildSpawnId,
|
|
22
|
+
createSpawnCredentialLease,
|
|
23
|
+
readSpawnPolicy,
|
|
24
|
+
resolveSpawnTtlSeconds,
|
|
25
|
+
revokeOrphanedSpawnCredentials,
|
|
26
|
+
} from '../lib/spawn-credential.js';
|
|
20
27
|
import { getToken, resolveInstanceUrl } from '../lib/config.js';
|
|
21
28
|
import { startPoller, terminalDeliveryAckError } from '../lib/poller.js';
|
|
22
29
|
import { startWebhookServer, forwardToLocalWebhook } from '../lib/webhook-server.js';
|
|
@@ -925,10 +932,46 @@ export const performRun = ({
|
|
|
925
932
|
// Kept injectable for small-page regression cases; the runtime ships with
|
|
926
933
|
// the server's ordinary ten-event page.
|
|
927
934
|
inboxBatchLimit = 10,
|
|
935
|
+
// TASK-102 part B. Undefined means "use the server's published default"; a
|
|
936
|
+
// number here is the operator's ask, and the policy read at boot is what
|
|
937
|
+
// makes a clamp visible instead of silent.
|
|
938
|
+
spawnCredentialTtlSeconds = null,
|
|
939
|
+
spawnCredentialLeaseFactory = createSpawnCredentialLease,
|
|
940
|
+
revokeOrphansImpl = revokeOrphanedSpawnCredentials,
|
|
928
941
|
sleepImpl = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }),
|
|
929
942
|
}) => {
|
|
930
943
|
const client = createClient({ instance: instanceUrl, token });
|
|
931
944
|
|
|
945
|
+
// THE BOOT SWEEP IS THE SECOND NET, AND IT HAPPENS ONCE PER PROCESS. A seat
|
|
946
|
+
// that dies mid-turn (kill, crash, reboot) leaves its child credential live
|
|
947
|
+
// until its own TTL expires, because `close()` never ran; this is what collects
|
|
948
|
+
// those. Fire-and-forget by contract: `performRun` is synchronous for its
|
|
949
|
+
// callers, the sweep is best-effort, and a sweep that failed must not stop a
|
|
950
|
+
// seat from spawning — it logs and returns. Per process rather than per spawn
|
|
951
|
+
// because the orphans being collected belong to the PREVIOUS process.
|
|
952
|
+
//
|
|
953
|
+
// The promise wrapper is not ceremony: it makes a throwing OR rejecting
|
|
954
|
+
// implementation of the seam a log line instead of an unhandled rejection,
|
|
955
|
+
// which is the difference between a diagnostic and a crash in a seat that is
|
|
956
|
+
// otherwise fine.
|
|
957
|
+
void Promise.resolve()
|
|
958
|
+
.then(() => revokeOrphansImpl({ client, log }))
|
|
959
|
+
.catch((err) => log(`spawn credential boot sweep failed (${err?.message ?? err})`));
|
|
960
|
+
|
|
961
|
+
// The bounds the server will clamp to. READ ONLY IF THERE IS AN ASK TO CLAMP:
|
|
962
|
+
// with no `COMMONLY_SPAWN_TTL_SECONDS` the cli sends no `ttlSeconds` at all
|
|
963
|
+
// and the server's default is what it is, so a per-run read would be a request
|
|
964
|
+
// on every seat's boot in exchange for nothing. Read once and cached, because
|
|
965
|
+
// the bounds are constants for the life of a process. `readSpawnPolicy` never
|
|
966
|
+
// rejects (a failure resolves to null), so this cannot fail a spawn; it just
|
|
967
|
+
// sends the ask unclamped and lets the server decide.
|
|
968
|
+
let spawnPolicyPromise = null;
|
|
969
|
+
const spawnPolicyForAsk = () => {
|
|
970
|
+
if (spawnCredentialTtlSeconds === null || spawnCredentialTtlSeconds === undefined) return null;
|
|
971
|
+
if (spawnPolicyPromise === null) spawnPolicyPromise = readSpawnPolicy({ client, log });
|
|
972
|
+
return spawnPolicyPromise;
|
|
973
|
+
};
|
|
974
|
+
|
|
932
975
|
// THE SEAT'S OWN RECORD IS THE ONLY DECLARATION THERE IS. The broker reaches a
|
|
933
976
|
// seat as an injected MCP entry written by hand into its environment — no
|
|
934
977
|
// server projection puts it there (`GRANT_BROKER_URL` is imported by one
|
|
@@ -1267,21 +1310,90 @@ export const performRun = ({
|
|
|
1267
1310
|
const promptWithFocus = `${focusFrame}\n\n${prompt}`;
|
|
1268
1311
|
|
|
1269
1312
|
log(`[${event.type}] spawning ${adapter.name}`);
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1313
|
+
// TASK-102 part B. The CHILD gets a scoped credential; the wrapper keeps
|
|
1314
|
+
// the seat token for its own polling, claiming and posting (`client` was
|
|
1315
|
+
// built from `token` above and is not touched here). The lease renews while
|
|
1316
|
+
// the child runs and revokes when it returns, so the window in which a
|
|
1317
|
+
// leaked child credential is usable is one turn rather than a seat's life.
|
|
1318
|
+
//
|
|
1319
|
+
// A refusal throws: `openSpawnCredential` only falls back to the seat token
|
|
1320
|
+
// for capacity, and a verdict (401/403/400/404/409) must fail the spawn
|
|
1321
|
+
// rather than restore the authority the refusal was about. The catch below
|
|
1322
|
+
// turns that throw into a NAMED refusal on the turn result, so the claim is
|
|
1323
|
+
// released as `delivery-refused` instead of being re-delivered forever.
|
|
1324
|
+
const spawnPolicyOrNull = spawnPolicyForAsk();
|
|
1325
|
+
const lease = spawnCredentialLeaseFactory({
|
|
1326
|
+
client,
|
|
1327
|
+
seatToken: token,
|
|
1328
|
+
spawnId: buildSpawnId({ agentName, eventId: event?._id || event?.payload?.batchEventIds || null }),
|
|
1329
|
+
desiredTtlSeconds: spawnCredentialTtlSeconds,
|
|
1330
|
+
policy: spawnPolicyOrNull === null ? null : await spawnPolicyOrNull,
|
|
1331
|
+
log,
|
|
1284
1332
|
});
|
|
1333
|
+
let opened;
|
|
1334
|
+
try {
|
|
1335
|
+
opened = await lease.open();
|
|
1336
|
+
} catch (err) {
|
|
1337
|
+
// A VERDICT IS NOT A CRASH, AND IT IS NOT A NO-OP (wren 72153, vera
|
|
1338
|
+
// 72157). Letting it escape as a bare throw left `turnResult` undefined,
|
|
1339
|
+
// so `claimReleaseFor` returned the legacy holder-only DELETE — right for
|
|
1340
|
+
// a transient failure, wrong for a refusal that recurs on every
|
|
1341
|
+
// redelivery: the wake loops forever with one local log line as its only
|
|
1342
|
+
// record. Naming it on the turn result is what lets the release carry the
|
|
1343
|
+
// class, and on a human wake lets the kernel hand the message to a
|
|
1344
|
+
// remaining listener instead of stranding it.
|
|
1345
|
+
if (!err?.spawnCredentialRefused) {
|
|
1346
|
+
// ONLY a verdict is named as a refusal. An unexpected failure inside
|
|
1347
|
+
// the lease — a bug in our own code, not the server's answer — keeps
|
|
1348
|
+
// the legacy release, because `delivery-refused` would hand a human's
|
|
1349
|
+
// message away and close the row on the strength of our own defect.
|
|
1350
|
+
// Redelivery is the right remedy for a crash.
|
|
1351
|
+
log(`spawn credential lease failed (${err?.message ?? err}) — not spawning`);
|
|
1352
|
+
throw err;
|
|
1353
|
+
}
|
|
1354
|
+
const status = Number.isInteger(err.status) ? err.status : null;
|
|
1355
|
+
const code = typeof err.body?.code === 'string' ? err.body.code : null;
|
|
1356
|
+
log(`spawn credential refused (HTTP ${status ?? 'no response'}) — not spawning`);
|
|
1357
|
+
// `delivery-refused` claims the least of the three classes: the model
|
|
1358
|
+
// never ran, and the refuser is our own mint rather than the model
|
|
1359
|
+
// provider (that would be `upstream-refused`, and its status is the HTTP
|
|
1360
|
+
// status of an upstream call — a meaning this refusal does not have, so
|
|
1361
|
+
// the number travels in the ack instead of on the claim record).
|
|
1362
|
+
return {
|
|
1363
|
+
outcome: 'no_action',
|
|
1364
|
+
refused: { reason: 'delivery-refused' },
|
|
1365
|
+
reason: `spawn-credential-refused-${status ?? 'unknown'}`,
|
|
1366
|
+
details: {
|
|
1367
|
+
...(status !== null ? { status } : {}),
|
|
1368
|
+
...(code !== null ? { code } : {}),
|
|
1369
|
+
},
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
let result;
|
|
1373
|
+
try {
|
|
1374
|
+
result = await adapter.spawn(frameDecisionForkRule(promptWithFocus), {
|
|
1375
|
+
sessionId,
|
|
1376
|
+
cwd: agentCwd,
|
|
1377
|
+
env: process.env,
|
|
1378
|
+
memoryLongTerm,
|
|
1379
|
+
environment: seatEnvironment,
|
|
1380
|
+
// Runtime context the Claude/Codex adapters expose only to their
|
|
1381
|
+
// per-spawn MCP environment. Claude keeps ${COMMONLY_*} placeholders
|
|
1382
|
+
// literal on disk and lets its native MCP parser expand them, so the
|
|
1383
|
+
// bearer token never enters the generated config file. Since TASK-102
|
|
1384
|
+
// part B this is the per-spawn credential when one could be minted, and
|
|
1385
|
+
// the seat token only when the server could not mint.
|
|
1386
|
+
runtimeToken: opened.token,
|
|
1387
|
+
instanceUrl,
|
|
1388
|
+
agentName,
|
|
1389
|
+
metadata: { event },
|
|
1390
|
+
});
|
|
1391
|
+
} finally {
|
|
1392
|
+
// Best-effort by construction: `close` logs and returns on a failed
|
|
1393
|
+
// revoke, so a turn that already produced an answer is not failed by
|
|
1394
|
+
// cleanup. The boot sweep collects anything left behind.
|
|
1395
|
+
await lease.close();
|
|
1396
|
+
}
|
|
1285
1397
|
|
|
1286
1398
|
if (result.newSessionId) {
|
|
1287
1399
|
setSession(agentName, eventPodId, result.newSessionId);
|
|
@@ -2611,6 +2723,10 @@ Docs:
|
|
|
2611
2723
|
workspacePath: record.workspacePath || null,
|
|
2612
2724
|
intervalMs: parseInt(opts.interval, 10),
|
|
2613
2725
|
...cascadeOverridesFromOpts(opts),
|
|
2726
|
+
// TASK-102 part B: the operator's TTL ask, if any. Unset means the
|
|
2727
|
+
// server's published default, which is the only lifetime we can justify
|
|
2728
|
+
// without an operator saying otherwise.
|
|
2729
|
+
spawnCredentialTtlSeconds: resolveSpawnTtlSeconds(process.env),
|
|
2614
2730
|
log: (line) => console.log(`${stamp()} [${name}] ${line}`),
|
|
2615
2731
|
// Both sinks are stamped, and both must be — but not for the reason
|
|
2616
2732
|
// this comment gave until now, which its own PR falsified.
|
package/src/commands/daemon.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { hostname, homedir } from 'os';
|
|
10
10
|
import { spawn } from 'child_process';
|
|
11
11
|
import {
|
|
12
|
-
chmodSync, closeSync, existsSync, mkdirSync, openSync, rmSync, watch,
|
|
12
|
+
chmodSync, closeSync, existsSync, mkdirSync, openSync, rmSync, watch,
|
|
13
13
|
} from 'fs';
|
|
14
14
|
import { createClient } from '../lib/api.js';
|
|
15
15
|
import { getToken, resolveInstanceUrl } from '../lib/config.js';
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
} from '../lib/daemon-supervisor.js';
|
|
22
22
|
import { loadAgentToken, saveAgentToken } from './agent.js';
|
|
23
23
|
import { getLastTurn } from '../lib/session-store.js';
|
|
24
|
-
import { getAdapter } from '../lib/adapters/index.js';
|
|
24
|
+
import { getAdapter, providerKeyEnvNames } from '../lib/adapters/index.js';
|
|
25
25
|
import {
|
|
26
26
|
installDaemonService,
|
|
27
27
|
uninstallDaemonService,
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
stopDaemonService,
|
|
30
30
|
restartDaemonService,
|
|
31
31
|
daemonLogPath,
|
|
32
|
+
missingProviderKeys,
|
|
32
33
|
servicePaths,
|
|
33
34
|
} from '../lib/daemon-service.js';
|
|
34
35
|
import { daemonLogsDir, daemonSeatLogPath, readLogTail } from '../lib/daemon-logs.js';
|
|
@@ -237,7 +238,10 @@ Examples:
|
|
|
237
238
|
|
|
238
239
|
// ── install / uninstall (ADR-026 D1) ──────────────────────────────────────
|
|
239
240
|
const serviceDeps = () => ({
|
|
240
|
-
writeFile
|
|
241
|
+
// writeFile is deliberately NOT injected here: daemon-service.js's default
|
|
242
|
+
// writer sets the 0600 mode at CREATION, and a bare `writeFileSync(file,
|
|
243
|
+
// content, 'utf8')` dropped that — leaving the service file, which can hold
|
|
244
|
+
// a provider key, readable at umask until the chmod a moment later.
|
|
241
245
|
mkdirp: (dir) => { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); },
|
|
242
246
|
chmod: (path, mode) => chmodSync(path, mode),
|
|
243
247
|
ensureFile: (path) => { const fd = openSync(path, 'a', 0o600); closeSync(fd); },
|
|
@@ -270,7 +274,12 @@ Examples:
|
|
|
270
274
|
try {
|
|
271
275
|
// A service without a credential just crash-loops at boot.
|
|
272
276
|
requireDaemonRecord();
|
|
273
|
-
|
|
277
|
+
// Same class one layer over (TASK-049): a service without the SEATS'
|
|
278
|
+
// provider key comes up clean, and every pi seat then dies inside its
|
|
279
|
+
// adapter. The keys are captured here because this is the only moment
|
|
280
|
+
// the operator's shell is in reach; the ones that are missing are named
|
|
281
|
+
// by installDaemonService rather than left to be discovered per seat.
|
|
282
|
+
await installDaemonService({ ...serviceDeps(), providerKeyEnvNames: providerKeyEnvNames() });
|
|
274
283
|
console.log('The daemon now starts at login and is kept alive. Uninstall with: commonly daemon uninstall');
|
|
275
284
|
} catch (error) {
|
|
276
285
|
console.error(`Daemon install failed: ${error.message}`);
|
|
@@ -342,6 +351,20 @@ Examples:
|
|
|
342
351
|
chmodSync(logsDir, 0o700);
|
|
343
352
|
const stampLog = (line) => console.log(`${new Date().toISOString()} ${line}`);
|
|
344
353
|
|
|
354
|
+
// TASK-049: fail loudly at BIND, once, naming the variable.
|
|
355
|
+
//
|
|
356
|
+
// A service-started daemon inherits a clean environment, so a provider
|
|
357
|
+
// key that lives only in the operator's shell is absent here; every pi
|
|
358
|
+
// seat then dies at adapter start with `COMMONLY_LITELLM_KEY is not set`
|
|
359
|
+
// and the operator sees crash loops with no cause at daemon level. One
|
|
360
|
+
// line at startup answers the question the log would otherwise take a
|
|
361
|
+
// seat autopsy to answer. Not fatal: the daemon's other seats are
|
|
362
|
+
// unaffected, and refusing to start would turn one missing key into a
|
|
363
|
+
// whole-machine outage.
|
|
364
|
+
for (const name of missingProviderKeys({ names: providerKeyEnvNames() })) {
|
|
365
|
+
stampLog(`WARNING: ${name} is not set in this environment — a seat whose adapter needs it will fail to start. Export it and re-run: commonly daemon install (docs/agents/daemon-service-environment.md)`);
|
|
366
|
+
}
|
|
367
|
+
|
|
345
368
|
const supervisor = createDaemonSupervisor({
|
|
346
369
|
record,
|
|
347
370
|
client,
|
|
@@ -22,3 +22,18 @@ const ADAPTERS = {
|
|
|
22
22
|
export const listAdapterNames = () => Object.keys(ADAPTERS);
|
|
23
23
|
|
|
24
24
|
export const getAdapter = (name) => ADAPTERS[name] || null;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* TASK-049: the environment a seat's ADAPTER needs from the operator's shell.
|
|
28
|
+
*
|
|
29
|
+
* The adapter owns the provider, so the adapter declares the variable (see
|
|
30
|
+
* `pi.js`); the daemon reads the list to carry those keys into its login
|
|
31
|
+
* service, because launchd and systemd start the daemon with a clean
|
|
32
|
+
* environment — a key that exists only in an interactive shell is absent at
|
|
33
|
+
* boot, and the seat then dies inside its own adapter. An adapter that
|
|
34
|
+
* authenticates through its own CLI (claude, codex) declares nothing here and
|
|
35
|
+
* contributes nothing.
|
|
36
|
+
*/
|
|
37
|
+
export const providerKeyEnvNames = (adapters = ADAPTERS) => [...new Set(
|
|
38
|
+
Object.values(adapters).map((adapter) => adapter.providerKeyEnv).filter(Boolean),
|
|
39
|
+
)];
|
package/src/lib/adapters/pi.js
CHANGED
|
@@ -386,6 +386,14 @@ const runPi = ({ args, cwd, env, payload, timeoutMs, credentials = [], spawnImpl
|
|
|
386
386
|
export default {
|
|
387
387
|
name: 'pi',
|
|
388
388
|
|
|
389
|
+
// TASK-049: the variable this adapter needs from the environment of whatever
|
|
390
|
+
// process spawns it. Declared HERE because the adapter owns the provider; the
|
|
391
|
+
// registry collects it (adapters/index.js) so the daemon can carry the key
|
|
392
|
+
// into its login service and fail loudly at bind when it is absent, instead
|
|
393
|
+
// of the seat dying at line ~406 with nothing naming the missing variable at
|
|
394
|
+
// daemon level.
|
|
395
|
+
providerKeyEnv: DEFAULT_PROVIDER.apiKeyEnv,
|
|
396
|
+
|
|
389
397
|
async detect() {
|
|
390
398
|
const res = spawnSync('pi', ['--version'], { encoding: 'utf8' });
|
|
391
399
|
if (res.error || res.status !== 0) return null;
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* supervisor: the unit CONTENT is unit-testable without touching launchctl.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { randomBytes } from 'crypto';
|
|
15
|
+
import { renameSync, unlinkSync, writeFileSync } from 'fs';
|
|
14
16
|
import { homedir } from 'os';
|
|
15
17
|
import { join, dirname, resolve } from 'path';
|
|
16
18
|
|
|
@@ -28,6 +30,29 @@ export const servicePaths = (platform = process.platform, home = homedir()) => (
|
|
|
28
30
|
const xmlEscape = (value) => String(value)
|
|
29
31
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
30
32
|
|
|
33
|
+
// The systemd sink is NOT the plist sink, so escaping one is not escaping both.
|
|
34
|
+
// Inside `Environment="…"` a raw `"` closes the quote, and a raw newline ends
|
|
35
|
+
// the assignment so the remainder of the key lands in the unit as a directive.
|
|
36
|
+
// The adversary here is the operator's own environment, so severity is low —
|
|
37
|
+
// but correctness does not need an adversary: a provider key containing a `"`
|
|
38
|
+
// writes a unit that will not start, with nothing on screen to say why.
|
|
39
|
+
// systemd quoted values carry C-style escapes (systemd.syntax(7)), so both
|
|
40
|
+
// values are escaped and neither sink is trusted to be the harmless one. The
|
|
41
|
+
// last replacement is the least obvious: `Environment=` does NOT expand `$VAR`
|
|
42
|
+
// but DOES perform specifier expansion (systemd.exec(5), "Specifier expansion is
|
|
43
|
+
// performed"), and `systemd.unit(5)` gives the escape — `%%` in place of `%`.
|
|
44
|
+
// A key containing `%h` would otherwise be rewritten to the home directory: the
|
|
45
|
+
// unit parses, the daemon starts, the seat gets a key nobody exported, and it
|
|
46
|
+
// fails at the provider with nothing at daemon level naming the cause — the same
|
|
47
|
+
// failure class this code exists to remove, arriving through the escaping.
|
|
48
|
+
const systemdEscape = (value) => String(value)
|
|
49
|
+
.replace(/%/g, '%%')
|
|
50
|
+
.replace(/\\/g, '\\\\')
|
|
51
|
+
.replace(/"/g, '\\"')
|
|
52
|
+
.replace(/\n/g, '\\n')
|
|
53
|
+
.replace(/\r/g, '\\r')
|
|
54
|
+
.replace(/\t/g, '\\t');
|
|
55
|
+
|
|
31
56
|
// PATH matters for the CHILDREN: the daemon spawns `commonly agent run`,
|
|
32
57
|
// which spawns the user's claude/codex CLI by name. launchd's default PATH
|
|
33
58
|
// has no /opt/homebrew/bin, so without this the daemon comes up and every
|
|
@@ -36,7 +61,87 @@ const childPath = (nodePath) => [
|
|
|
36
61
|
dirname(nodePath), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin',
|
|
37
62
|
].filter((entry, index, all) => all.indexOf(entry) === index).join(':');
|
|
38
63
|
|
|
39
|
-
|
|
64
|
+
// TASK-049: PATH is not the only thing the children need.
|
|
65
|
+
//
|
|
66
|
+
// launchd and systemd both start the daemon from a CLEAN environment, so a
|
|
67
|
+
// seat's provider key that exists only in the operator's interactive shell is
|
|
68
|
+
// absent at boot — and the seat then dies inside its own adapter (`pi.js`
|
|
69
|
+
// throws `COMMONLY_LITELLM_KEY is not set`). That is the same defect the PATH
|
|
70
|
+
// line above fixes, one layer over, and it is fixed the same way: carry what
|
|
71
|
+
// the install could see. Installation is the ONLY moment the operator's shell is
|
|
72
|
+
// in reach, so the keys are captured there, and the ones that were missing are
|
|
73
|
+
// named at install time rather than discovered one crash-looping seat at a time.
|
|
74
|
+
//
|
|
75
|
+
// The values are secrets. The daemon's own credential is a 0600 file, so the
|
|
76
|
+
// service file is written 0600 too instead of being left at the umask default a
|
|
77
|
+
// PATH-only unit could safely keep.
|
|
78
|
+
//
|
|
79
|
+
// The mode is guaranteed BY CONSTRUCTION rather than by the order of two calls:
|
|
80
|
+
// the content is written to a sibling temp file CREATED 0600 and then renamed
|
|
81
|
+
// over the target. `writeFileSync`'s mode applies only at creation, so the
|
|
82
|
+
// obvious alternatives each leave a path open — a write straight to the target
|
|
83
|
+
// cannot narrow a file an EARLIER install left at 0644 (its mode survives the
|
|
84
|
+
// write, and a pre-existing 0644 service file is what every upgrade meets),
|
|
85
|
+
// and a chmod after the write closes the door the key already walked through.
|
|
86
|
+
// The rename also removes a second hazard, narrower than it first looks: systemd
|
|
87
|
+
// does NOT re-read a changed unit by itself — it reports the file as changed on
|
|
88
|
+
// disk and keeps the loaded copy until a `systemctl --user daemon-reload`. What
|
|
89
|
+
// is exposed is a reader CONCURRENT with the write, and that is not exotic here:
|
|
90
|
+
// an install ends by running `daemon-reload`, every other install on the machine
|
|
91
|
+
// can run one at the same moment, and `systemctl --user show` reads the file.
|
|
92
|
+
// After a rename each of them sees the old file or the new one, never a
|
|
93
|
+
// truncated unit.
|
|
94
|
+
//
|
|
95
|
+
// POSIX rename replaces the destination atomically, so this needs no
|
|
96
|
+
// chmod-if-exists dance and no cleanup of the old file.
|
|
97
|
+
//
|
|
98
|
+
// The temp NAME and the temp WRITE are each half of the same defence, and
|
|
99
|
+
// neither is sufficient on its own. A predictable name (`<file>.tmp-<pid>`) lets
|
|
100
|
+
// a process running as this user — which is every seat this repo spawns —
|
|
101
|
+
// pre-plant a symlink at that path and capture the key when the write follows
|
|
102
|
+
// it; `O_EXCL` is what actually refuses that, because it fails on a symlink at
|
|
103
|
+
// the final component instead of writing through it. The random suffix only
|
|
104
|
+
// removes the easy target, and the cleanup only deletes a temp this call made.
|
|
105
|
+
export const SERVICE_FILE_MODE = 0o600;
|
|
106
|
+
|
|
107
|
+
// Exported so the O_EXCL refusal is witnessed against a real symlink at a known
|
|
108
|
+
// path rather than assumed from the absence of a race.
|
|
109
|
+
export const serviceTempPath = (file, suffix = randomBytes(6).toString('hex')) => `${file}.tmp-${suffix}`;
|
|
110
|
+
|
|
111
|
+
export const writeServiceFile = (file, content, { suffix } = {}) => {
|
|
112
|
+
const tmp = serviceTempPath(file, suffix);
|
|
113
|
+
let created = false;
|
|
114
|
+
try {
|
|
115
|
+
// `wx` = O_CREAT|O_EXCL: the write either creates the temp itself or does not
|
|
116
|
+
// happen at all, so a planted path is refused rather than followed.
|
|
117
|
+
writeFileSync(tmp, content, { encoding: 'utf8', mode: SERVICE_FILE_MODE, flag: 'wx' });
|
|
118
|
+
created = true;
|
|
119
|
+
renameSync(tmp, file);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
// Only the temp this call created is ours to remove. After a refused write
|
|
122
|
+
// the path belongs to whoever planted it.
|
|
123
|
+
if (created) {
|
|
124
|
+
try {
|
|
125
|
+
unlinkSync(tmp);
|
|
126
|
+
} catch {
|
|
127
|
+
// Nothing to clean up; the original error is the one that matters.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
export const providerEnvPairs = ({ names = [], env = process.env } = {}) => names
|
|
134
|
+
.filter((name) => env[name])
|
|
135
|
+
.map((name) => [name, String(env[name])]);
|
|
136
|
+
|
|
137
|
+
export const missingProviderKeys = ({ names = [], env = process.env } = {}) => names
|
|
138
|
+
.filter((name) => !env[name]);
|
|
139
|
+
|
|
140
|
+
const plistEnvPairs = (pairs) => pairs
|
|
141
|
+
.map(([name, value]) => `\t\t<key>${xmlEscape(name)}</key>\n\t\t<string>${xmlEscape(value)}</string>\n`)
|
|
142
|
+
.join('');
|
|
143
|
+
|
|
144
|
+
export const launchdPlist = ({ nodePath, cliPath, home = homedir(), providerEnv = [] }) => `<?xml version="1.0" encoding="UTF-8"?>
|
|
40
145
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
41
146
|
<plist version="1.0">
|
|
42
147
|
<dict>
|
|
@@ -48,7 +153,7 @@ export const launchdPlist = ({ nodePath, cliPath, home = homedir() }) => `<?xml
|
|
|
48
153
|
\t\t<string>${xmlEscape(childPath(nodePath))}</string>
|
|
49
154
|
\t\t<key>HOME</key>
|
|
50
155
|
\t\t<string>${xmlEscape(home)}</string>
|
|
51
|
-
\t</dict>
|
|
156
|
+
${plistEnvPairs(providerEnv)}\t</dict>
|
|
52
157
|
\t<key>ProgramArguments</key>
|
|
53
158
|
\t<array>
|
|
54
159
|
\t\t<string>${xmlEscape(nodePath)}</string>
|
|
@@ -69,7 +174,11 @@ export const launchdPlist = ({ nodePath, cliPath, home = homedir() }) => `<?xml
|
|
|
69
174
|
</plist>
|
|
70
175
|
`;
|
|
71
176
|
|
|
72
|
-
export const systemdUnit = ({ nodePath, cliPath }) =>
|
|
177
|
+
export const systemdUnit = ({ nodePath, cliPath, providerEnv = [] }) => {
|
|
178
|
+
const providerLines = providerEnv.length
|
|
179
|
+
? `\n${providerEnv.map(([name, value]) => `Environment="${systemdEscape(`${name}=${value}`)}"`).join('\n')}`
|
|
180
|
+
: '';
|
|
181
|
+
return `[Unit]
|
|
73
182
|
Description=Commonly local agent daemon (ADR-026)
|
|
74
183
|
After=network-online.target
|
|
75
184
|
|
|
@@ -77,23 +186,28 @@ After=network-online.target
|
|
|
77
186
|
ExecStart=${nodePath} ${cliPath} daemon run --foreground
|
|
78
187
|
Restart=always
|
|
79
188
|
RestartSec=5
|
|
80
|
-
Environment=PATH=${childPath(nodePath)}
|
|
189
|
+
Environment=PATH=${childPath(nodePath)}${providerLines}
|
|
81
190
|
|
|
82
191
|
[Install]
|
|
83
192
|
WantedBy=default.target
|
|
84
193
|
`;
|
|
194
|
+
};
|
|
85
195
|
|
|
86
196
|
export const installDaemonService = async ({
|
|
87
197
|
platform = process.platform,
|
|
88
198
|
home = homedir(),
|
|
89
199
|
nodePath = process.execPath,
|
|
90
200
|
cliPath = resolve(process.argv[1]),
|
|
91
|
-
|
|
201
|
+
// The provider keys the seats' adapters declare (adapters/index.js).
|
|
202
|
+
providerKeyEnvNames = [],
|
|
203
|
+
env = process.env,
|
|
204
|
+
writeFile = writeServiceFile,
|
|
92
205
|
mkdirp,
|
|
93
206
|
execCmd, // async (argv: string[]) => void — throws on failure
|
|
94
207
|
chmod = () => {},
|
|
95
208
|
ensureFile,
|
|
96
209
|
log = () => {},
|
|
210
|
+
warn = log,
|
|
97
211
|
}) => {
|
|
98
212
|
const target = servicePaths(platform, home);
|
|
99
213
|
mkdirp(dirname(target.file));
|
|
@@ -104,17 +218,27 @@ export const installDaemonService = async ({
|
|
|
104
218
|
chmod(daemonLogPath(home), 0o600);
|
|
105
219
|
}
|
|
106
220
|
|
|
221
|
+
const providerEnv = providerEnvPairs({ names: providerKeyEnvNames, env });
|
|
222
|
+
const missing = missingProviderKeys({ names: providerKeyEnvNames, env });
|
|
223
|
+
|
|
107
224
|
if (target.kind === 'launchd') {
|
|
108
|
-
|
|
225
|
+
// The mode and the atomicity are the WRITER's, not an ordering rule here:
|
|
226
|
+
// `writeServiceFile` creates a 0600 sibling and renames it over the target,
|
|
227
|
+
// so no chmod is needed on this path and no half-written unit is ever
|
|
228
|
+
// visible to launchd (see the writer for why neither half could cover both).
|
|
229
|
+
writeFile(target.file, launchdPlist({ nodePath, cliPath, home, providerEnv }));
|
|
109
230
|
// Reload cleanly if a previous version is loaded; the unload of an
|
|
110
231
|
// unknown label fails by design and is ignored.
|
|
111
232
|
await execCmd(['launchctl', 'unload', target.file]).catch(() => {});
|
|
112
233
|
await execCmd(['launchctl', 'load', '-w', target.file]);
|
|
113
234
|
} else {
|
|
114
|
-
writeFile(target.file, systemdUnit({ nodePath, cliPath }));
|
|
235
|
+
writeFile(target.file, systemdUnit({ nodePath, cliPath, providerEnv }));
|
|
115
236
|
await execCmd(['systemctl', '--user', 'daemon-reload']);
|
|
116
237
|
await execCmd(['systemctl', '--user', 'enable', '--now', SYSTEMD_UNIT]);
|
|
117
238
|
}
|
|
239
|
+
for (const name of missing) {
|
|
240
|
+
warn(`${name} is not set in this shell, so it is NOT in the service file. A seat whose adapter needs it will fail to start; export it and re-run: commonly daemon install (docs/agents/daemon-service-environment.md)`);
|
|
241
|
+
}
|
|
118
242
|
log(`Installed ${target.kind} service (${target.file}). Logs: ${daemonLogPath(home)}`);
|
|
119
243
|
return target;
|
|
120
244
|
};
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* spawn-credential.js — TASK-102 part B (the cli half of TASK-094).
|
|
3
|
+
*
|
|
4
|
+
* WHAT THIS REPLACES. Every adapter used to hand the child the seat's own
|
|
5
|
+
* runtime token (`ctx.runtimeToken`, sourced from `COMMONLY_AGENT_TOKEN`): one
|
|
6
|
+
* long-lived credential, the full authority of the seat, inherited by every
|
|
7
|
+
* process the child spawns and written to a credential file that outlives the
|
|
8
|
+
* turn. The backend half (TASK-094 PR A, merged as `3e08c328`) can mint a
|
|
9
|
+
* per-spawn child instead — bounded lifetime, named in a ledger, revocable —
|
|
10
|
+
* and this module is the caller that makes that real rather than available.
|
|
11
|
+
*
|
|
12
|
+
* WHY A LEASE AND NOT A SINGLE MINT. The granted TTL is 900s by default
|
|
13
|
+
* (`SPAWN_TTL_MAX_SECONDS`), and a turn can outlive it: a long coding turn
|
|
14
|
+
* would start failing mid-flight with 401s the seat cannot explain, which is
|
|
15
|
+
* strictly worse than the seat token it replaced. So the lease renews at
|
|
16
|
+
* half-life while the child runs (the absolute lifetime is 24h, so renewal is
|
|
17
|
+
* the mechanism the backend already provides for exactly this) and revokes at
|
|
18
|
+
* the end of the turn, which is what bounds a leaked child credential to one
|
|
19
|
+
* turn instead of forever.
|
|
20
|
+
*
|
|
21
|
+
* THE FALLBACK PREDICATE IS ABOUT WHAT A FAILURE TELLS US (vera 71068/`#1823`
|
|
22
|
+
* thread). Four arms, each named here so a reader checking the predicate against
|
|
23
|
+
* this comment does not have to re-derive one:
|
|
24
|
+
*
|
|
25
|
+
* capacity — 429, 5xx, or no HTTP response at all. The server could not mint
|
|
26
|
+
* *right now*; that says nothing about whether this seat may mint, and the
|
|
27
|
+
* seat token is an authority the seat already holds, so falling back grants
|
|
28
|
+
* nothing new and keeps the seat working. `no status` belongs here because
|
|
29
|
+
* the request never got a verdict at all: an availability fact, not an
|
|
30
|
+
* authorization one.
|
|
31
|
+
* route absent — a 404 whose body carries NO `code`. The mint's own refusals
|
|
32
|
+
* always name themselves (`REFUSAL_STATUS` in
|
|
33
|
+
* `backend/routes/spawnCredentials.ts` maps `not_found` → 404), so a bare
|
|
34
|
+
* 404 is Express's default page from a backend deployed before this route
|
|
35
|
+
* existed. Failing THAT closed would stop every spawn on such a fleet and
|
|
36
|
+
* re-deliver the event forever — strictly worse than the seat token every
|
|
37
|
+
* seat was using before this module existed. `readSpawnPolicy` reads its own
|
|
38
|
+
* 404 the same way; one reading of "404" per file, not two.
|
|
39
|
+
* verdict — every other 4xx (401 dead token, 403 not this seat's installation,
|
|
40
|
+
* 400 malformed spawn id, 409 not renewable, or a 404 that DOES carry a
|
|
41
|
+
* code). A refusal is about the caller or the request, and falling back
|
|
42
|
+
* there would silently restore the exact authority the refusal was about,
|
|
43
|
+
* making the whole mechanism decorative.
|
|
44
|
+
* malformed success — a 2xx carrying no usable token. Deliberately NOT filed
|
|
45
|
+
* under capacity: the server answered, and the answer is a server bug
|
|
46
|
+
* rather than anything about this caller. It falls back for the same reason
|
|
47
|
+
* route-absent does — the seat's authority is what it already had, and
|
|
48
|
+
* failing closed would take a fleet down over someone else's bug — but it is
|
|
49
|
+
* its own arm so that the choice is visible as a choice.
|
|
50
|
+
*
|
|
51
|
+
* The caller's own identity is never used for its own HTTP call: the lease is
|
|
52
|
+
* minted with the SEAT token over the seat's client, and only the CHILD gets
|
|
53
|
+
* the scoped token. The wrapper keeps polling, claiming and posting with the
|
|
54
|
+
* seat token, which is why `performRun` passes `spawnToken.token` to the
|
|
55
|
+
* adapter and keeps `token` for its client.
|
|
56
|
+
*
|
|
57
|
+
* THE POLICY READ EXISTS SO THE CLAMP IS VISIBLE. `GET /policy` publishes the
|
|
58
|
+
* bounds `clampSpawnTtlSeconds` actually applies (`minTtlSeconds`,
|
|
59
|
+
* `maxTtlSeconds`, `absoluteLifetimeSeconds`). A caller that asks for 3600 used
|
|
60
|
+
* to receive a 900s credential with no way to find out; here the ask is
|
|
61
|
+
* compared against the published cap and the difference is logged once at
|
|
62
|
+
* open. The read is best-effort: a missing policy degrades to sending the ask
|
|
63
|
+
* and letting the server clamp, never to refusing to spawn.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
export const SPAWN_CREDENTIAL_BASE = '/api/agents/runtime/spawn-credentials';
|
|
67
|
+
|
|
68
|
+
// Renew at half the granted lifetime: one renewal is missed and the credential
|
|
69
|
+
// still has 450s of headroom at the default TTL, which covers a slow span
|
|
70
|
+
// between the timer firing and the request landing.
|
|
71
|
+
export const RENEW_AT_FRACTION = 0.5;
|
|
72
|
+
|
|
73
|
+
// Never renew faster than this. A server that grants a TTL shorter than two
|
|
74
|
+
// minutes would otherwise produce a renewal loop tight enough to be its own
|
|
75
|
+
// traffic; the floor keeps the loop's cadence a function of our intent rather
|
|
76
|
+
// than of an unexpectedly small grant.
|
|
77
|
+
export const MIN_RENEW_INTERVAL_MS = 30 * 1000;
|
|
78
|
+
|
|
79
|
+
/** Capacity (the server could not mint) vs verdict (the server refused). */
|
|
80
|
+
export const isCapacityRefusal = (status) => (
|
|
81
|
+
status === undefined || status === null || status === 429 || status >= 500
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A 404 with no `code` is the route missing, not this caller being refused.
|
|
86
|
+
*
|
|
87
|
+
* The mint's refusals are always typed (`not_found` → 404 via
|
|
88
|
+
* `REFUSAL_STATUS`), so an untyped 404 cannot have come from the route — it is
|
|
89
|
+
* Express's default page. Treated as its own arm rather than as a verdict
|
|
90
|
+
* because the two call for opposite handling: a verdict must fail the spawn
|
|
91
|
+
* closed, while a route that does not exist yet must not stop the fleet from
|
|
92
|
+
* spawning at all.
|
|
93
|
+
*/
|
|
94
|
+
export const isRouteAbsentRefusal = (status, body) => (
|
|
95
|
+
status === 404 && !body?.code
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The ledger names the spawn, and the name has to be stable per spawn and
|
|
100
|
+
* unique across spawns: the event id is the only identifier the wrapper has
|
|
101
|
+
* that means "this turn" (a session id recurs across turns, an agent name
|
|
102
|
+
* recurs forever). Truncated to the route's `MAX_SPAWN_ID_LENGTH`, keeping the
|
|
103
|
+
* tail — the event id — because the prefix is the part a reader can infer.
|
|
104
|
+
*/
|
|
105
|
+
export const buildSpawnId = ({ agentName = 'agent', eventId = null, maxLength = 128 } = {}) => {
|
|
106
|
+
const suffix = eventId === null || eventId === undefined ? 'unknown' : String(eventId);
|
|
107
|
+
const tail = `:${suffix}`;
|
|
108
|
+
// The bound is on the WHOLE id. Bounding only the suffix (the first version)
|
|
109
|
+
// let a long enough agent name through the bound entirely: the backend
|
|
110
|
+
// refuses anything over MAX_SPAWN_ID_LENGTH with `invalid_spawn_id` → 400 →
|
|
111
|
+
// fail closed, so a name would have made its own seat unspawnable. The name is
|
|
112
|
+
// shortened first because it is the part a reader can infer from the pod; the
|
|
113
|
+
// event id is what makes the row unique across turns, so it is kept whole
|
|
114
|
+
// whenever it fits. Longest live `instanceId` is 25 chars (vera, 566 seats),
|
|
115
|
+
// so this guards a future name rather than a live path.
|
|
116
|
+
const nameBudget = Math.max(1, maxLength - tail.length);
|
|
117
|
+
const name = String(agentName).slice(0, nameBudget) || 'agent';
|
|
118
|
+
const id = `${name}${tail}`;
|
|
119
|
+
return id.length > maxLength ? id.slice(0, maxLength) : id;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The operator's ask, read from `COMMONLY_SPAWN_TTL_SECONDS`.
|
|
124
|
+
*
|
|
125
|
+
* Unset is the normal case and means "the server's published default" — the
|
|
126
|
+
* cli has no business inventing a lifetime for a credential it does not own.
|
|
127
|
+
* A malformed value degrades to unset rather than to a guess: this knob exists
|
|
128
|
+
* so an operator can discover the cap, and a typo must not become a different
|
|
129
|
+
* number that nobody chose.
|
|
130
|
+
*/
|
|
131
|
+
export const resolveSpawnTtlSeconds = (env = {}) => {
|
|
132
|
+
const raw = String(env.COMMONLY_SPAWN_TTL_SECONDS || '').trim();
|
|
133
|
+
if (!raw) return null;
|
|
134
|
+
const parsed = Number.parseInt(raw, 10);
|
|
135
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
|
136
|
+
return parsed;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Best-effort bounds read. Returns null when the route is absent (an older
|
|
141
|
+
* server) or unreachable — the caller then sends its ask unclamped and lets the
|
|
142
|
+
* server's own clamp decide, which is the pre-policy behaviour and never worse.
|
|
143
|
+
*/
|
|
144
|
+
export const readSpawnPolicy = async ({ client, log = () => {} }) => {
|
|
145
|
+
try {
|
|
146
|
+
const policy = await client.get(`${SPAWN_CREDENTIAL_BASE}/policy`);
|
|
147
|
+
return policy && typeof policy === 'object' ? policy : null;
|
|
148
|
+
} catch (err) {
|
|
149
|
+
log(`spawn credential policy unavailable (${err?.status ?? 'no response'}) — sending the TTL ask unclamped`);
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The boot sweep: collect credentials orphaned by a process that died before it
|
|
156
|
+
* could revoke them (a killed seat, a crashed daemon, a machine that rebooted
|
|
157
|
+
* mid-turn). `POST /revoke-orphans` is the net behind `close()`.
|
|
158
|
+
*
|
|
159
|
+
* Called once per process from the run entry, and best-effort on purpose: it is
|
|
160
|
+
* the SECOND net, so a failure has to leave the seat starting normally. It also
|
|
161
|
+
* cannot be allowed to hold up the first turn, which is why the caller does not
|
|
162
|
+
* await it.
|
|
163
|
+
*/
|
|
164
|
+
export const revokeOrphanedSpawnCredentials = async ({ client, log = () => {} }) => {
|
|
165
|
+
try {
|
|
166
|
+
const result = await client.post(`${SPAWN_CREDENTIAL_BASE}/revoke-orphans`, {});
|
|
167
|
+
const revoked = typeof result?.revoked === 'number' ? result.revoked : null;
|
|
168
|
+
log(revoked
|
|
169
|
+
? `spawn credential boot sweep revoked ${revoked} orphan(s)`
|
|
170
|
+
: 'spawn credential boot sweep found no orphans');
|
|
171
|
+
return { ok: true, revoked };
|
|
172
|
+
} catch (err) {
|
|
173
|
+
// Includes the route-absent case: a backend older than the mint has no
|
|
174
|
+
// sweep to run, and that must be a log line, never a failed boot.
|
|
175
|
+
log(`spawn credential boot sweep skipped (${err?.status ?? 'no response'})`);
|
|
176
|
+
return { ok: false, reason: 'sweep-failed' };
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Open one lease. Returns `{ token, credentialId, source, expiresAt, grantedSeconds }`.
|
|
182
|
+
*
|
|
183
|
+
* `source` is `'spawn'` when the child got a scoped credential and
|
|
184
|
+
* `'seat-fallback'` when it got the seat token because the server could not
|
|
185
|
+
* mint. A refusal (a verdict — see the header) throws with
|
|
186
|
+
* `spawnCredentialRefused` set and the status attached, so the caller fails the
|
|
187
|
+
* spawn closed rather than falling back.
|
|
188
|
+
*/
|
|
189
|
+
export const openSpawnCredential = async ({
|
|
190
|
+
client,
|
|
191
|
+
seatToken,
|
|
192
|
+
spawnId,
|
|
193
|
+
desiredTtlSeconds = null,
|
|
194
|
+
policy = null,
|
|
195
|
+
log = () => {},
|
|
196
|
+
}) => {
|
|
197
|
+
const cap = typeof policy?.maxTtlSeconds === 'number' ? policy.maxTtlSeconds : null;
|
|
198
|
+
let ask = desiredTtlSeconds;
|
|
199
|
+
if (typeof ask === 'number' && Number.isFinite(ask)) {
|
|
200
|
+
if (cap !== null && ask > cap) {
|
|
201
|
+
log(`spawn credential: asked for ${ask}s, the server's cap is ${cap}s — asking for ${cap}s instead`);
|
|
202
|
+
ask = cap;
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
ask = null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let minted;
|
|
209
|
+
try {
|
|
210
|
+
minted = await client.post(
|
|
211
|
+
SPAWN_CREDENTIAL_BASE,
|
|
212
|
+
ask === null ? { spawnId } : { spawnId, ttlSeconds: ask },
|
|
213
|
+
);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
const status = err?.status ?? null;
|
|
216
|
+
if (isCapacityRefusal(status === null ? undefined : status)) {
|
|
217
|
+
log(`spawn credential unavailable (${status === null ? 'no response' : `HTTP ${status}`}) — spawning with the seat token`);
|
|
218
|
+
return { token: seatToken, credentialId: null, source: 'seat-fallback', expiresAt: null, grantedSeconds: null };
|
|
219
|
+
}
|
|
220
|
+
if (isRouteAbsentRefusal(status, err?.body)) {
|
|
221
|
+
log('spawn credential route absent on this backend (HTTP 404 with no code) — spawning with the seat token');
|
|
222
|
+
return {
|
|
223
|
+
token: seatToken, credentialId: null, source: 'seat-fallback', expiresAt: null, grantedSeconds: null, reason: 'route-absent',
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
const code = err?.body?.code ? ` code=${err.body.code}` : '';
|
|
227
|
+
const refusal = new Error(`spawn credential refused: HTTP ${status}${code}`);
|
|
228
|
+
refusal.spawnCredentialRefused = true;
|
|
229
|
+
refusal.status = status;
|
|
230
|
+
refusal.body = err?.body ?? null;
|
|
231
|
+
throw refusal;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// A 201 with no usable token is not a credential. Treat it as un-mintable
|
|
235
|
+
// and fall back, rather than handing the child `undefined` — which would
|
|
236
|
+
// look like a scoped credential everywhere downstream while silently being
|
|
237
|
+
// no credential at all. (It is also the shape a stub client returns, so this
|
|
238
|
+
// guard is what keeps a test that does not care about minting on the seat
|
|
239
|
+
// token instead of on nothing.)
|
|
240
|
+
if (typeof minted?.token !== 'string' || !minted.token.trim()) {
|
|
241
|
+
log('spawn credential response carried no token — spawning with the seat token');
|
|
242
|
+
return {
|
|
243
|
+
token: seatToken, credentialId: null, source: 'seat-fallback', expiresAt: null, grantedSeconds: null, reason: 'no-token',
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const grantedSeconds = minted?.expiresAt
|
|
248
|
+
? Math.max(0, Math.round((new Date(minted.expiresAt).getTime() - Date.now()) / 1000))
|
|
249
|
+
: (typeof ask === 'number' ? ask : cap);
|
|
250
|
+
return {
|
|
251
|
+
token: minted.token,
|
|
252
|
+
credentialId: minted.credentialId || null,
|
|
253
|
+
source: 'spawn',
|
|
254
|
+
expiresAt: minted.expiresAt || null,
|
|
255
|
+
grantedSeconds,
|
|
256
|
+
};
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/** Renewal cadence for a granted lifetime. */
|
|
260
|
+
export const renewIntervalMs = (grantedSeconds) => {
|
|
261
|
+
const half = typeof grantedSeconds === 'number' && grantedSeconds > 0
|
|
262
|
+
? (grantedSeconds * 1000) * RENEW_AT_FRACTION
|
|
263
|
+
: MIN_RENEW_INTERVAL_MS;
|
|
264
|
+
return Math.max(MIN_RENEW_INTERVAL_MS, Math.round(half));
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* A lease owns the credential's lifetime: open, renew while the child runs,
|
|
269
|
+
* revoke when it ends. `setIntervalImpl`/`clearIntervalImpl` are injected so a
|
|
270
|
+
* test can drive the cadence without waiting on wall-clock time.
|
|
271
|
+
*/
|
|
272
|
+
export const createSpawnCredentialLease = ({
|
|
273
|
+
client,
|
|
274
|
+
seatToken,
|
|
275
|
+
spawnId,
|
|
276
|
+
desiredTtlSeconds = null,
|
|
277
|
+
policy = null,
|
|
278
|
+
log = () => {},
|
|
279
|
+
setIntervalImpl = setInterval,
|
|
280
|
+
clearIntervalImpl = clearInterval,
|
|
281
|
+
}) => {
|
|
282
|
+
let credentialId = null;
|
|
283
|
+
let timer = null;
|
|
284
|
+
let grantedSeconds = null;
|
|
285
|
+
|
|
286
|
+
const stopRenewal = () => {
|
|
287
|
+
if (timer !== null) {
|
|
288
|
+
clearIntervalImpl(timer);
|
|
289
|
+
timer = null;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const renew = async () => {
|
|
294
|
+
if (!credentialId) return;
|
|
295
|
+
// A renewal extends the SAME credential, so the TTL to ask for is the one
|
|
296
|
+
// the server already granted rather than the caller's original desire: a
|
|
297
|
+
// caller that asked for 3600 and was capped at 900 must not turn every
|
|
298
|
+
// renewal into another request for 3600.
|
|
299
|
+
const ask = typeof grantedSeconds === 'number' && grantedSeconds > 0
|
|
300
|
+
? Math.floor(grantedSeconds)
|
|
301
|
+
: undefined;
|
|
302
|
+
try {
|
|
303
|
+
await client.post(
|
|
304
|
+
`${SPAWN_CREDENTIAL_BASE}/${credentialId}/renew`,
|
|
305
|
+
ask === undefined ? {} : { ttlSeconds: ask },
|
|
306
|
+
);
|
|
307
|
+
log(`spawn credential renewed (${credentialId})`);
|
|
308
|
+
} catch (err) {
|
|
309
|
+
const status = err?.status ?? null;
|
|
310
|
+
if (isCapacityRefusal(status === null ? undefined : status)) {
|
|
311
|
+
// The credential is still live; we simply could not reach the server.
|
|
312
|
+
// Keep the timer: the next tick is a fresh attempt.
|
|
313
|
+
log(`spawn credential renewal deferred (${status === null ? 'no response' : `HTTP ${status}`})`);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (isRouteAbsentRefusal(status, err?.body)) {
|
|
317
|
+
// Same reading of 404 as `openSpawnCredential`: an untyped 404 is the
|
|
318
|
+
// route missing, so keep the timer and do not treat it as a verdict
|
|
319
|
+
// about this credential. The credential keeps its remaining lifetime
|
|
320
|
+
// either way, and a route that comes back is picked up next tick.
|
|
321
|
+
log('spawn credential renewal deferred (route absent, HTTP 404 with no code)');
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
// A verdict — expired or revoked. The route's contract is explicit that
|
|
325
|
+
// this is the caller's signal to stop, not to retry harder: a late
|
|
326
|
+
// renewal must not resurrect a dead child.
|
|
327
|
+
stopRenewal();
|
|
328
|
+
log(`spawn credential renewal refused (HTTP ${status}) — the child keeps its remaining lifetime and is not extended`);
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const startRenewal = () => {
|
|
333
|
+
const interval = renewIntervalMs(grantedSeconds);
|
|
334
|
+
timer = setIntervalImpl(() => { renew(); }, interval);
|
|
335
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
async open() {
|
|
340
|
+
const opened = await openSpawnCredential({ client, seatToken, spawnId, desiredTtlSeconds, policy, log });
|
|
341
|
+
credentialId = opened.credentialId;
|
|
342
|
+
grantedSeconds = opened.grantedSeconds;
|
|
343
|
+
if (opened.source === 'spawn' && credentialId) startRenewal();
|
|
344
|
+
log(opened.source === 'spawn'
|
|
345
|
+
? `spawn credential minted (${credentialId}, granted ${opened.grantedSeconds}s)`
|
|
346
|
+
: 'spawning with the seat token (spawn credential unavailable)');
|
|
347
|
+
return opened;
|
|
348
|
+
},
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* End the spawn: stop extending it and revoke it. Revocation is
|
|
352
|
+
* best-effort on purpose — the boot sweep (`revokeOrphanedSpawnCredentials`,
|
|
353
|
+
* called once per process from the run entry) is the second net, and a
|
|
354
|
+
* failed revoke must never fail a turn that already produced an answer.
|
|
355
|
+
*/
|
|
356
|
+
async close() {
|
|
357
|
+
stopRenewal();
|
|
358
|
+
if (!credentialId) return { revoked: false, reason: 'no-credential' };
|
|
359
|
+
const id = credentialId;
|
|
360
|
+
credentialId = null;
|
|
361
|
+
try {
|
|
362
|
+
await client.del(`${SPAWN_CREDENTIAL_BASE}/${id}`);
|
|
363
|
+
log(`spawn credential revoked (${id})`);
|
|
364
|
+
return { revoked: true };
|
|
365
|
+
} catch (err) {
|
|
366
|
+
log(`spawn credential revoke failed (${id}, ${err?.status ?? 'no response'}) — the boot sweep will collect it`);
|
|
367
|
+
return { revoked: false, reason: 'revoke-failed' };
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
|
|
371
|
+
// Exposed for tests and for a caller that wants the cadence it got.
|
|
372
|
+
get credentialId() { return credentialId; },
|
|
373
|
+
get grantedSeconds() { return grantedSeconds; },
|
|
374
|
+
};
|
|
375
|
+
};
|