@ravefox/space 0.2.0 → 0.3.0

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.
Files changed (163) hide show
  1. package/LICENSE +21 -203
  2. package/README.md +89 -26
  3. package/bin/space.js +106 -0
  4. package/lib/agents.js +119 -0
  5. package/lib/credentials.js +95 -0
  6. package/lib/daemon.js +294 -0
  7. package/lib/harness.js +166 -0
  8. package/lib/http.js +101 -0
  9. package/lib/login.js +68 -0
  10. package/lib/mcp.js +34 -0
  11. package/lib/runtime.js +241 -0
  12. package/lib/store.js +96 -0
  13. package/lib/tools.js +93 -0
  14. package/package.json +28 -40
  15. package/NOTICE +0 -44
  16. package/assets/webhook-sdk/python/bot.py +0 -63
  17. package/assets/webhook-sdk/python/commonly.py +0 -232
  18. package/node_modules/@commonlyai/seats/README.md +0 -25
  19. package/node_modules/@commonlyai/seats/package.json +0 -22
  20. package/node_modules/@commonlyai/seats/src/adapters/hermes.js +0 -264
  21. package/node_modules/@commonlyai/seats/src/adapters/stub.js +0 -24
  22. package/node_modules/@commonlyai/seats/src/contract.js +0 -48
  23. package/node_modules/@commonlyai/seats/src/failures.js +0 -44
  24. package/node_modules/@commonlyai/seats/src/harness-plan.js +0 -329
  25. package/node_modules/@commonlyai/seats/src/index.js +0 -22
  26. package/node_modules/@commonlyai/seats/src/process-group.js +0 -269
  27. package/node_modules/@commonlyai/seats/src/record/format.js +0 -737
  28. package/node_modules/@commonlyai/seats/src/record/otel.js +0 -238
  29. package/node_modules/@commonlyai/seats/src/runtime-observation.js +0 -29
  30. package/node_modules/@commonlyai/seats/src/seat-model.js +0 -17
  31. package/node_modules/@commonlyai/seats/src/supervisor.js +0 -308
  32. package/node_modules/@commonlyai/seats/src/trace/events.js +0 -46
  33. package/node_modules/@commonlyai/seats/src/trace/store.js +0 -276
  34. package/node_modules/@commonlyai/seats/src/turn-processes.js +0 -226
  35. package/node_modules/@commonlyai/seats/src/upstream-refusal.js +0 -418
  36. package/node_modules/@commonlyai/seats/src/valet/env.js +0 -201
  37. package/node_modules/@commonlyai/seats/src/valet/grant.js +0 -123
  38. package/node_modules/@commonlyai/seats/src/valet/permissions.js +0 -213
  39. package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +0 -529
  40. package/skills/commonly/SKILL.md +0 -233
  41. package/src/commands/agent.js +0 -6334
  42. package/src/commands/daemon.js +0 -933
  43. package/src/commands/dev.js +0 -153
  44. package/src/commands/doctor.js +0 -141
  45. package/src/commands/inbox.js +0 -330
  46. package/src/commands/login.js +0 -194
  47. package/src/commands/mcp.js +0 -56
  48. package/src/commands/pod.js +0 -120
  49. package/src/commands/task.js +0 -279
  50. package/src/commands/verify.js +0 -48
  51. package/src/index.js +0 -99
  52. package/src/lib/adapter-login.js +0 -76
  53. package/src/lib/adapters/broker-bridge-entry.js +0 -76
  54. package/src/lib/adapters/claude.js +0 -2207
  55. package/src/lib/adapters/codex-broker-bridge.mjs +0 -298
  56. package/src/lib/adapters/codex.js +0 -1898
  57. package/src/lib/adapters/dsh.js +0 -513
  58. package/src/lib/adapters/goose.js +0 -510
  59. package/src/lib/adapters/hermes.js +0 -18
  60. package/src/lib/adapters/index.js +0 -68
  61. package/src/lib/adapters/opencode.js +0 -1681
  62. package/src/lib/adapters/pi-commonly-mcp.mjs +0 -71
  63. package/src/lib/adapters/pi-mcp-client.mjs +0 -617
  64. package/src/lib/adapters/pi-shell-env.mjs +0 -41
  65. package/src/lib/adapters/pi.js +0 -1025
  66. package/src/lib/adapters/qwen.js +0 -523
  67. package/src/lib/adapters/seat-model.js +0 -8
  68. package/src/lib/adapters/stub.js +0 -1
  69. package/src/lib/adapters/tested-version.js +0 -106
  70. package/src/lib/api.js +0 -205
  71. package/src/lib/claim-outcome.js +0 -128
  72. package/src/lib/claimed-work-gate.js +0 -73
  73. package/src/lib/cli-hint.js +0 -33
  74. package/src/lib/commit-attribution.js +0 -391
  75. package/src/lib/config.js +0 -155
  76. package/src/lib/content-refusal.js +0 -94
  77. package/src/lib/credential-file.js +0 -208
  78. package/src/lib/daemon-agent-states.js +0 -95
  79. package/src/lib/daemon-hint.js +0 -18
  80. package/src/lib/daemon-logs.js +0 -43
  81. package/src/lib/daemon-service-windows.js +0 -263
  82. package/src/lib/daemon-service.js +0 -486
  83. package/src/lib/daemon-state.js +0 -84
  84. package/src/lib/daemon-store.js +0 -119
  85. package/src/lib/daemon-supervisor.js +0 -629
  86. package/src/lib/daemon-watch.js +0 -115
  87. package/src/lib/declared-mcp-guard.js +0 -284
  88. package/src/lib/default-environment.js +0 -207
  89. package/src/lib/device-login.js +0 -126
  90. package/src/lib/digest-frame.js +0 -151
  91. package/src/lib/doctor.js +0 -291
  92. package/src/lib/egress-proxy.js +0 -160
  93. package/src/lib/enforcement.js +0 -946
  94. package/src/lib/environment.js +0 -681
  95. package/src/lib/first-output-watchdog.js +0 -191
  96. package/src/lib/git-readonly-server.js +0 -138
  97. package/src/lib/git-readonly.js +0 -463
  98. package/src/lib/git-remote-commonly.mjs +0 -100
  99. package/src/lib/grant-broker-guard.js +0 -345
  100. package/src/lib/hooks-config.js +0 -238
  101. package/src/lib/host-gh-broker.js +0 -152
  102. package/src/lib/host-gh-shim.mjs +0 -98
  103. package/src/lib/inbox.js +0 -483
  104. package/src/lib/instance-proxy.js +0 -76
  105. package/src/lib/machine-host.js +0 -29
  106. package/src/lib/mcp-credential-delivery.js +0 -121
  107. package/src/lib/mcp-first-run.js +0 -50
  108. package/src/lib/mcp-home.js +0 -644
  109. package/src/lib/mcp-ready.js +0 -68
  110. package/src/lib/mcp-server-version.js +0 -123
  111. package/src/lib/mcp-token.js +0 -36
  112. package/src/lib/mcp-warm-child.mjs +0 -412
  113. package/src/lib/memory-bridge.js +0 -191
  114. package/src/lib/memory-import.js +0 -156
  115. package/src/lib/missing-harness.js +0 -31
  116. package/src/lib/model-unavailable.js +0 -154
  117. package/src/lib/native-runtime-inventory.js +0 -40
  118. package/src/lib/outsider-grant-mcp.js +0 -82
  119. package/src/lib/own-key-launch.js +0 -42
  120. package/src/lib/pod-catch-up.js +0 -55
  121. package/src/lib/pod-focus.js +0 -224
  122. package/src/lib/pod-tail.js +0 -68
  123. package/src/lib/poll-liveness.js +0 -103
  124. package/src/lib/poll-retry.js +0 -81
  125. package/src/lib/poller.js +0 -200
  126. package/src/lib/provider-proxy.js +0 -42
  127. package/src/lib/public-seat-mcp-flag.js +0 -36
  128. package/src/lib/public-tool-error.js +0 -60
  129. package/src/lib/public-withheld-tools.js +0 -48
  130. package/src/lib/reply-receipt.js +0 -55
  131. package/src/lib/resolve-on-path.js +0 -22
  132. package/src/lib/runtime-inventory.js +0 -56
  133. package/src/lib/runtime-observation.js +0 -37
  134. package/src/lib/sandbox/bwrap.js +0 -275
  135. package/src/lib/sandbox/codex-perimeter.js +0 -16
  136. package/src/lib/sandbox/host-check.js +0 -268
  137. package/src/lib/sandbox/mode.js +0 -49
  138. package/src/lib/sandbox/opencode-loopback.mjs +0 -68
  139. package/src/lib/sandbox/seatbelt.js +0 -684
  140. package/src/lib/sandbox/turn-dir.js +0 -56
  141. package/src/lib/sandbox/windows-unsandboxed.js +0 -183
  142. package/src/lib/sandbox/workspace-git-config.js +0 -243
  143. package/src/lib/sandbox/workspace-pin.js +0 -158
  144. package/src/lib/seat-runtime-controls.js +0 -61
  145. package/src/lib/seat-stop.js +0 -97
  146. package/src/lib/server-skills.js +0 -32
  147. package/src/lib/session-recycle.js +0 -219
  148. package/src/lib/session-store.js +0 -215
  149. package/src/lib/skill-prompt.js +0 -74
  150. package/src/lib/skills-import.js +0 -113
  151. package/src/lib/spawn-credential.js +0 -402
  152. package/src/lib/spawn-retry.js +0 -311
  153. package/src/lib/stable-install.js +0 -176
  154. package/src/lib/supervised-turn.js +0 -352
  155. package/src/lib/task.js +0 -235
  156. package/src/lib/turn-failure-notice.js +0 -177
  157. package/src/lib/unconfined-seat-guard.js +0 -94
  158. package/src/lib/update-check.js +0 -192
  159. package/src/lib/upstream-refusal.js +0 -1
  160. package/src/lib/webhook-server.js +0 -109
  161. package/src/lib/windows-command.js +0 -393
  162. package/src/lib/workspace-git.js +0 -87
  163. package/src/lib/workspace-trust.js +0 -162
@@ -1,74 +0,0 @@
1
- // A public seat cannot load its declared skills as files, so each adapter
2
- // delivers the SKILL.md bodies as instructions text instead:
3
- // - claude: skills are disabled outright (`--disable-slash-commands`,
4
- // `--setting-sources ''`), so the text rides as `--append-system-prompt`;
5
- // - codex: `exec` has no skill loader the seat can use, and a public seat's
6
- // CODEX_HOME is one this CLI owns, so the text is that home's AGENTS.md.
7
- // One reader for both, so the two runtimes cannot drift on what a skill says.
8
- import { readFile } from 'fs/promises';
9
- import { isAbsolute, join, resolve as pathResolve } from 'path';
10
- import { homedir } from 'os';
11
-
12
- const SKILL_FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/;
13
-
14
- export const SKILLS_AS_TEXT_PREAMBLE = 'These skills are part of how you work here. Skills are not loaded as files in this seat, '
15
- + 'so each one is given in full below. Follow them as you would a loaded skill.';
16
-
17
- /**
18
- * A declared skill path as the environment spec means it: `~/` expanded,
19
- * relative paths taken from `baseDir`. The same resolution mountSkills applies.
20
- */
21
- export const resolveSkillSource = (rawSource, baseDir) => {
22
- let source = rawSource;
23
- if (source === '~') source = homedir();
24
- else if (typeof source === 'string' && source.startsWith('~/')) source = join(homedir(), source.slice(2));
25
- return isAbsolute(source) ? source : pathResolve(baseDir, source);
26
- };
27
-
28
- const SECTION_SEPARATOR = '\n\n';
29
-
30
- /**
31
- * Each source's SKILL.md as `## Skill: <name>` plus its body without the
32
- * frontmatter, in declared order. `maxBytes` bounds the whole text
33
- * skillsAsText returns (preamble, separators and `trailer` included), so a cap
34
- * is the size of what the runtime reads. A source with no SKILL.md, or one that
35
- * would cross the cap, is skipped with a warning naming the runtime (`label`)
36
- * rather than failing the spawn.
37
- */
38
- export const readSkillSections = async (skillSources, { maxBytes, label, trailer = '', onIncluded }) => {
39
- const sections = [];
40
- let total = Buffer.byteLength(SKILLS_AS_TEXT_PREAMBLE, 'utf8') + Buffer.byteLength(trailer, 'utf8');
41
- for (const source of skillSources || []) {
42
- let markdown;
43
- try {
44
- // eslint-disable-next-line no-await-in-loop
45
- markdown = await readFile(join(source, 'SKILL.md'), 'utf8');
46
- } catch {
47
- // eslint-disable-next-line no-console
48
- console.warn(`[${label}] public seat: no SKILL.md to inline at ${source}`);
49
- continue;
50
- }
51
- const frontmatter = SKILL_FRONTMATTER_RE.exec(markdown);
52
- const name = (frontmatter && /^name:\s*(.+)$/m.exec(frontmatter[1])?.[1].trim())
53
- || source.split(/[\\/]/).filter(Boolean).pop();
54
- const section = `## Skill: ${name}\n\n${markdown.slice(frontmatter ? frontmatter[0].length : 0).trim()}`;
55
- const bytes = Buffer.byteLength(SECTION_SEPARATOR, 'utf8') + Buffer.byteLength(section, 'utf8');
56
- if (total + bytes > maxBytes) {
57
- // eslint-disable-next-line no-console
58
- console.warn(`[${label}] public seat: skill ${name} not inlined (over ${maxBytes} bytes in total)`);
59
- continue;
60
- }
61
- total += bytes;
62
- sections.push(section);
63
- onIncluded?.(source);
64
- }
65
- return sections;
66
- };
67
-
68
- /** The preamble, every section and `trailer`, or null when no skill could be read. */
69
- export const skillsAsText = async (skillSources, options) => {
70
- const sections = await readSkillSections(skillSources, options);
71
- return sections.length
72
- ? `${SKILLS_AS_TEXT_PREAMBLE}${SECTION_SEPARATOR}${sections.join(SECTION_SEPARATOR)}${options.trailer || ''}`
73
- : null;
74
- };
@@ -1,113 +0,0 @@
1
- /**
2
- * Local skills import — the metadata half of "your agent arrives whole"
3
- * (retention plan Phase C, D3). Scans a local skills directory
4
- * (<cwd>/.claude/skills or ~/.claude/skills) for SKILL.md files, parses
5
- * NAME + DESCRIPTION out of the frontmatter, and registers agent-scoped
6
- * skill entries so the agent's profile shows what it can do.
7
- *
8
- * Deliberately metadata-only (plan D3): skill *content* stays on the
9
- * user's machine — local agents execute their skills locally, Commonly
10
- * doesn't need the body, and skill files can carry private material. The
11
- * uploaded SKILL.md is a frontmatter-only stub that says exactly that.
12
- */
13
-
14
- import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
15
- import { homedir } from 'os';
16
- import { join } from 'path';
17
-
18
- export const MAX_SKILLS = 50;
19
-
20
- const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
21
-
22
- /** Parse `name:` and `description:` out of SKILL.md frontmatter. */
23
- export const parseSkillMeta = (markdown, fallbackName) => {
24
- const fm = FRONTMATTER_RE.exec(markdown);
25
- if (!fm) return { name: fallbackName, description: '' };
26
- const fields = {};
27
- for (const line of fm[1].split(/\r?\n/)) {
28
- const m = /^(name|description):\s*(.+)$/.exec(line.trim());
29
- if (m && !fields[m[1]]) fields[m[1]] = m[2].trim().replace(/^['"]|['"]$/g, '');
30
- }
31
- return {
32
- name: fields.name || fallbackName,
33
- description: (fields.description || '').slice(0, 500),
34
- };
35
- };
36
-
37
- /**
38
- * Find skill directories. Explicit dir wins; otherwise check the two
39
- * well-known locations. Each candidate is a directory whose subdirs
40
- * contain a SKILL.md.
41
- */
42
- export const detectSkills = ({ cwd = process.cwd(), home = homedir(), explicitDir = null } = {}) => {
43
- const roots = [];
44
- if (explicitDir) {
45
- if (!existsSync(explicitDir) || !statSync(explicitDir).isDirectory()) {
46
- throw new Error(`No such directory: ${explicitDir}`);
47
- }
48
- roots.push(explicitDir);
49
- } else {
50
- for (const candidate of [join(cwd, '.claude', 'skills'), join(home, '.claude', 'skills')]) {
51
- try {
52
- if (existsSync(candidate) && statSync(candidate).isDirectory()) roots.push(candidate);
53
- } catch {
54
- // symlinked skill roots can dangle; skip quietly
55
- }
56
- }
57
- }
58
-
59
- const skills = [];
60
- const seen = new Set();
61
- for (const root of roots) {
62
- for (const entry of readdirSync(root).sort()) {
63
- const skillFile = join(root, entry, 'SKILL.md');
64
- try {
65
- if (!existsSync(skillFile) || !statSync(skillFile).isFile()) continue;
66
- } catch {
67
- continue;
68
- }
69
- const meta = parseSkillMeta(readFileSync(skillFile, 'utf8'), entry);
70
- if (seen.has(meta.name)) continue;
71
- seen.add(meta.name);
72
- skills.push({ ...meta, path: skillFile });
73
- if (skills.length >= MAX_SKILLS) return skills;
74
- }
75
- }
76
- return skills;
77
- };
78
-
79
- /** The frontmatter-only stub that gets uploaded in place of skill content. */
80
- export const skillStub = ({ name, description }) => [
81
- '---',
82
- `name: ${name}`,
83
- `description: ${description || name}`,
84
- '---',
85
- '',
86
- 'Imported as metadata from a local agent — the skill itself runs on the',
87
- "agent's own machine; its content was intentionally not uploaded.",
88
- '',
89
- ].join('\n');
90
-
91
- /**
92
- * Register the skills as agent-scoped entries via POST /api/skills/import.
93
- * `client` must be authenticated as the USER (pod membership is checked),
94
- * not the agent runtime token.
95
- */
96
- export const importSkills = async (client, { skills, podId, agentName, instanceId = 'default' }) => {
97
- const results = [];
98
- for (const skill of skills) {
99
- // eslint-disable-next-line no-await-in-loop
100
- await client.post('/api/skills/import', {
101
- podId,
102
- name: skill.name,
103
- content: skillStub(skill),
104
- metadataOnly: true,
105
- description: skill.description || undefined,
106
- scope: 'agent',
107
- agentName,
108
- instanceId,
109
- });
110
- results.push(skill.name);
111
- }
112
- return { imported: results.length, names: results };
113
- };
@@ -1,402 +0,0 @@
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
- * The events a turn reads: its batch, or the one event it ran. A batch's own
86
- * `_id` is synthetic, so the batch list is the only real answer for one. Only
87
- * the wrapper builds that list, so it counts only on the batch type.
88
- */
89
- export const turnEventIds = (event) => {
90
- if (event?.type === 'inbox.batch' && Array.isArray(event.payload?.batchEventIds)) return event.payload.batchEventIds;
91
- return event?._id ? [String(event._id)] : [];
92
- };
93
-
94
- /**
95
- * A 404 with no `code` is the route missing, not this caller being refused.
96
- *
97
- * The mint's refusals are always typed (`not_found` → 404 via
98
- * `REFUSAL_STATUS`), so an untyped 404 cannot have come from the route — it is
99
- * Express's default page. Treated as its own arm rather than as a verdict
100
- * because the two call for opposite handling: a verdict must fail the spawn
101
- * closed, while a route that does not exist yet must not stop the fleet from
102
- * spawning at all.
103
- */
104
- export const isRouteAbsentRefusal = (status, body) => (
105
- status === 404 && !body?.code
106
- );
107
-
108
- /**
109
- * The ledger names the spawn, and the name has to be stable per spawn and
110
- * unique across spawns: the event id is the only identifier the wrapper has
111
- * that means "this turn" (a session id recurs across turns, an agent name
112
- * recurs forever). Truncated to the route's `MAX_SPAWN_ID_LENGTH`, keeping the
113
- * tail — the event id — because the prefix is the part a reader can infer.
114
- */
115
- export const buildSpawnId = ({ agentName = 'agent', eventId = null, maxLength = 128 } = {}) => {
116
- const suffix = eventId === null || eventId === undefined ? 'unknown' : String(eventId);
117
- const tail = `:${suffix}`;
118
- // The bound is on the WHOLE id. Bounding only the suffix (the first version)
119
- // let a long enough agent name through the bound entirely: the backend
120
- // refuses anything over MAX_SPAWN_ID_LENGTH with `invalid_spawn_id` → 400 →
121
- // fail closed, so a name would have made its own seat unspawnable. The name is
122
- // shortened first because it is the part a reader can infer from the pod; the
123
- // event id is what makes the row unique across turns, so it is kept whole
124
- // whenever it fits. Longest live `instanceId` is 25 chars (vera, 566 seats),
125
- // so this guards a future name rather than a live path.
126
- const nameBudget = Math.max(1, maxLength - tail.length);
127
- const name = String(agentName).slice(0, nameBudget) || 'agent';
128
- const id = `${name}${tail}`;
129
- return id.length > maxLength ? id.slice(0, maxLength) : id;
130
- };
131
-
132
- /**
133
- * The operator's ask, read from `COMMONLY_SPAWN_TTL_SECONDS`.
134
- *
135
- * Unset is the normal case and means "the server's published default" — the
136
- * cli has no business inventing a lifetime for a credential it does not own.
137
- * A malformed value degrades to unset rather than to a guess: this knob exists
138
- * so an operator can discover the cap, and a typo must not become a different
139
- * number that nobody chose.
140
- */
141
- export const resolveSpawnTtlSeconds = (env = {}) => {
142
- const raw = String(env.COMMONLY_SPAWN_TTL_SECONDS || '').trim();
143
- if (!raw) return null;
144
- const parsed = Number.parseInt(raw, 10);
145
- if (!Number.isFinite(parsed) || parsed <= 0) return null;
146
- return parsed;
147
- };
148
-
149
- /**
150
- * Best-effort bounds read. Returns null when the route is absent (an older
151
- * server) or unreachable — the caller then sends its ask unclamped and lets the
152
- * server's own clamp decide, which is the pre-policy behaviour and never worse.
153
- */
154
- export const readSpawnPolicy = async ({ client, log = () => {} }) => {
155
- try {
156
- const policy = await client.get(`${SPAWN_CREDENTIAL_BASE}/policy`);
157
- return policy && typeof policy === 'object' ? policy : null;
158
- } catch (err) {
159
- log(`spawn credential policy unavailable (${err?.status ?? 'no response'}) — sending the TTL ask unclamped`);
160
- return null;
161
- }
162
- };
163
-
164
- /**
165
- * The boot sweep: collect credentials orphaned by a process that died before it
166
- * could revoke them (a killed seat, a crashed daemon, a machine that rebooted
167
- * mid-turn). `POST /revoke-orphans` is the net behind `close()`.
168
- *
169
- * Called once per process from the run entry, and best-effort on purpose: it is
170
- * the SECOND net, so a failure has to leave the seat starting normally. It also
171
- * cannot be allowed to hold up the first turn, which is why the caller does not
172
- * await it.
173
- */
174
- export const revokeOrphanedSpawnCredentials = async ({ client, log = () => {} }) => {
175
- try {
176
- const result = await client.post(`${SPAWN_CREDENTIAL_BASE}/revoke-orphans`, {});
177
- const revoked = typeof result?.revoked === 'number' ? result.revoked : null;
178
- log(revoked
179
- ? `spawn credential boot sweep revoked ${revoked} orphan(s)`
180
- : 'spawn credential boot sweep found no orphans');
181
- return { ok: true, revoked };
182
- } catch (err) {
183
- // Includes the route-absent case: a backend older than the mint has no
184
- // sweep to run, and that must be a log line, never a failed boot.
185
- log(`spawn credential boot sweep skipped (${err?.status ?? 'no response'})`);
186
- return { ok: false, reason: 'sweep-failed' };
187
- }
188
- };
189
-
190
- /**
191
- * Open one lease. Returns `{ token, credentialId, source, expiresAt, grantedSeconds, ownerWoken }`.
192
- *
193
- * `source` is `'spawn'` when the child got a scoped credential and
194
- * `'seat-fallback'` when it got the seat token because the server could not
195
- * mint. A refusal (a verdict — see the header) throws with
196
- * `spawnCredentialRefused` set and the status attached, so the caller fails the
197
- * spawn closed rather than falling back.
198
- *
199
- * `ownerWoken` (F145) is true only when the server said exactly that. A seat
200
- * token can't be marked, and an older backend doesn't answer, so both read as
201
- * false and the memory tools stay withheld.
202
- */
203
- export const openSpawnCredential = async ({
204
- client,
205
- seatToken,
206
- spawnId,
207
- desiredTtlSeconds = null,
208
- policy = null,
209
- publicSeat = false,
210
- eventIds = [],
211
- log = () => {},
212
- }) => {
213
- const cap = typeof policy?.maxTtlSeconds === 'number' ? policy.maxTtlSeconds : null;
214
- let ask = desiredTtlSeconds;
215
- if (typeof ask === 'number' && Number.isFinite(ask)) {
216
- if (cap !== null && ask > cap) {
217
- log(`spawn credential: asked for ${ask}s, the server's cap is ${cap}s — asking for ${cap}s instead`);
218
- ask = cap;
219
- }
220
- } else {
221
- ask = null;
222
- }
223
-
224
- let minted;
225
- try {
226
- minted = await client.post(
227
- SPAWN_CREDENTIAL_BASE,
228
- {
229
- spawnId,
230
- ...(ask === null ? {} : { ttlSeconds: ask }),
231
- ...(publicSeat ? { publicSeat: true, eventIds } : {}),
232
- },
233
- );
234
- } catch (err) {
235
- const status = err?.status ?? null;
236
- if (isCapacityRefusal(status === null ? undefined : status)) {
237
- log(`spawn credential unavailable (${status === null ? 'no response' : `HTTP ${status}`}) — spawning with the seat token`);
238
- return {
239
- token: seatToken, credentialId: null, source: 'seat-fallback', expiresAt: null, grantedSeconds: null, ownerWoken: false,
240
- };
241
- }
242
- if (isRouteAbsentRefusal(status, err?.body)) {
243
- log('spawn credential route absent on this backend (HTTP 404 with no code) — spawning with the seat token');
244
- return {
245
- token: seatToken, credentialId: null, source: 'seat-fallback', expiresAt: null, grantedSeconds: null, ownerWoken: false, reason: 'route-absent',
246
- };
247
- }
248
- const code = err?.body?.code ? ` code=${err.body.code}` : '';
249
- const refusal = new Error(`spawn credential refused: HTTP ${status}${code}`);
250
- refusal.spawnCredentialRefused = true;
251
- refusal.status = status;
252
- refusal.body = err?.body ?? null;
253
- throw refusal;
254
- }
255
-
256
- // A 201 with no usable token is not a credential. Treat it as un-mintable
257
- // and fall back, rather than handing the child `undefined` — which would
258
- // look like a scoped credential everywhere downstream while silently being
259
- // no credential at all. (It is also the shape a stub client returns, so this
260
- // guard is what keeps a test that does not care about minting on the seat
261
- // token instead of on nothing.)
262
- if (typeof minted?.token !== 'string' || !minted.token.trim()) {
263
- log('spawn credential response carried no token — spawning with the seat token');
264
- return {
265
- token: seatToken, credentialId: null, source: 'seat-fallback', expiresAt: null, grantedSeconds: null, ownerWoken: false, reason: 'no-token',
266
- };
267
- }
268
-
269
- const grantedSeconds = minted?.expiresAt
270
- ? Math.max(0, Math.round((new Date(minted.expiresAt).getTime() - Date.now()) / 1000))
271
- : (typeof ask === 'number' ? ask : cap);
272
- return {
273
- token: minted.token,
274
- credentialId: minted.credentialId || null,
275
- source: 'spawn',
276
- expiresAt: minted.expiresAt || null,
277
- grantedSeconds,
278
- ownerWoken: minted.ownerWoken === true,
279
- };
280
- };
281
-
282
- /** Renewal cadence for a granted lifetime. */
283
- export const renewIntervalMs = (grantedSeconds) => {
284
- const half = typeof grantedSeconds === 'number' && grantedSeconds > 0
285
- ? (grantedSeconds * 1000) * RENEW_AT_FRACTION
286
- : MIN_RENEW_INTERVAL_MS;
287
- return Math.max(MIN_RENEW_INTERVAL_MS, Math.round(half));
288
- };
289
-
290
- /**
291
- * A lease owns the credential's lifetime: open, renew while the child runs,
292
- * revoke when it ends. `setIntervalImpl`/`clearIntervalImpl` are injected so a
293
- * test can drive the cadence without waiting on wall-clock time.
294
- */
295
- export const createSpawnCredentialLease = ({
296
- client,
297
- seatToken,
298
- spawnId,
299
- desiredTtlSeconds = null,
300
- policy = null,
301
- publicSeat = false,
302
- eventIds = [],
303
- log = () => {},
304
- setIntervalImpl = setInterval,
305
- clearIntervalImpl = clearInterval,
306
- }) => {
307
- let credentialId = null;
308
- let timer = null;
309
- let grantedSeconds = null;
310
-
311
- const stopRenewal = () => {
312
- if (timer !== null) {
313
- clearIntervalImpl(timer);
314
- timer = null;
315
- }
316
- };
317
-
318
- const renew = async () => {
319
- if (!credentialId) return;
320
- // A renewal extends the SAME credential, so the TTL to ask for is the one
321
- // the server already granted rather than the caller's original desire: a
322
- // caller that asked for 3600 and was capped at 900 must not turn every
323
- // renewal into another request for 3600.
324
- const ask = typeof grantedSeconds === 'number' && grantedSeconds > 0
325
- ? Math.floor(grantedSeconds)
326
- : undefined;
327
- try {
328
- await client.post(
329
- `${SPAWN_CREDENTIAL_BASE}/${credentialId}/renew`,
330
- ask === undefined ? {} : { ttlSeconds: ask },
331
- );
332
- log(`spawn credential renewed (${credentialId})`);
333
- } catch (err) {
334
- const status = err?.status ?? null;
335
- if (isCapacityRefusal(status === null ? undefined : status)) {
336
- // The credential is still live; we simply could not reach the server.
337
- // Keep the timer: the next tick is a fresh attempt.
338
- log(`spawn credential renewal deferred (${status === null ? 'no response' : `HTTP ${status}`})`);
339
- return;
340
- }
341
- if (isRouteAbsentRefusal(status, err?.body)) {
342
- // Same reading of 404 as `openSpawnCredential`: an untyped 404 is the
343
- // route missing, so keep the timer and do not treat it as a verdict
344
- // about this credential. The credential keeps its remaining lifetime
345
- // either way, and a route that comes back is picked up next tick.
346
- log('spawn credential renewal deferred (route absent, HTTP 404 with no code)');
347
- return;
348
- }
349
- // A verdict — expired or revoked. The route's contract is explicit that
350
- // this is the caller's signal to stop, not to retry harder: a late
351
- // renewal must not resurrect a dead child.
352
- stopRenewal();
353
- log(`spawn credential renewal refused (HTTP ${status}) — the child keeps its remaining lifetime and is not extended`);
354
- }
355
- };
356
-
357
- const startRenewal = () => {
358
- const interval = renewIntervalMs(grantedSeconds);
359
- timer = setIntervalImpl(() => { renew(); }, interval);
360
- if (timer && typeof timer.unref === 'function') timer.unref();
361
- };
362
-
363
- return {
364
- async open() {
365
- const opened = await openSpawnCredential({
366
- client, seatToken, spawnId, desiredTtlSeconds, policy, publicSeat, eventIds, log,
367
- });
368
- credentialId = opened.credentialId;
369
- grantedSeconds = opened.grantedSeconds;
370
- if (opened.source === 'spawn' && credentialId) startRenewal();
371
- log(opened.source === 'spawn'
372
- ? `spawn credential minted (${credentialId}, granted ${opened.grantedSeconds}s)`
373
- : 'spawning with the seat token (spawn credential unavailable)');
374
- return opened;
375
- },
376
-
377
- /**
378
- * End the spawn: stop extending it and revoke it. Revocation is
379
- * best-effort on purpose — the boot sweep (`revokeOrphanedSpawnCredentials`,
380
- * called once per process from the run entry) is the second net, and a
381
- * failed revoke must never fail a turn that already produced an answer.
382
- */
383
- async close() {
384
- stopRenewal();
385
- if (!credentialId) return { revoked: false, reason: 'no-credential' };
386
- const id = credentialId;
387
- credentialId = null;
388
- try {
389
- await client.del(`${SPAWN_CREDENTIAL_BASE}/${id}`);
390
- log(`spawn credential revoked (${id})`);
391
- return { revoked: true };
392
- } catch (err) {
393
- log(`spawn credential revoke failed (${id}, ${err?.status ?? 'no response'}) — the boot sweep will collect it`);
394
- return { revoked: false, reason: 'revoke-failed' };
395
- }
396
- },
397
-
398
- // Exposed for tests and for a caller that wants the cadence it got.
399
- get credentialId() { return credentialId; },
400
- get grantedSeconds() { return grantedSeconds; },
401
- };
402
- };