@ravefox/space 0.2.1 → 0.4.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.
- package/LICENSE +21 -205
- package/README.md +104 -31
- package/bin/space.js +113 -0
- package/lib/agents.js +181 -0
- package/lib/boardTools.js +89 -0
- package/lib/credentials.js +95 -0
- package/lib/daemon.js +294 -0
- package/lib/harness.js +166 -0
- package/lib/http.js +101 -0
- package/lib/login.js +68 -0
- package/lib/mcp.js +39 -0
- package/lib/runtime.js +253 -0
- package/lib/store.js +96 -0
- package/lib/tools.js +93 -0
- package/lib/workspaceSandbox.js +77 -0
- package/lib/workspaceTools.js +160 -0
- package/lib/workspaceWorker.js +17 -0
- package/package.json +27 -46
- package/NOTICE +0 -55
- package/assets/webhook-sdk/python/bot.py +0 -63
- package/assets/webhook-sdk/python/commonly.py +0 -232
- package/node_modules/@commonlyai/seats/README.md +0 -25
- package/node_modules/@commonlyai/seats/package.json +0 -22
- package/node_modules/@commonlyai/seats/src/adapters/hermes.js +0 -264
- package/node_modules/@commonlyai/seats/src/adapters/stub.js +0 -24
- package/node_modules/@commonlyai/seats/src/contract.js +0 -48
- package/node_modules/@commonlyai/seats/src/failures.js +0 -44
- package/node_modules/@commonlyai/seats/src/harness-plan.js +0 -329
- package/node_modules/@commonlyai/seats/src/index.js +0 -22
- package/node_modules/@commonlyai/seats/src/process-group.js +0 -269
- package/node_modules/@commonlyai/seats/src/record/format.js +0 -737
- package/node_modules/@commonlyai/seats/src/record/otel.js +0 -238
- package/node_modules/@commonlyai/seats/src/runtime-observation.js +0 -29
- package/node_modules/@commonlyai/seats/src/seat-model.js +0 -17
- package/node_modules/@commonlyai/seats/src/supervisor.js +0 -308
- package/node_modules/@commonlyai/seats/src/trace/events.js +0 -46
- package/node_modules/@commonlyai/seats/src/trace/store.js +0 -276
- package/node_modules/@commonlyai/seats/src/turn-processes.js +0 -226
- package/node_modules/@commonlyai/seats/src/upstream-refusal.js +0 -418
- package/node_modules/@commonlyai/seats/src/valet/env.js +0 -201
- package/node_modules/@commonlyai/seats/src/valet/grant.js +0 -123
- package/node_modules/@commonlyai/seats/src/valet/permissions.js +0 -213
- package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +0 -529
- package/skills/commonly/SKILL.md +0 -233
- package/src/commands/agent.js +0 -6334
- package/src/commands/daemon.js +0 -933
- package/src/commands/dev.js +0 -153
- package/src/commands/doctor.js +0 -141
- package/src/commands/inbox.js +0 -330
- package/src/commands/login.js +0 -194
- package/src/commands/mcp.js +0 -56
- package/src/commands/pod.js +0 -120
- package/src/commands/task.js +0 -279
- package/src/commands/verify.js +0 -48
- package/src/index.js +0 -99
- package/src/lib/adapter-login.js +0 -76
- package/src/lib/adapters/broker-bridge-entry.js +0 -76
- package/src/lib/adapters/claude.js +0 -2207
- package/src/lib/adapters/codex-broker-bridge.mjs +0 -298
- package/src/lib/adapters/codex.js +0 -1898
- package/src/lib/adapters/dsh.js +0 -513
- package/src/lib/adapters/goose.js +0 -510
- package/src/lib/adapters/hermes.js +0 -18
- package/src/lib/adapters/index.js +0 -68
- package/src/lib/adapters/opencode.js +0 -1681
- package/src/lib/adapters/pi-commonly-mcp.mjs +0 -71
- package/src/lib/adapters/pi-mcp-client.mjs +0 -617
- package/src/lib/adapters/pi-shell-env.mjs +0 -41
- package/src/lib/adapters/pi.js +0 -1025
- package/src/lib/adapters/qwen.js +0 -523
- package/src/lib/adapters/seat-model.js +0 -8
- package/src/lib/adapters/stub.js +0 -1
- package/src/lib/adapters/tested-version.js +0 -106
- package/src/lib/api.js +0 -205
- package/src/lib/claim-outcome.js +0 -128
- package/src/lib/claimed-work-gate.js +0 -73
- package/src/lib/cli-hint.js +0 -33
- package/src/lib/commit-attribution.js +0 -391
- package/src/lib/config.js +0 -155
- package/src/lib/content-refusal.js +0 -94
- package/src/lib/credential-file.js +0 -208
- package/src/lib/daemon-agent-states.js +0 -95
- package/src/lib/daemon-hint.js +0 -18
- package/src/lib/daemon-logs.js +0 -43
- package/src/lib/daemon-service-windows.js +0 -263
- package/src/lib/daemon-service.js +0 -486
- package/src/lib/daemon-state.js +0 -84
- package/src/lib/daemon-store.js +0 -119
- package/src/lib/daemon-supervisor.js +0 -629
- package/src/lib/daemon-watch.js +0 -115
- package/src/lib/declared-mcp-guard.js +0 -284
- package/src/lib/default-environment.js +0 -207
- package/src/lib/device-login.js +0 -126
- package/src/lib/digest-frame.js +0 -151
- package/src/lib/doctor.js +0 -291
- package/src/lib/egress-proxy.js +0 -160
- package/src/lib/enforcement.js +0 -946
- package/src/lib/environment.js +0 -681
- package/src/lib/first-output-watchdog.js +0 -191
- package/src/lib/git-readonly-server.js +0 -138
- package/src/lib/git-readonly.js +0 -463
- package/src/lib/git-remote-commonly.mjs +0 -100
- package/src/lib/grant-broker-guard.js +0 -345
- package/src/lib/hooks-config.js +0 -238
- package/src/lib/host-gh-broker.js +0 -152
- package/src/lib/host-gh-shim.mjs +0 -98
- package/src/lib/inbox.js +0 -483
- package/src/lib/instance-proxy.js +0 -76
- package/src/lib/machine-host.js +0 -29
- package/src/lib/mcp-credential-delivery.js +0 -121
- package/src/lib/mcp-first-run.js +0 -50
- package/src/lib/mcp-home.js +0 -644
- package/src/lib/mcp-ready.js +0 -68
- package/src/lib/mcp-server-version.js +0 -123
- package/src/lib/mcp-token.js +0 -36
- package/src/lib/mcp-warm-child.mjs +0 -412
- package/src/lib/memory-bridge.js +0 -191
- package/src/lib/memory-import.js +0 -156
- package/src/lib/missing-harness.js +0 -31
- package/src/lib/model-unavailable.js +0 -154
- package/src/lib/native-runtime-inventory.js +0 -40
- package/src/lib/outsider-grant-mcp.js +0 -82
- package/src/lib/own-key-launch.js +0 -42
- package/src/lib/pod-catch-up.js +0 -55
- package/src/lib/pod-focus.js +0 -224
- package/src/lib/pod-tail.js +0 -68
- package/src/lib/poll-liveness.js +0 -103
- package/src/lib/poll-retry.js +0 -81
- package/src/lib/poller.js +0 -200
- package/src/lib/provider-proxy.js +0 -42
- package/src/lib/public-seat-mcp-flag.js +0 -36
- package/src/lib/public-tool-error.js +0 -60
- package/src/lib/public-withheld-tools.js +0 -48
- package/src/lib/reply-receipt.js +0 -55
- package/src/lib/resolve-on-path.js +0 -22
- package/src/lib/runtime-inventory.js +0 -56
- package/src/lib/runtime-observation.js +0 -37
- package/src/lib/sandbox/bwrap.js +0 -275
- package/src/lib/sandbox/codex-perimeter.js +0 -16
- package/src/lib/sandbox/host-check.js +0 -268
- package/src/lib/sandbox/mode.js +0 -49
- package/src/lib/sandbox/opencode-loopback.mjs +0 -68
- package/src/lib/sandbox/seatbelt.js +0 -684
- package/src/lib/sandbox/turn-dir.js +0 -56
- package/src/lib/sandbox/windows-unsandboxed.js +0 -183
- package/src/lib/sandbox/workspace-git-config.js +0 -243
- package/src/lib/sandbox/workspace-pin.js +0 -158
- package/src/lib/seat-runtime-controls.js +0 -61
- package/src/lib/seat-stop.js +0 -97
- package/src/lib/server-skills.js +0 -32
- package/src/lib/session-recycle.js +0 -219
- package/src/lib/session-store.js +0 -215
- package/src/lib/skill-prompt.js +0 -74
- package/src/lib/skills-import.js +0 -113
- package/src/lib/spawn-credential.js +0 -402
- package/src/lib/spawn-retry.js +0 -311
- package/src/lib/stable-install.js +0 -176
- package/src/lib/supervised-turn.js +0 -352
- package/src/lib/task.js +0 -235
- package/src/lib/turn-failure-notice.js +0 -177
- package/src/lib/unconfined-seat-guard.js +0 -94
- package/src/lib/update-check.js +0 -192
- package/src/lib/upstream-refusal.js +0 -1
- package/src/lib/webhook-server.js +0 -109
- package/src/lib/windows-command.js +0 -393
- package/src/lib/workspace-git.js +0 -87
- package/src/lib/workspace-trust.js +0 -162
package/src/lib/skill-prompt.js
DELETED
|
@@ -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
|
-
};
|
package/src/lib/skills-import.js
DELETED
|
@@ -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
|
-
};
|