@commonlyai/cli 0.1.71 → 0.1.73

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.71",
3
+ "version": "0.1.73",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -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
- const result = await adapter.spawn(frameDecisionForkRule(promptWithFocus), {
1271
- sessionId,
1272
- cwd: agentCwd,
1273
- env: process.env,
1274
- memoryLongTerm,
1275
- environment: seatEnvironment,
1276
- // Runtime context the Claude/Codex adapters expose only to their
1277
- // per-spawn MCP environment. Claude keeps ${COMMONLY_*} placeholders
1278
- // literal on disk and lets its native MCP parser expand them, so the
1279
- // bearer token never enters the generated config file.
1280
- runtimeToken: token,
1281
- instanceUrl,
1282
- agentName,
1283
- metadata: { event },
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.
@@ -43,6 +43,7 @@ import {
43
43
  spawnCredentials,
44
44
  } from '../upstream-refusal.js';
45
45
  import { buildMemoryPreamble } from '../memory-bridge.js';
46
+ import { effectiveSandboxTrust } from '../environment.js';
46
47
  import { GRANT_BROKER_REFUSAL, isGrantBrokerUrl } from './pi-mcp-client.mjs';
47
48
  import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
48
49
  import { removeCredentialFile, writeCredentialFile } from '../credential-file.js';
@@ -283,10 +284,23 @@ export const sessionExists = async (sessionDir, sessionId) => {
283
284
  }
284
285
  };
285
286
 
286
- /** Fail closed: pi cannot enforce `environment.sandbox`, so a seat that declares one does not start. */
287
+ /**
288
+ * Fail closed: pi cannot enforce `environment.sandbox`, so a seat that declares
289
+ * one does not start.
290
+ *
291
+ * The trust is read THROUGH the legacy table (`effectiveSandboxTrust`), not
292
+ * raw. A stored `internal` means `public` (Vera 69592, Wren 69585) and every
293
+ * other gate in this package resolves it that way: the attach path
294
+ * (`agent.js`), the mode resolver (`sandbox/mode.js`) and the grant-broker
295
+ * guard. Reading it raw here made this gate the one site that failed OPEN — a
296
+ * legacy record passed both checks and the seat started unconfined, which is
297
+ * the exact outcome that was refused for the record it was migrated from.
298
+ * The mode clause needs no mapping: `internal` and the derived mode are
299
+ * independent, and a public-meaning record is refused before the mode is read.
300
+ */
287
301
  export const assertNoSandboxDeclared = (environment = {}) => {
288
302
  const sandbox = environment?.sandbox || {};
289
- if (sandbox.trust === 'public') {
303
+ if (effectiveSandboxTrust(sandbox.trust) === 'public') {
290
304
  throw new Error('pi adapter: public-trust seats are not supported — pi has no enforced sandbox; do not attach a pi seat to a stranger-readable pod');
291
305
  }
292
306
  if (sandbox.mode && sandbox.mode !== 'none') {
@@ -32,8 +32,9 @@ export const ADAPTERS_WITH_DEFAULT_MCP = new Set(['claude', 'codex', 'pi']);
32
32
  *
33
33
  * `pi` is absent deliberately. It has no sandbox path until #1740's transport
34
34
  * work gives it one, and since #1727 it REFUSES TO START on a spec that
35
- * declares one: `assertNoSandboxDeclared` in adapters/pi.js throws on
36
- * `trust: 'public'` and on any `mode` other than 'none'. So handing pi this
35
+ * declares one: `assertNoSandboxDeclared` in adapters/pi.js throws on an
36
+ * effective `trust: 'public'` (a legacy `internal` resolves to it) and on any
37
+ * `mode` other than 'none'. So handing pi this
37
38
  * block is not a harmless no-op — it is an unspawnable seat. A pi seat gets the
38
39
  * `mcp[]` half only, and the residual is that such a seat runs unconfined: that
39
40
  * belongs to the row that owns pi confinement, not to a declaration written
@@ -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
+ };