@ravefox/space 0.0.0-stage → 0.2.1
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 +205 -0
- package/NOTICE +55 -0
- package/README.md +63 -2
- package/assets/webhook-sdk/python/bot.py +63 -0
- package/assets/webhook-sdk/python/commonly.py +232 -0
- package/node_modules/@commonlyai/seats/README.md +25 -0
- package/node_modules/@commonlyai/seats/package.json +22 -0
- package/node_modules/@commonlyai/seats/src/adapters/hermes.js +264 -0
- package/node_modules/@commonlyai/seats/src/adapters/stub.js +24 -0
- package/node_modules/@commonlyai/seats/src/contract.js +48 -0
- package/node_modules/@commonlyai/seats/src/failures.js +44 -0
- package/node_modules/@commonlyai/seats/src/harness-plan.js +329 -0
- package/node_modules/@commonlyai/seats/src/index.js +22 -0
- package/node_modules/@commonlyai/seats/src/process-group.js +269 -0
- package/node_modules/@commonlyai/seats/src/record/format.js +737 -0
- package/node_modules/@commonlyai/seats/src/record/otel.js +238 -0
- package/node_modules/@commonlyai/seats/src/runtime-observation.js +29 -0
- package/node_modules/@commonlyai/seats/src/seat-model.js +17 -0
- package/node_modules/@commonlyai/seats/src/supervisor.js +308 -0
- package/node_modules/@commonlyai/seats/src/trace/events.js +46 -0
- package/node_modules/@commonlyai/seats/src/trace/store.js +276 -0
- package/node_modules/@commonlyai/seats/src/turn-processes.js +226 -0
- package/node_modules/@commonlyai/seats/src/upstream-refusal.js +418 -0
- package/node_modules/@commonlyai/seats/src/valet/env.js +201 -0
- package/node_modules/@commonlyai/seats/src/valet/grant.js +123 -0
- package/node_modules/@commonlyai/seats/src/valet/permissions.js +213 -0
- package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +529 -0
- package/package.json +63 -4
- package/skills/commonly/SKILL.md +233 -0
- package/src/commands/agent.js +6334 -0
- package/src/commands/daemon.js +933 -0
- package/src/commands/dev.js +153 -0
- package/src/commands/doctor.js +141 -0
- package/src/commands/inbox.js +330 -0
- package/src/commands/login.js +194 -0
- package/src/commands/mcp.js +56 -0
- package/src/commands/pod.js +120 -0
- package/src/commands/task.js +279 -0
- package/src/commands/verify.js +48 -0
- package/src/index.js +99 -0
- package/src/lib/adapter-login.js +76 -0
- package/src/lib/adapters/broker-bridge-entry.js +76 -0
- package/src/lib/adapters/claude.js +2207 -0
- package/src/lib/adapters/codex-broker-bridge.mjs +298 -0
- package/src/lib/adapters/codex.js +1898 -0
- package/src/lib/adapters/dsh.js +513 -0
- package/src/lib/adapters/goose.js +510 -0
- package/src/lib/adapters/hermes.js +18 -0
- package/src/lib/adapters/index.js +68 -0
- package/src/lib/adapters/opencode.js +1681 -0
- package/src/lib/adapters/pi-commonly-mcp.mjs +71 -0
- package/src/lib/adapters/pi-mcp-client.mjs +617 -0
- package/src/lib/adapters/pi-shell-env.mjs +41 -0
- package/src/lib/adapters/pi.js +1025 -0
- package/src/lib/adapters/qwen.js +523 -0
- package/src/lib/adapters/seat-model.js +8 -0
- package/src/lib/adapters/stub.js +1 -0
- package/src/lib/adapters/tested-version.js +106 -0
- package/src/lib/api.js +205 -0
- package/src/lib/claim-outcome.js +128 -0
- package/src/lib/claimed-work-gate.js +73 -0
- package/src/lib/cli-hint.js +33 -0
- package/src/lib/commit-attribution.js +391 -0
- package/src/lib/config.js +155 -0
- package/src/lib/content-refusal.js +94 -0
- package/src/lib/credential-file.js +208 -0
- package/src/lib/daemon-agent-states.js +95 -0
- package/src/lib/daemon-hint.js +18 -0
- package/src/lib/daemon-logs.js +43 -0
- package/src/lib/daemon-service-windows.js +263 -0
- package/src/lib/daemon-service.js +486 -0
- package/src/lib/daemon-state.js +84 -0
- package/src/lib/daemon-store.js +119 -0
- package/src/lib/daemon-supervisor.js +629 -0
- package/src/lib/daemon-watch.js +115 -0
- package/src/lib/declared-mcp-guard.js +284 -0
- package/src/lib/default-environment.js +207 -0
- package/src/lib/device-login.js +126 -0
- package/src/lib/digest-frame.js +151 -0
- package/src/lib/doctor.js +291 -0
- package/src/lib/egress-proxy.js +160 -0
- package/src/lib/enforcement.js +946 -0
- package/src/lib/environment.js +681 -0
- package/src/lib/first-output-watchdog.js +191 -0
- package/src/lib/git-readonly-server.js +138 -0
- package/src/lib/git-readonly.js +463 -0
- package/src/lib/git-remote-commonly.mjs +100 -0
- package/src/lib/grant-broker-guard.js +345 -0
- package/src/lib/hooks-config.js +238 -0
- package/src/lib/host-gh-broker.js +152 -0
- package/src/lib/host-gh-shim.mjs +98 -0
- package/src/lib/inbox.js +483 -0
- package/src/lib/instance-proxy.js +76 -0
- package/src/lib/machine-host.js +29 -0
- package/src/lib/mcp-credential-delivery.js +121 -0
- package/src/lib/mcp-first-run.js +50 -0
- package/src/lib/mcp-home.js +644 -0
- package/src/lib/mcp-ready.js +68 -0
- package/src/lib/mcp-server-version.js +123 -0
- package/src/lib/mcp-token.js +36 -0
- package/src/lib/mcp-warm-child.mjs +412 -0
- package/src/lib/memory-bridge.js +191 -0
- package/src/lib/memory-import.js +156 -0
- package/src/lib/missing-harness.js +31 -0
- package/src/lib/model-unavailable.js +154 -0
- package/src/lib/native-runtime-inventory.js +40 -0
- package/src/lib/outsider-grant-mcp.js +82 -0
- package/src/lib/own-key-launch.js +42 -0
- package/src/lib/pod-catch-up.js +55 -0
- package/src/lib/pod-focus.js +224 -0
- package/src/lib/pod-tail.js +68 -0
- package/src/lib/poll-liveness.js +103 -0
- package/src/lib/poll-retry.js +81 -0
- package/src/lib/poller.js +200 -0
- package/src/lib/provider-proxy.js +42 -0
- package/src/lib/public-seat-mcp-flag.js +36 -0
- package/src/lib/public-tool-error.js +60 -0
- package/src/lib/public-withheld-tools.js +48 -0
- package/src/lib/reply-receipt.js +55 -0
- package/src/lib/resolve-on-path.js +22 -0
- package/src/lib/runtime-inventory.js +56 -0
- package/src/lib/runtime-observation.js +37 -0
- package/src/lib/sandbox/bwrap.js +275 -0
- package/src/lib/sandbox/codex-perimeter.js +16 -0
- package/src/lib/sandbox/host-check.js +268 -0
- package/src/lib/sandbox/mode.js +49 -0
- package/src/lib/sandbox/opencode-loopback.mjs +68 -0
- package/src/lib/sandbox/seatbelt.js +684 -0
- package/src/lib/sandbox/turn-dir.js +56 -0
- package/src/lib/sandbox/windows-unsandboxed.js +183 -0
- package/src/lib/sandbox/workspace-git-config.js +243 -0
- package/src/lib/sandbox/workspace-pin.js +158 -0
- package/src/lib/seat-runtime-controls.js +61 -0
- package/src/lib/seat-stop.js +97 -0
- package/src/lib/server-skills.js +32 -0
- package/src/lib/session-recycle.js +219 -0
- package/src/lib/session-store.js +215 -0
- package/src/lib/skill-prompt.js +74 -0
- package/src/lib/skills-import.js +113 -0
- package/src/lib/spawn-credential.js +402 -0
- package/src/lib/spawn-retry.js +311 -0
- package/src/lib/stable-install.js +176 -0
- package/src/lib/supervised-turn.js +352 -0
- package/src/lib/task.js +235 -0
- package/src/lib/turn-failure-notice.js +177 -0
- package/src/lib/unconfined-seat-guard.js +94 -0
- package/src/lib/update-check.js +192 -0
- package/src/lib/upstream-refusal.js +1 -0
- package/src/lib/webhook-server.js +109 -0
- package/src/lib/windows-command.js +393 -0
- package/src/lib/workspace-git.js +87 -0
- package/src/lib/workspace-trust.js +162 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ready files for a public seat's MCP servers (F421, F460).
|
|
3
|
+
*
|
|
4
|
+
* The Commonly MCP server reads the seat credential once, at start, and
|
|
5
|
+
* creates the file named in COMMONLY_MCP_READY_FILE on its host's first
|
|
6
|
+
* `tools/list` (commonly-mcp/src/ready-file.js); the grant-broker bridge does
|
|
7
|
+
* the same (codex-broker-bridge.mjs). So once every ready file a spawn named
|
|
8
|
+
* exists, nothing in that spawn still needs the credential file, and
|
|
9
|
+
* `armCredentialRelease` (credential-file.js) deletes it.
|
|
10
|
+
*
|
|
11
|
+
* A seat names files only when the spawn has a ready folder, which the adapter
|
|
12
|
+
* creates for a public seat alone. A public seat whose Commonly server can't
|
|
13
|
+
* be shown to write one is refused: a warm build from before ready files, or
|
|
14
|
+
* our package run from a path outside the host-prepared MCP home (a checkout).
|
|
15
|
+
* Without the file its credential would stay readable to the deadline on every
|
|
16
|
+
* turn, or, named by nothing, be gone before that server read it.
|
|
17
|
+
*/
|
|
18
|
+
import { randomUUID } from 'node:crypto';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { CREDENTIAL_FILE_VAR } from './credential-file.js';
|
|
21
|
+
import { isShippedCommonlyMcpCommand } from './declared-mcp-guard.js';
|
|
22
|
+
import { selectedMcpWritesReadyFile } from './mcp-home.js';
|
|
23
|
+
import { describeMcpCommand } from './mcp-server-version.js';
|
|
24
|
+
|
|
25
|
+
export const MCP_READY_FILE_ENV = 'COMMONLY_MCP_READY_FILE';
|
|
26
|
+
|
|
27
|
+
const nameReadyFile = (name, ctx) => {
|
|
28
|
+
const path = join(ctx.readyFileDir, randomUUID());
|
|
29
|
+
ctx.readyFiles.push({ name, path });
|
|
30
|
+
return path;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const publicMcpOutdatedError = (name) => new Error(
|
|
34
|
+
`public seats need a Space MCP server that reports when it has started, and the one this seat runs for `
|
|
35
|
+
+ `${name} does not (a build from before that, or one run from outside the host-prepared MCP home); `
|
|
36
|
+
+ 'update @commonlyai/mcp',
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
export const publicMcpUnreportedReaderError = (name) => new Error(
|
|
40
|
+
`public seats hand the runtime credential only to the Space MCP server, which reports when it has read it; `
|
|
41
|
+
+ `${name} is another server declaring ${CREDENTIAL_FILE_VAR}, so it would lose the credential before reading it. `
|
|
42
|
+
+ 'Remove that declaration, or run this server on a private seat',
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The ready file for `server` (as declared), run as `spawnCommand` (the warm
|
|
47
|
+
* build it resolved to), or null when this spawn names none for it.
|
|
48
|
+
*/
|
|
49
|
+
export const commonlyMcpReadyFile = (server, spawnCommand, ctx, {
|
|
50
|
+
writesReadyFile = selectedMcpWritesReadyFile,
|
|
51
|
+
describe = describeMcpCommand,
|
|
52
|
+
env = null,
|
|
53
|
+
} = {}) => {
|
|
54
|
+
if (!ctx?.readyFileDir) return null;
|
|
55
|
+
const ours = isShippedCommonlyMcpCommand(server?.command) || Boolean(describe(spawnCommand)?.isCommonly);
|
|
56
|
+
if (!ours) {
|
|
57
|
+
// `env` is the entry's environment after credential delivery: a server that
|
|
58
|
+
// is not ours but was handed the credential path names no ready file, so
|
|
59
|
+
// the release would not wait for it.
|
|
60
|
+
if (env && String(env[CREDENTIAL_FILE_VAR] ?? '').trim()) throw publicMcpUnreportedReaderError(server?.name);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
if (!writesReadyFile(spawnCommand)) throw publicMcpOutdatedError(server.name);
|
|
64
|
+
return nameReadyFile(server.name, ctx);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** The broker bridge ships with this CLI, so it always writes one. */
|
|
68
|
+
export const bridgeReadyFile = (server, ctx) => (ctx?.readyFileDir ? nameReadyFile(server.name, ctx) : null);
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which `@commonlyai/mcp` a declared MCP command would run, and which channels
|
|
3
|
+
* that release understands.
|
|
4
|
+
*
|
|
5
|
+
* Shared by the pi bridge (which decides whether to pipe a credential) and by
|
|
6
|
+
* the claude and codex adapters (which decide whether to hand over a PATH or the
|
|
7
|
+
* value): one predicate, because three copies of "is this old enough" would
|
|
8
|
+
* drift, and the drift would be silent in exactly one adapter.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
export const MCP_PACKAGE = '@commonlyai/mcp';
|
|
14
|
+
|
|
15
|
+
/** The release whose reader accepts the credential on an inherited fd. */
|
|
16
|
+
export const PIPE_READER_VERSION = [0, 3, 11];
|
|
17
|
+
|
|
18
|
+
/** The release whose reader accepts `COMMONLY_TOKEN_FILE` (a PATH, not a secret). */
|
|
19
|
+
export const FILE_READER_VERSION = [0, 3, 12];
|
|
20
|
+
|
|
21
|
+
export const parseVersion = (spec) => {
|
|
22
|
+
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(String(spec || '').trim());
|
|
23
|
+
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The version as a bare triple — or null when there is none to build.
|
|
28
|
+
*
|
|
29
|
+
* `parseVersion` is a PREFIX test, which is right for ordering two versions and
|
|
30
|
+
* wrong for building anything out of one: it accepts `0.3.13/../../../../tmp/x`,
|
|
31
|
+
* so a version read out of a file (`~/.commonly/mcp/.registry.json`, the
|
|
32
|
+
* `current` pointer) or out of `npm view` could become a path or an npm SPEC
|
|
33
|
+
* ARGUMENT that leaves the seat's home. Everything that turns a version into a
|
|
34
|
+
* STRING used outside this module's own comparisons goes through here instead,
|
|
35
|
+
* and the string it builds is the parsed triple, so no suffix can travel.
|
|
36
|
+
*
|
|
37
|
+
* The cost is deliberate and one-directional: a prerelease (`0.3.13-rc.1`)
|
|
38
|
+
* becomes `0.3.13` rather than falling back to `@latest` — the stable release of
|
|
39
|
+
* the same triple, which is a real published version, and strictly better than
|
|
40
|
+
* the shared `@latest` dir this whole change exists to get off. Anchoring the
|
|
41
|
+
* regex at the end instead would reject the prerelease and leave it unpinned.
|
|
42
|
+
*/
|
|
43
|
+
export const exactVersion = (spec) => {
|
|
44
|
+
const triple = parseVersion(spec);
|
|
45
|
+
return triple ? triple.join('.') : null;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* True when `version` is older than `target`; null when there is no version to
|
|
50
|
+
* judge (an unpinned `@latest` tracks the published release, so it is never
|
|
51
|
+
* treated as old).
|
|
52
|
+
*/
|
|
53
|
+
export const versionOlderThan = (version, target) => {
|
|
54
|
+
if (!version) return null;
|
|
55
|
+
for (let i = 0; i < 3; i += 1) {
|
|
56
|
+
if (version[i] !== target[i]) return version[i] < target[i];
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What an `@commonlyai/mcp` command would run, or null when the command cannot
|
|
63
|
+
* be identified as that package at all.
|
|
64
|
+
*
|
|
65
|
+
* Two shapes matter: `npx [-y] @commonlyai/mcp@<spec>` (a spec is a version, or
|
|
66
|
+
* `latest`/absent, which resolves to whatever is published — never treated as
|
|
67
|
+
* old), and a local checkout, `node <path>/src/index.js`, which is what the
|
|
68
|
+
* staging seats run; for that one the package.json beside it is the only honest
|
|
69
|
+
* answer, and a package.json naming something else means this is not our server.
|
|
70
|
+
*
|
|
71
|
+
* `{ isCommonly: true, version: null }` means "our server, version unknown" —
|
|
72
|
+
* an unpinned npx spec, whose whole point is that it tracks the published one.
|
|
73
|
+
* `null` as the return value means "not identifiable as our server", which is a
|
|
74
|
+
* different answer and takes a different branch: a stranger's server gets its
|
|
75
|
+
* declaration honoured unchanged.
|
|
76
|
+
*
|
|
77
|
+
* The ENTRY SCRIPT is checked first, and that ordering is load-bearing since
|
|
78
|
+
* TASK-174. A seat home runs `node <home>/<version>/node_modules/@commonlyai/mcp/<bin>`,
|
|
79
|
+
* and that path CONTAINS the package name — so a spec-first order matched the
|
|
80
|
+
* path, took the `@` of `@commonlyai/mcp` for the version separator, and returned
|
|
81
|
+
* `{ version: null }` ("unpinned, so never old") for every home build. The
|
|
82
|
+
* version then decided nothing: measured, a home holding 0.3.7 was handed the
|
|
83
|
+
* credential FILE channel, whose reader does not exist before 0.3.12. Nor is
|
|
84
|
+
* "a spec has no slash" a usable rule — a SCOPED spec is `@commonlyai/mcp`.
|
|
85
|
+
*/
|
|
86
|
+
export const describeMcpCommand = (command, {
|
|
87
|
+
readTextFile = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : null),
|
|
88
|
+
} = {}) => {
|
|
89
|
+
if (!Array.isArray(command) || command.length === 0) return null;
|
|
90
|
+
const parts = command.map(String);
|
|
91
|
+
const scriptPath = parts.find((p) => p.endsWith('.js') || p.endsWith('.mjs'));
|
|
92
|
+
if (scriptPath) {
|
|
93
|
+
// `src/index.js` → `../package.json`; also try one level further up, because
|
|
94
|
+
// a bin shim can live in `bin/` beside `src/`.
|
|
95
|
+
for (const candidate of [join(dirname(scriptPath), '..', 'package.json'), join(dirname(scriptPath), 'package.json')]) {
|
|
96
|
+
let raw;
|
|
97
|
+
try {
|
|
98
|
+
raw = readTextFile(candidate);
|
|
99
|
+
} catch {
|
|
100
|
+
raw = null;
|
|
101
|
+
}
|
|
102
|
+
if (!raw) continue;
|
|
103
|
+
try {
|
|
104
|
+
const pkg = JSON.parse(raw);
|
|
105
|
+
if (!pkg || typeof pkg !== 'object') continue;
|
|
106
|
+
if (pkg.name === MCP_PACKAGE) return { isCommonly: true, version: parseVersion(pkg.version) };
|
|
107
|
+
// A package.json that names another package settles it: not ours, so its
|
|
108
|
+
// declaration is none of this function's business.
|
|
109
|
+
return null;
|
|
110
|
+
} catch {
|
|
111
|
+
// A malformed package.json is not an answer; keep looking.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
const pkgArg = parts.find((p) => p.includes(MCP_PACKAGE));
|
|
117
|
+
if (pkgArg) {
|
|
118
|
+
const at = pkgArg.lastIndexOf('@');
|
|
119
|
+
if (at <= pkgArg.indexOf(MCP_PACKAGE)) return { isCommonly: true, version: null };
|
|
120
|
+
return { isCommonly: true, version: parseVersion(pkgArg.slice(at + 1)) };
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The 0600 copy of an agent's runtime token that `commonly mcp config` points
|
|
3
|
+
* COMMONLY_TOKEN_FILE at (TASK-666). It is a second live secret, so the agent
|
|
4
|
+
* token's lifecycle owns it: save refreshes it, delete removes it.
|
|
5
|
+
*/
|
|
6
|
+
import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'fs';
|
|
7
|
+
import { homedir } from 'os';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
|
|
10
|
+
export const validMcpTokenName = (name) => typeof name === 'string' && /^[A-Za-z0-9._-]+$/.test(name) && !name.startsWith('.');
|
|
11
|
+
|
|
12
|
+
export const mcpTokenPath = (name) => {
|
|
13
|
+
if (!validMcpTokenName(name)) throw new Error(`Invalid agent name '${name}'`);
|
|
14
|
+
return join(homedir(), '.commonly', 'mcp', `${name}.token`);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Remove first, then create exclusively at 0600: no write into a looser-mode file or through a symlink.
|
|
18
|
+
export const writeMcpTokenCopy = (name, token) => {
|
|
19
|
+
const dir = join(homedir(), '.commonly', 'mcp');
|
|
20
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
21
|
+
chmodSync(dir, 0o700);
|
|
22
|
+
const path = mcpTokenPath(name);
|
|
23
|
+
rmSync(path, { force: true });
|
|
24
|
+
writeFileSync(path, `${token}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
25
|
+
return path;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// A bad name never has a copy, so remove and refresh are no-ops for it and cannot block detach or save.
|
|
29
|
+
export const removeMcpTokenCopy = (name) => {
|
|
30
|
+
if (validMcpTokenName(name)) rmSync(mcpTokenPath(name), { force: true });
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Reattach mints a new token; a copy that already exists must follow it.
|
|
34
|
+
export const refreshMcpTokenCopy = (name, token) => {
|
|
35
|
+
if (token && validMcpTokenName(name) && existsSync(mcpTokenPath(name))) writeMcpTokenCopy(name, token);
|
|
36
|
+
};
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The background warm for the seat MCP home — see mcp-home.js for why.
|
|
3
|
+
*
|
|
4
|
+
* This runs as a DETACHED child, never on a spawn path, because everything it
|
|
5
|
+
* does is slow or networked: a registry lookup, an `npm install`, and an MCP
|
|
6
|
+
* `initialize` handshake against the freshly installed bin. The spawn path only
|
|
7
|
+
* reads a pointer file and exec's `node <bin>`.
|
|
8
|
+
*
|
|
9
|
+
* Order of operations is the contract: install into `.tmp-<version>-<pid>`,
|
|
10
|
+
* PROBE the bin inside that temp dir, and only then `rename` it into
|
|
11
|
+
* `<home>/<version>` and move the pointer. So a version dir in the home is a
|
|
12
|
+
* version that answered the protocol at least once, and a half-installed or
|
|
13
|
+
* non-starting build can never become what seats execute.
|
|
14
|
+
*
|
|
15
|
+
* Single-flight by lock file (O_EXCL), so concurrent spawns on a fresh host
|
|
16
|
+
* trigger exactly one install. A lock older than STALE_LOCK_MS is a warm that
|
|
17
|
+
* died (a laptop sleeping mid-install is the normal way) and is reclaimed rather
|
|
18
|
+
* than allowed to wedge the home forever.
|
|
19
|
+
*
|
|
20
|
+
* The probe's env is measured, not assumed: at 0.3.13 the bin exits 1 with
|
|
21
|
+
* "[commonly-mcp] fatal: COMMONLY_API_URL is required" and, with that set, exits
|
|
22
|
+
* 1 again on "No runtime token". With both present as PLACEHOLDERS it answers
|
|
23
|
+
* `initialize` with a result — the boot check is presence-only and `initialize`
|
|
24
|
+
* does not touch the network (both measured on this host 2026-09-27), which is
|
|
25
|
+
* what makes a credential-free probe honest rather than a stub.
|
|
26
|
+
*/
|
|
27
|
+
import { spawn as spawnChild } from 'node:child_process';
|
|
28
|
+
import {
|
|
29
|
+
closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync,
|
|
30
|
+
} from 'node:fs';
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
import {
|
|
33
|
+
KEEP_VERSION_DIRS, PRUNE_GRACE_MS, REGISTRY_CACHE_NAME, REGISTRY_TTL_MS, STALE_LOCK_MS,
|
|
34
|
+
WARM_RESULTS, currentPointerPath, isUsableWarmHome, liveInUsePids, lockPathFor,
|
|
35
|
+
readBinInPrefix, versionDirFor,
|
|
36
|
+
} from './mcp-home.js';
|
|
37
|
+
import { MCP_PACKAGE, exactVersion, parseVersion } from './mcp-server-version.js';
|
|
38
|
+
import { resolveLaunch } from './windows-command.js';
|
|
39
|
+
|
|
40
|
+
export const PROBE_API_URL_PLACEHOLDER = 'http://127.0.0.1:1';
|
|
41
|
+
export const PROBE_TOKEN_PLACEHOLDER = 'cm_agent_warm_probe';
|
|
42
|
+
export const PROBE_TIMEOUT_MS = 10 * 1000;
|
|
43
|
+
export const REGISTRY_TIMEOUT_MS = 20 * 1000;
|
|
44
|
+
|
|
45
|
+
const INITIALIZE_REQUEST = JSON.stringify({
|
|
46
|
+
jsonrpc: '2.0',
|
|
47
|
+
id: 1,
|
|
48
|
+
method: 'initialize',
|
|
49
|
+
params: {
|
|
50
|
+
protocolVersion: '2024-11-05',
|
|
51
|
+
capabilities: {},
|
|
52
|
+
clientInfo: { name: 'commonly-cli-warm', version: '1' },
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const exists = (path) => {
|
|
57
|
+
try {
|
|
58
|
+
return existsSync(path);
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const readIfAny = (path) => {
|
|
65
|
+
try {
|
|
66
|
+
return existsSync(path) ? readFileSync(path, 'utf8') : null;
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Take the warm lock, reclaiming one that is too old to belong to a live warm.
|
|
74
|
+
*
|
|
75
|
+
* Returns the open fd, or null when another process holds a fresh lock.
|
|
76
|
+
*/
|
|
77
|
+
export const acquireWarmLock = (home, {
|
|
78
|
+
now = Date.now(),
|
|
79
|
+
open = (path) => openSync(path, 'wx'),
|
|
80
|
+
stat = (path) => statSync(path),
|
|
81
|
+
remove = (path) => unlinkSync(path),
|
|
82
|
+
staleMs = STALE_LOCK_MS,
|
|
83
|
+
} = {}) => {
|
|
84
|
+
const lockPath = lockPathFor(home);
|
|
85
|
+
try {
|
|
86
|
+
return open(lockPath);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (error && error.code !== 'EEXIST') return null;
|
|
89
|
+
}
|
|
90
|
+
let age;
|
|
91
|
+
try {
|
|
92
|
+
age = now - stat(lockPath).mtimeMs;
|
|
93
|
+
} catch {
|
|
94
|
+
return null; // vanished between EEXIST and stat: not ours to break
|
|
95
|
+
}
|
|
96
|
+
if (age <= staleMs) return null;
|
|
97
|
+
try {
|
|
98
|
+
remove(lockPath);
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
return open(lockPath);
|
|
104
|
+
} catch {
|
|
105
|
+
// Lost the reclaim race to another spawn's warm. Fine: that one is working.
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Default `npm` runner. Resolves `{ ok, stdout }`; never rejects. On Windows
|
|
112
|
+
* `npm` is npm.cmd, which Node cannot spawn without a shell, so the name goes
|
|
113
|
+
* through the resolver the adapters use (npm.cmd becomes node.exe npm-cli.js).
|
|
114
|
+
* Off Windows it is spawned as given. `resolution` is the resolver's test seam.
|
|
115
|
+
*/
|
|
116
|
+
export const runCommand = (bin, args, {
|
|
117
|
+
timeout = REGISTRY_TIMEOUT_MS, cwd, resolution,
|
|
118
|
+
} = {}) => new Promise((resolve) => {
|
|
119
|
+
let settled = false;
|
|
120
|
+
let child;
|
|
121
|
+
const done = (result) => {
|
|
122
|
+
if (settled) return;
|
|
123
|
+
settled = true;
|
|
124
|
+
resolve(result);
|
|
125
|
+
};
|
|
126
|
+
try {
|
|
127
|
+
// The child runs in `cwd`, so a relative PATH entry is read there.
|
|
128
|
+
const launch = resolveLaunch(bin, args, { cwd, ...resolution });
|
|
129
|
+
child = spawnChild(launch.command, launch.args, { stdio: ['ignore', 'pipe', 'pipe'], env: process.env, cwd });
|
|
130
|
+
} catch (error) {
|
|
131
|
+
done({ ok: false, stdout: '', stderr: String(error && error.message) });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
let stdout = '';
|
|
135
|
+
let stderr = '';
|
|
136
|
+
child.stdout.on('data', (d) => { stdout += String(d); });
|
|
137
|
+
child.stderr.on('data', (d) => { stderr += String(d); });
|
|
138
|
+
const timer = setTimeout(() => {
|
|
139
|
+
try {
|
|
140
|
+
child.kill('SIGKILL');
|
|
141
|
+
} catch { /* already gone */ }
|
|
142
|
+
done({ ok: false, stdout, stderr: `${stderr}\n[commonly] timed out after ${timeout}ms` });
|
|
143
|
+
}, timeout);
|
|
144
|
+
child.on('error', (error) => { clearTimeout(timer); done({ ok: false, stdout, stderr: String(error && error.message) }); });
|
|
145
|
+
child.on('close', (code) => { clearTimeout(timer); done({ ok: code === 0, code, stdout, stderr }); });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
/** The registry's current version for the package, cached on disk with a TTL. */
|
|
149
|
+
export const resolveRegistryLatest = async (home, {
|
|
150
|
+
now = Date.now(),
|
|
151
|
+
exec = runCommand,
|
|
152
|
+
ttlMs = REGISTRY_TTL_MS,
|
|
153
|
+
writeFile = (path, text) => writeFileSync(path, text),
|
|
154
|
+
} = {}) => {
|
|
155
|
+
const cachePath = join(home, REGISTRY_CACHE_NAME);
|
|
156
|
+
const cached = readIfAny(cachePath);
|
|
157
|
+
if (cached) {
|
|
158
|
+
try {
|
|
159
|
+
const parsed = JSON.parse(cached);
|
|
160
|
+
const cachedVersion = parsed ? exactVersion(parsed.version) : null;
|
|
161
|
+
if (cachedVersion && Number.isFinite(parsed.checkedAt) && now - parsed.checkedAt < ttlMs) {
|
|
162
|
+
return { version: cachedVersion, source: 'cache' };
|
|
163
|
+
}
|
|
164
|
+
} catch { /* unreadable cache is just a miss */ }
|
|
165
|
+
}
|
|
166
|
+
const result = await exec('npm', ['view', MCP_PACKAGE, 'version', '--json'], {
|
|
167
|
+
timeout: REGISTRY_TIMEOUT_MS, cwd: home,
|
|
168
|
+
});
|
|
169
|
+
const raw = result && result.ok ? String(result.stdout || '').trim() : '';
|
|
170
|
+
let version = null;
|
|
171
|
+
if (raw) {
|
|
172
|
+
try {
|
|
173
|
+
const parsed = JSON.parse(raw);
|
|
174
|
+
version = typeof parsed === 'string' ? parsed : null;
|
|
175
|
+
} catch {
|
|
176
|
+
version = raw.replace(/^"|"$/g, '');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// This answer is both persisted and, downstream, an argv entry
|
|
180
|
+
// (`npm install <pkg>@<version>`) and a path (`<home>/<version>`, the temp
|
|
181
|
+
// prefix). Rebuild it as a triple here, at the one place it enters, so the
|
|
182
|
+
// rest of the warm can keep treating a version as a name.
|
|
183
|
+
const safe = exactVersion(version);
|
|
184
|
+
if (safe) {
|
|
185
|
+
try {
|
|
186
|
+
writeFile(cachePath, JSON.stringify({ version: safe, checkedAt: now }));
|
|
187
|
+
} catch { /* a cache we cannot write is not a warm we should fail */ }
|
|
188
|
+
return { version: safe, source: 'registry' };
|
|
189
|
+
}
|
|
190
|
+
// Registry down: the last known answer is better than none, and better than
|
|
191
|
+
// moving the pointer backwards.
|
|
192
|
+
if (cached) {
|
|
193
|
+
try {
|
|
194
|
+
const parsed = JSON.parse(cached);
|
|
195
|
+
const stale = parsed ? exactVersion(parsed.version) : null;
|
|
196
|
+
if (stale) return { version: stale, source: 'stale-cache' };
|
|
197
|
+
} catch { /* fall through */ }
|
|
198
|
+
}
|
|
199
|
+
return { version: null, source: 'unavailable' };
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Does the installed bin start and speak the protocol?
|
|
204
|
+
*
|
|
205
|
+
* Accepts a JSON-RPC response to id 1 whether it is a `result` or an `error`:
|
|
206
|
+
* what this proves is that the build runs and answers `initialize`, which is
|
|
207
|
+
* exactly what the pointer's contract claims. It is not an authorization check —
|
|
208
|
+
* the seat's own token is what makes calls work.
|
|
209
|
+
*/
|
|
210
|
+
export const probeBin = (binPath, {
|
|
211
|
+
spawnImpl = spawnChild,
|
|
212
|
+
timeoutMs = PROBE_TIMEOUT_MS,
|
|
213
|
+
apiUrl = process.env.COMMONLY_WARM_API_URL || PROBE_API_URL_PLACEHOLDER,
|
|
214
|
+
cwd,
|
|
215
|
+
} = {}) => new Promise((resolve) => {
|
|
216
|
+
let settled = false;
|
|
217
|
+
let child;
|
|
218
|
+
const done = (ok) => {
|
|
219
|
+
if (settled) return;
|
|
220
|
+
settled = true;
|
|
221
|
+
try {
|
|
222
|
+
if (child) child.kill('SIGKILL');
|
|
223
|
+
} catch { /* already gone */ }
|
|
224
|
+
resolve(ok);
|
|
225
|
+
};
|
|
226
|
+
try {
|
|
227
|
+
child = spawnImpl(process.execPath, [binPath], {
|
|
228
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
229
|
+
cwd,
|
|
230
|
+
env: {
|
|
231
|
+
PATH: process.env.PATH,
|
|
232
|
+
HOME: process.env.HOME,
|
|
233
|
+
COMMONLY_API_URL: String(apiUrl),
|
|
234
|
+
COMMONLY_AGENT_TOKEN: PROBE_TOKEN_PLACEHOLDER,
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
} catch {
|
|
238
|
+
resolve(false);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const timer = setTimeout(() => done(false), timeoutMs);
|
|
242
|
+
let seen = '';
|
|
243
|
+
const onData = (chunk) => {
|
|
244
|
+
seen += String(chunk);
|
|
245
|
+
if (/"id"\s*:\s*1/.test(seen)) {
|
|
246
|
+
clearTimeout(timer);
|
|
247
|
+
done(true);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
child.stdout.on('data', onData);
|
|
251
|
+
child.on('error', () => { clearTimeout(timer); done(false); });
|
|
252
|
+
child.on('close', () => { clearTimeout(timer); done(false); });
|
|
253
|
+
try {
|
|
254
|
+
child.stdin.write(`${INITIALIZE_REQUEST}\n`);
|
|
255
|
+
} catch {
|
|
256
|
+
clearTimeout(timer);
|
|
257
|
+
done(false);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const writePointerAtomic = (home, version, { writeTemp = writeFileSync } = {}) => {
|
|
262
|
+
const target = currentPointerPath(home);
|
|
263
|
+
const temp = join(home, `.current-${process.pid}`);
|
|
264
|
+
writeTemp(temp, `${version}\n`);
|
|
265
|
+
renameSync(temp, target);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
/** Keep the newest N version dirs; the current one is never a candidate. */
|
|
269
|
+
export const pruneVersionDirs = (home, current, {
|
|
270
|
+
keep = KEEP_VERSION_DIRS,
|
|
271
|
+
grace = PRUNE_GRACE_MS,
|
|
272
|
+
now = Date.now(),
|
|
273
|
+
readDir = (dir) => {
|
|
274
|
+
try {
|
|
275
|
+
return readdirSync(dir);
|
|
276
|
+
} catch {
|
|
277
|
+
return [];
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
remove = (path) => rmSync(path, { recursive: true, force: true }),
|
|
281
|
+
mtime = (path) => {
|
|
282
|
+
try {
|
|
283
|
+
return statSync(path).mtimeMs;
|
|
284
|
+
} catch {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
livePids = (version) => liveInUsePids(home, version),
|
|
289
|
+
} = {}) => {
|
|
290
|
+
// Exact triples only, for the same reason `newestInstalledVersion` uses them:
|
|
291
|
+
// a dir named `0.3.13-rc.1` is not the dir `0.3.13`, and this loop REMOVES what
|
|
292
|
+
// it names.
|
|
293
|
+
const versions = readDir(home).filter((name) => exactVersion(name) === name);
|
|
294
|
+
versions.sort((a, b) => {
|
|
295
|
+
const [am, an, ap] = parseVersion(a);
|
|
296
|
+
const [bm, bn, bp] = parseVersion(b);
|
|
297
|
+
return (bm - am) || (bn - an) || (bp - ap);
|
|
298
|
+
});
|
|
299
|
+
const removed = [];
|
|
300
|
+
versions.slice(keep).forEach((name) => {
|
|
301
|
+
if (name === current) return;
|
|
302
|
+
// A live claimant outranks the count: this dir may be executing right now.
|
|
303
|
+
if (livePids(name).length > 0) return;
|
|
304
|
+
// And time outranks the count too — a dir inside the grace window may belong
|
|
305
|
+
// to a spawn that has not written its claim yet. An unreadable/absent mtime
|
|
306
|
+
// is not evidence of age, so it is left alone.
|
|
307
|
+
const installedAt = mtime(versionDirFor(home, name));
|
|
308
|
+
if (typeof installedAt !== 'number' || now - installedAt < grace) return;
|
|
309
|
+
try {
|
|
310
|
+
remove(versionDirFor(home, name));
|
|
311
|
+
removed.push(name);
|
|
312
|
+
} catch { /* a dir we cannot remove is not a failed warm */ }
|
|
313
|
+
});
|
|
314
|
+
return removed;
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The whole warm, in one function so a test can drive every branch with injected
|
|
319
|
+
* I/O. Returns one of WARM_RESULTS — never throws, because it runs unattended.
|
|
320
|
+
*/
|
|
321
|
+
export const warmMcpHome = async (home, {
|
|
322
|
+
now = Date.now(),
|
|
323
|
+
exec = runCommand,
|
|
324
|
+
probe = probeBin,
|
|
325
|
+
spawnProbe,
|
|
326
|
+
lock = () => acquireWarmLock(home, { now }),
|
|
327
|
+
closeLock = (fd) => closeSync(fd),
|
|
328
|
+
mkdir = (dir) => mkdirSync(dir, { recursive: true }),
|
|
329
|
+
remove = (path) => rmSync(path, { recursive: true, force: true }),
|
|
330
|
+
rename = (from, to) => renameSync(from, to),
|
|
331
|
+
readBin = (prefix) => readBinInPrefix(prefix),
|
|
332
|
+
readCurrent = (h) => {
|
|
333
|
+
const raw = readIfAny(currentPointerPath(h));
|
|
334
|
+
if (typeof raw !== 'string') return null;
|
|
335
|
+
return exactVersion(raw.trim()) || null;
|
|
336
|
+
},
|
|
337
|
+
keep = KEEP_VERSION_DIRS,
|
|
338
|
+
} = {}) => {
|
|
339
|
+
mkdir(home);
|
|
340
|
+
const fd = lock();
|
|
341
|
+
if (fd === null) return WARM_RESULTS.LOCKED;
|
|
342
|
+
try {
|
|
343
|
+
const latest = await resolveRegistryLatest(home, { now, exec });
|
|
344
|
+
if (!latest.version) return WARM_RESULTS.REGISTRY_UNAVAILABLE;
|
|
345
|
+
const target = latest.version;
|
|
346
|
+
const current = readCurrent(home);
|
|
347
|
+
if (current && current === target) return WARM_RESULTS.CURRENT;
|
|
348
|
+
if (current && parseVersion(current) && parseVersion(target)) {
|
|
349
|
+
const [tm, tn, tp] = parseVersion(target);
|
|
350
|
+
const [cm, cn, cp] = parseVersion(current);
|
|
351
|
+
if ((tm < cm) || (tm === cm && tn < cn) || (tm === cm && tn === cn && tp <= cp)) {
|
|
352
|
+
return WARM_RESULTS.CURRENT;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const temp = join(home, `.tmp-${target}-${process.pid}`);
|
|
356
|
+
remove(temp);
|
|
357
|
+
mkdir(temp);
|
|
358
|
+
const install = await exec('npm', [
|
|
359
|
+
'install', '--prefix', temp, `${MCP_PACKAGE}@${target}`, '--no-save', '--no-audit', '--no-fund',
|
|
360
|
+
], { timeout: 5 * 60 * 1000, cwd: home });
|
|
361
|
+
if (!install || !install.ok) {
|
|
362
|
+
remove(temp);
|
|
363
|
+
return WARM_RESULTS.INSTALL_FAILED;
|
|
364
|
+
}
|
|
365
|
+
const bin = readBin(temp);
|
|
366
|
+
if (!bin) {
|
|
367
|
+
remove(temp);
|
|
368
|
+
return WARM_RESULTS.INSTALL_FAILED;
|
|
369
|
+
}
|
|
370
|
+
const started = await (spawnProbe || probe)(bin, {
|
|
371
|
+
apiUrl: process.env.COMMONLY_WARM_API_URL, cwd: home,
|
|
372
|
+
});
|
|
373
|
+
if (!started) {
|
|
374
|
+
remove(temp);
|
|
375
|
+
return WARM_RESULTS.PROBE_FAILED;
|
|
376
|
+
}
|
|
377
|
+
const destination = versionDirFor(home, target);
|
|
378
|
+
if (exists(destination)) remove(temp);
|
|
379
|
+
else rename(temp, destination);
|
|
380
|
+
writePointerAtomic(home, target);
|
|
381
|
+
pruneVersionDirs(home, target, { keep });
|
|
382
|
+
return WARM_RESULTS.ADVANCED;
|
|
383
|
+
} catch {
|
|
384
|
+
return WARM_RESULTS.INSTALL_FAILED;
|
|
385
|
+
} finally {
|
|
386
|
+
try {
|
|
387
|
+
closeLock(fd);
|
|
388
|
+
} catch { /* nothing to close */ }
|
|
389
|
+
try {
|
|
390
|
+
rmSync(lockPathFor(home), { force: true });
|
|
391
|
+
} catch { /* lock is best-effort cleanup */ }
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const invokedDirectly = process.argv[1] && /mcp-warm-child\.mjs$/.test(process.argv[1]);
|
|
396
|
+
if (invokedDirectly) {
|
|
397
|
+
// Validated, not trusted: argv entries are coerced with String(), so a parent
|
|
398
|
+
// that passed an undefined home sends the literal 'undefined' and this child
|
|
399
|
+
// would otherwise create and populate a directory by that name (which is how
|
|
400
|
+
// one appeared inside the package directory on 2026-09-27).
|
|
401
|
+
const home = process.argv[2];
|
|
402
|
+
if (isUsableWarmHome(home)) {
|
|
403
|
+
warmMcpHome(home, { now: Date.now() })
|
|
404
|
+
.then((result) => {
|
|
405
|
+
// A warm runs with stdio ignored; this is for a human running it by hand
|
|
406
|
+
// and for `--verbose` debugging, and it must never be on a spawn path.
|
|
407
|
+
if (process.env.COMMONLY_WARM_VERBOSE) process.stdout.write(`${result}\n`);
|
|
408
|
+
process.exitCode = 0;
|
|
409
|
+
})
|
|
410
|
+
.catch(() => { process.exitCode = 0; });
|
|
411
|
+
}
|
|
412
|
+
}
|