@ravefox/space 0.0.0-stage → 0.2.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 +203 -0
- package/NOTICE +44 -0
- package/README.md +49 -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 +56 -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,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event poller — polls GET /api/agents/runtime/events and forwards
|
|
3
|
+
* each event to a local handler (webhook server or callback).
|
|
4
|
+
*
|
|
5
|
+
* Used by `commonly agent connect` when running against any instance,
|
|
6
|
+
* since localhost webhooks can't receive inbound calls from remote GKE.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createClient } from './api.js';
|
|
10
|
+
import { formatDigestFrame } from './digest-frame.js';
|
|
11
|
+
import { EMPTY_OUTPUT } from './claim-outcome.js';
|
|
12
|
+
import { createPollLiveness, formatStall, resolvePollLivenessMs } from './poll-liveness.js';
|
|
13
|
+
import { CLI } from './cli-hint.js';
|
|
14
|
+
|
|
15
|
+
// Delivery failures that must stop a consumer rather than fall through to the
|
|
16
|
+
// normal at-least-once retry path. A stale delivery belongs to a replacement
|
|
17
|
+
// runner; a missing required nonce means this runner is misconfigured. Neither
|
|
18
|
+
// can become healthy by immediately fetching and retrying more work.
|
|
19
|
+
export const terminalDeliveryAckError = (ackErr, eventId, runner = 'poller') => {
|
|
20
|
+
if (ackErr?.status === 409 && ackErr?.body?.code === 'stale_delivery') {
|
|
21
|
+
return Object.assign(
|
|
22
|
+
new Error(`Delivery ${eventId} was superseded — stopping ${runner}.`),
|
|
23
|
+
{ code: 'stale_delivery' },
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
if (ackErr?.status === 400 && ackErr?.body?.code === 'delivery_id_required') {
|
|
27
|
+
return Object.assign(
|
|
28
|
+
new Error(`Delivery ${eventId} requires deliveryId — stopping ${runner}; update its configuration.`),
|
|
29
|
+
{ code: 'delivery_id_required' },
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const startPoller = ({
|
|
36
|
+
instanceUrl,
|
|
37
|
+
token,
|
|
38
|
+
agentName,
|
|
39
|
+
instanceId = 'default',
|
|
40
|
+
intervalMs = 5000,
|
|
41
|
+
loginInstance, // the --instance the connect ran with, so the login hint names it
|
|
42
|
+
onEvent, // async (event) => { outcome, content? }
|
|
43
|
+
onError, // (err) => void
|
|
44
|
+
pollLivenessMs, // TASK-338: see lib/poll-liveness.js
|
|
45
|
+
pollLivenessCheckMs,
|
|
46
|
+
onPollStalled, // ({ stalledMs }) => void; the connect command exits on it
|
|
47
|
+
}) => {
|
|
48
|
+
const client = createClient({ instance: instanceUrl, token });
|
|
49
|
+
let running = true;
|
|
50
|
+
let consecutiveErrors = 0;
|
|
51
|
+
// Stop-after-N-auth-failures: without this, a revoked token leaves the
|
|
52
|
+
// poller hammering 401s forever at 60s backoff, invisible to the user.
|
|
53
|
+
// 3 is deliberate — 1 would churn on a token-rotation race during
|
|
54
|
+
// reprovision-all; 5+ wastes rate-limit budget after the real-revoke case.
|
|
55
|
+
let consecutiveAuthErrors = 0;
|
|
56
|
+
const MAX_AUTH_ERRORS = 3;
|
|
57
|
+
let pollStalled = false;
|
|
58
|
+
const pollLiveness = createPollLiveness({
|
|
59
|
+
limitMs: resolvePollLivenessMs(pollLivenessMs),
|
|
60
|
+
checkEveryMs: pollLivenessCheckMs,
|
|
61
|
+
onStall: ({ stalledMs }) => {
|
|
62
|
+
if (!running) return;
|
|
63
|
+
pollStalled = true;
|
|
64
|
+
running = false;
|
|
65
|
+
pollLiveness.stop();
|
|
66
|
+
onError?.(new Error(`poll stalled: no inbox poll settled for ${formatStall(stalledMs)} — stopping poller`));
|
|
67
|
+
onPollStalled?.({ stalledMs });
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const poll = async () => {
|
|
72
|
+
if (!running) return;
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
// Same clock as `agent run`: running from here to the scheduled wait,
|
|
76
|
+
// paused while the local handler takes the event.
|
|
77
|
+
pollLiveness.arm();
|
|
78
|
+
const page = await client.get('/api/agents/runtime/events', {
|
|
79
|
+
agentName,
|
|
80
|
+
instanceId,
|
|
81
|
+
limit: 10,
|
|
82
|
+
});
|
|
83
|
+
pollLiveness.arm();
|
|
84
|
+
if (pollStalled) return;
|
|
85
|
+
const { events = [] } = page;
|
|
86
|
+
|
|
87
|
+
// A digest is received context, not a webhook invocation. The runtime
|
|
88
|
+
// inbox claims it only alongside a wake from the same pod.
|
|
89
|
+
const wakeEvents = events.filter((event) => event.deliveryMode !== 'digest');
|
|
90
|
+
const digestEvents = events.filter((event) => event.deliveryMode === 'digest'
|
|
91
|
+
&& wakeEvents[0] && String(event.podId) === String(wakeEvents[0].podId));
|
|
92
|
+
const rollup = wakeEvents[0]?.payload?.digestRollup;
|
|
93
|
+
const frame = formatDigestFrame(digestEvents, rollup);
|
|
94
|
+
let digestConsumed = false;
|
|
95
|
+
for (const [index, event] of wakeEvents.entries()) {
|
|
96
|
+
const attachedDigest = index === 0 && frame !== null;
|
|
97
|
+
const forwarded = attachedDigest ? {
|
|
98
|
+
...event,
|
|
99
|
+
payload: {
|
|
100
|
+
...(event.payload || {}),
|
|
101
|
+
digestSummary: frame,
|
|
102
|
+
...(typeof event.payload?.content === 'string' ? {
|
|
103
|
+
content: `${event.payload.content}\n\n${frame}`,
|
|
104
|
+
} : {}),
|
|
105
|
+
},
|
|
106
|
+
} : event;
|
|
107
|
+
let result = { outcome: 'no_action' };
|
|
108
|
+
try {
|
|
109
|
+
// A handler that returns nothing said nothing: not a read of the digest.
|
|
110
|
+
pollLiveness.pause();
|
|
111
|
+
result = (await onEvent(forwarded)) || { outcome: 'no_action', reason: EMPTY_OUTPUT };
|
|
112
|
+
} catch (handlerErr) {
|
|
113
|
+
result = { outcome: 'error', reason: handlerErr.message };
|
|
114
|
+
} finally {
|
|
115
|
+
pollLiveness.arm();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Acknowledge the event
|
|
119
|
+
try {
|
|
120
|
+
const deliveryId = event.payload?.deliveryId;
|
|
121
|
+
await client.post(`/api/agents/runtime/events/${event._id}/ack`, {
|
|
122
|
+
result,
|
|
123
|
+
...(typeof deliveryId === 'string' && deliveryId ? { deliveryId } : {}),
|
|
124
|
+
});
|
|
125
|
+
if (attachedDigest && (result.outcome === 'posted'
|
|
126
|
+
|| result.outcome === 'acknowledged'
|
|
127
|
+
|| (result.outcome === 'no_action' && !result.reason))) {
|
|
128
|
+
digestConsumed = true;
|
|
129
|
+
}
|
|
130
|
+
} catch (ackErr) {
|
|
131
|
+
const terminalError = terminalDeliveryAckError(ackErr, event._id);
|
|
132
|
+
if (terminalError) {
|
|
133
|
+
running = false;
|
|
134
|
+
onError?.(terminalError);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
// Non-fatal — event will be retried
|
|
138
|
+
onError?.(new Error(`Ack failed for ${event._id}: ${ackErr.message}`));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (digestConsumed) {
|
|
143
|
+
for (const event of digestEvents) {
|
|
144
|
+
try {
|
|
145
|
+
const deliveryId = event.payload?.deliveryId;
|
|
146
|
+
await client.post(`/api/agents/runtime/events/${event._id}/ack`, {
|
|
147
|
+
result: { outcome: 'no_action', reason: 'digest-read' },
|
|
148
|
+
...(typeof deliveryId === 'string' && deliveryId ? { deliveryId } : {}),
|
|
149
|
+
});
|
|
150
|
+
} catch (ackErr) {
|
|
151
|
+
const terminalError = terminalDeliveryAckError(ackErr, event._id);
|
|
152
|
+
if (terminalError) {
|
|
153
|
+
running = false;
|
|
154
|
+
onError?.(terminalError);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
onError?.(new Error(`Ack failed for ${event._id}: ${ackErr.message}`));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
consecutiveErrors = 0;
|
|
163
|
+
consecutiveAuthErrors = 0;
|
|
164
|
+
} catch (err) {
|
|
165
|
+
if (err?.status === 401 || err?.status === 403) {
|
|
166
|
+
consecutiveAuthErrors += 1;
|
|
167
|
+
if (consecutiveAuthErrors >= MAX_AUTH_ERRORS) {
|
|
168
|
+
onError?.(new Error(
|
|
169
|
+
`Runtime token rejected ${consecutiveAuthErrors} times in a row — stopping poller. `
|
|
170
|
+
+ `This connection takes its key from --token, then COMMONLY_TOKEN, then your saved login. `
|
|
171
|
+
+ `If the key was rotated or revoked, re-run with the new key in --token or COMMONLY_TOKEN; `
|
|
172
|
+
+ `with neither set, run ${CLI} login${loginInstance ? ` --instance ${loginInstance}` : ''}.`,
|
|
173
|
+
));
|
|
174
|
+
running = false;
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
consecutiveErrors++;
|
|
179
|
+
onError?.(err);
|
|
180
|
+
// Back off on repeated errors (max 60s). A wait this loop scheduled
|
|
181
|
+
// itself always ends, so the liveness clock stops for it.
|
|
182
|
+
const backoff = Math.min(intervalMs * consecutiveErrors, 60_000);
|
|
183
|
+
pollLiveness.pause();
|
|
184
|
+
await new Promise((r) => setTimeout(r, backoff));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
pollLiveness.pause();
|
|
188
|
+
if (running) setTimeout(poll, intervalMs);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// Start immediately
|
|
192
|
+
poll();
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
stop: () => {
|
|
196
|
+
running = false;
|
|
197
|
+
pollLiveness.stop();
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { resolveProviderKeyFile as resolveKeyFileShared } from '@commonlyai/seats/wire/provider-proxy';
|
|
2
|
+
import { effectiveSandboxTrust } from './environment.js';
|
|
3
|
+
import { bwrapReadRoots } from './sandbox/bwrap.js';
|
|
4
|
+
import { seatbeltSystemReadRoots } from './sandbox/seatbelt.js';
|
|
5
|
+
|
|
6
|
+
export * from '@commonlyai/seats/wire/provider-proxy';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* F453. What a sandboxed seat can read on this platform, as roots, or
|
|
10
|
+
* undefined for a spawn that runs in no sandbox. macOS: the Seatbelt
|
|
11
|
+
* baseline's read grants, read from the profile itself. Linux: bwrap's read
|
|
12
|
+
* binds, including the seat's declared read-outside and write-outside.
|
|
13
|
+
* `sandboxed` is the adapter's word on whether this spawn is confined; left
|
|
14
|
+
* out, a public seat is confined and any other is not.
|
|
15
|
+
*/
|
|
16
|
+
export const sandboxReadableRoots = (environment, platform = process.platform, { sandboxed } = {}) => {
|
|
17
|
+
const confined = sandboxed ?? effectiveSandboxTrust(environment?.sandbox?.trust) === 'public';
|
|
18
|
+
if (!confined) return undefined;
|
|
19
|
+
if (platform === 'darwin') return seatbeltSystemReadRoots();
|
|
20
|
+
if (platform === 'linux') return bwrapReadRoots(environment);
|
|
21
|
+
return { subpaths: [], literals: [] };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The shared key-file validation, with the sandbox check every adapter gets:
|
|
26
|
+
* the proxy reads a confined seat's key file outside the sandbox, so a key
|
|
27
|
+
* file the sandbox can read is refused before the seat starts, and before
|
|
28
|
+
* anything opens the file. Adapters pass their environment; `seatDirectories`
|
|
29
|
+
* names stable directories the adapter itself gives the sandbox (a seat home),
|
|
30
|
+
* checked whenever the spawn is confined. Without options nothing changes.
|
|
31
|
+
*/
|
|
32
|
+
export const resolveProviderKeyFile = (provider, workspacePath, adapterLabel, {
|
|
33
|
+
environment, platform, sandboxed, seatDirectories = [],
|
|
34
|
+
} = {}) => {
|
|
35
|
+
const roots = sandboxReadableRoots(environment, platform, { sandboxed });
|
|
36
|
+
return resolveKeyFileShared(provider, workspacePath, adapterLabel, {
|
|
37
|
+
readableRoots: roots && {
|
|
38
|
+
subpaths: [...roots.subpaths, ...seatDirectories.filter(Boolean)],
|
|
39
|
+
literals: roots.literals,
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tell a public seat's Commonly MCP server that it serves a public seat.
|
|
3
|
+
*
|
|
4
|
+
* The server then writes nothing once Commonly has been unreachable in that
|
|
5
|
+
* process (commonly-mcp/src/public-seat-gate.js), which closes the post a model
|
|
6
|
+
* makes through the tool before the CLI's own witness withholds the reply
|
|
7
|
+
* (public-tool-error.js, TASK-256). Only the shipped Commonly server is
|
|
8
|
+
* flagged. Every adapter carries a declared literal env key through to the
|
|
9
|
+
* server's process (`deliverSeatCredential` copies the declaration's env), so
|
|
10
|
+
* one copy here reaches all of them. A server that predates the gate ignores
|
|
11
|
+
* the key.
|
|
12
|
+
*
|
|
13
|
+
* Set at spawn, on a copy: the record and the guards (`auditDeclaredMcp`,
|
|
14
|
+
* `isShippedCommonlyMcpEntry`) still see the declaration as written.
|
|
15
|
+
*/
|
|
16
|
+
import { isShippedCommonlyMcpCommand } from './declared-mcp-guard.js';
|
|
17
|
+
import { describeMcpCommand } from './mcp-server-version.js';
|
|
18
|
+
|
|
19
|
+
export const PUBLIC_SEAT_MCP_ENV = 'COMMONLY_MCP_PUBLIC_SEAT';
|
|
20
|
+
|
|
21
|
+
// The npx form, or a recorded local checkout of the same package (the form
|
|
22
|
+
// goose's public check and `auditDeclaredMcp` also admit).
|
|
23
|
+
const isCommonlyServer = (server, describe) => isShippedCommonlyMcpCommand(server?.command)
|
|
24
|
+
|| Boolean(describe(server?.command)?.isCommonly);
|
|
25
|
+
|
|
26
|
+
export const withPublicSeatMcpFlag = (environment, { describe = describeMcpCommand } = {}) => {
|
|
27
|
+
const servers = Array.isArray(environment?.mcp) ? environment.mcp : null;
|
|
28
|
+
const flagged = servers ? servers.map((server) => isCommonlyServer(server, describe)) : [];
|
|
29
|
+
if (!flagged.some(Boolean)) return environment;
|
|
30
|
+
return {
|
|
31
|
+
...environment,
|
|
32
|
+
mcp: servers.map((server, i) => (flagged[i]
|
|
33
|
+
? { ...server, env: { ...(server.env || {}), [PUBLIC_SEAT_MCP_ENV]: '1' } }
|
|
34
|
+
: server)),
|
|
35
|
+
};
|
|
36
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which failed Commonly tool calls a public seat's model may answer in plain
|
|
3
|
+
* words. Everything else withholds the turn (#2374).
|
|
4
|
+
*
|
|
5
|
+
* The rule is inverted on purpose. Matching known connection failures let an
|
|
6
|
+
* API 5xx through: the shipped MCP turns a non-2xx into a tool error carrying
|
|
7
|
+
* the status, and during a rollout the ingress answers 503 while the socket
|
|
8
|
+
* connects, so the model narrated the outage to a stranger. A list of failures
|
|
9
|
+
* fails open on the next one nobody listed; a list of answers fails closed.
|
|
10
|
+
*
|
|
11
|
+
* The answerable ones:
|
|
12
|
+
* - an API refusal the person can be told about: 400, 403, 404, 409, 410, 413
|
|
13
|
+
* or 422. 401 is NOT among them: a seat whose token the API rejects is
|
|
14
|
+
* broken plumbing, and every later call fails the same way.
|
|
15
|
+
* - the runtime's own schema check, which the model fixes on its next call.
|
|
16
|
+
* OpenCode 1.19.0 words it "The <tool> tool was called with invalid
|
|
17
|
+
* arguments: …" (ToolInvalidArgumentsError, read from the shipped binary).
|
|
18
|
+
*
|
|
19
|
+
* Everything else withholds: a 5xx, 401, 429 or any other status; a tool error
|
|
20
|
+
* with no status (the shipped MCP's network, timeout and token failures); an
|
|
21
|
+
* MCP transport error ("MCP error -32000: Connection closed"); an empty error;
|
|
22
|
+
* and any text this module does not recognise.
|
|
23
|
+
*
|
|
24
|
+
* How the status arrives: the shipped MCP answers a failed call with
|
|
25
|
+
* `isError: true` and one text part, `JSON.stringify({ status, body, message })`
|
|
26
|
+
* for an HTTP error or `{ message }` otherwise (commonly-mcp/src/tools.js,
|
|
27
|
+
* `err`). OpenCode throws that text as the tool's error, so the event's
|
|
28
|
+
* `state.error` is the JSON (measured on the 2026-10-21 canary).
|
|
29
|
+
*
|
|
30
|
+
* The TASK-256 readiness witness uses this same predicate, so the two
|
|
31
|
+
* witnesses cannot disagree about what counts as a failure.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export const ANSWERABLE_API_STATUSES = new Set([400, 403, 404, 409, 410, 413, 422]);
|
|
35
|
+
|
|
36
|
+
// The two ways a public turn is withheld over its Commonly MCP server: a tool
|
|
37
|
+
// call failed in a way the model must not narrate, or the server was never shown
|
|
38
|
+
// working. Both leave the event unacked for retry.
|
|
39
|
+
export const PUBLIC_TOOL_FAILED_CODE = 'public_tool_failed';
|
|
40
|
+
export const PUBLIC_MCP_NOT_READY_CODE = 'public_mcp_not_ready';
|
|
41
|
+
|
|
42
|
+
const INVALID_ARGUMENTS_RE = /^The \S+ tool was called with invalid arguments: /;
|
|
43
|
+
|
|
44
|
+
const parsePayload = (text) => {
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(text);
|
|
47
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Whether a failed tool call's error text is one the model may answer. */
|
|
54
|
+
export const isAnswerableToolError = (error) => {
|
|
55
|
+
const text = typeof error === 'string' ? error.trim() : '';
|
|
56
|
+
if (!text) return false;
|
|
57
|
+
if (INVALID_ARGUMENTS_RE.test(text)) return true;
|
|
58
|
+
const payload = parsePayload(text);
|
|
59
|
+
return Boolean(payload && Number.isInteger(payload.status) && ANSWERABLE_API_STATUSES.has(payload.status));
|
|
60
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Commonly MCP tools that public seats are not offered, on any adapter.
|
|
2
|
+
// Claude workspace seats follow the same list. Each
|
|
3
|
+
// adapter removes them in its own vocabulary (TASK-287; Goose's public list
|
|
4
|
+
// already leaves them out).
|
|
5
|
+
// commonly_catch_up moves the read cursor past history unread; a wrapper seat
|
|
6
|
+
// already catches up before each turn, so no seat on this list needs the tool.
|
|
7
|
+
export const PUBLIC_WITHHELD_TOOLS = Object.freeze(['commonly_attach_file', 'commonly_catch_up']);
|
|
8
|
+
|
|
9
|
+
// F118 (Cofounder ruling, 11-04): public agents start from nothing and earn
|
|
10
|
+
// capabilities. A public seat answers strangers, so an injected message must
|
|
11
|
+
// not be able to spread the agent into other pods or rewrite its memory.
|
|
12
|
+
// Withheld on public trust only: a Claude workspace seat keeps its memory
|
|
13
|
+
// writes, and denies self-install itself (claude.js, workspaceDenies). The
|
|
14
|
+
// memory writes come back on a turn the owner woke (F145, below).
|
|
15
|
+
// commonly_log_cycle is a memory write too: it appends to the agent's
|
|
16
|
+
// `cycles[]` through POST /api/agents/runtime/memory/sync. Reading memory
|
|
17
|
+
// (commonly_read_agent_memory, and the long_term the wrapper puts in the
|
|
18
|
+
// prompt) stays offered.
|
|
19
|
+
// commonly_delete_message (F141): a stranger-steered seat could delete its own
|
|
20
|
+
// replies to an injection, which are the evidence of what the attack achieved.
|
|
21
|
+
// A workspace seat keeps it.
|
|
22
|
+
export const PUBLIC_ONLY_WITHHELD_TOOLS = Object.freeze([
|
|
23
|
+
'commonly_self_install_into_pod',
|
|
24
|
+
'commonly_write_agent_memory',
|
|
25
|
+
'commonly_save_my_memory',
|
|
26
|
+
'commonly_log_cycle',
|
|
27
|
+
'commonly_delete_message',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
// Everything a public seat is not offered. Codex, OpenCode, Qwen and Goose
|
|
31
|
+
// confine on public trust only, so this is the whole list for them.
|
|
32
|
+
export const PUBLIC_SEAT_WITHHELD_TOOLS = Object.freeze([
|
|
33
|
+
...PUBLIC_WITHHELD_TOOLS, ...PUBLIC_ONLY_WITHHELD_TOOLS,
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
// F145: what comes back on a turn the kernel verified its owner woke, so the
|
|
37
|
+
// owner keeps a remembering agent. The kernel refuses these writes on any other
|
|
38
|
+
// public turn, whatever an adapter offers.
|
|
39
|
+
export const OWNER_WOKEN_MEMORY_TOOLS = Object.freeze([
|
|
40
|
+
'commonly_write_agent_memory',
|
|
41
|
+
'commonly_save_my_memory',
|
|
42
|
+
'commonly_log_cycle',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/** What a public seat is not offered on this turn. Only a literal `true` opens it. */
|
|
46
|
+
export const publicSeatWithheldTools = (ctx) => (ctx?.ownerWoken === true
|
|
47
|
+
? PUBLIC_SEAT_WITHHELD_TOOLS.filter((tool) => !OWNER_WOKEN_MEMORY_TOOLS.includes(tool))
|
|
48
|
+
: PUBLIC_SEAT_WITHHELD_TOOLS);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// D245 item 4: the receipt the wrapper posts with a reply, so a BYO reply
|
|
2
|
+
// carries "how this reply was made" the way a hosted reply has its run trace.
|
|
3
|
+
// It is the wrapper's own account and the server stores it as self-reported
|
|
4
|
+
// (backend/services/byoReceipt.ts), which also bounds and checks every field.
|
|
5
|
+
// This side keeps to what it actually observed: an adapter that reports no
|
|
6
|
+
// stats sends only its name and the measured duration.
|
|
7
|
+
|
|
8
|
+
const isName = (value) => typeof value === 'string' && value.length > 0;
|
|
9
|
+
const isCount = (value) => Number.isInteger(value) && value >= 0;
|
|
10
|
+
|
|
11
|
+
const parseLine = (raw) => {
|
|
12
|
+
try { return JSON.parse(raw); } catch { return null; }
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What a Claude Code stream-json run says about itself: the model from the
|
|
17
|
+
* init event, the turn count from the result event, and each tool it called,
|
|
18
|
+
* once, in first-use order.
|
|
19
|
+
*/
|
|
20
|
+
export const claudeRunStats = (stdout) => {
|
|
21
|
+
const events = String(stdout || '').split('\n').map(parseLine).filter(Boolean);
|
|
22
|
+
const stats = {};
|
|
23
|
+
const init = events.find((event) => event.type === 'system' && event.subtype === 'init');
|
|
24
|
+
if (isName(init?.model)) stats.model = init.model;
|
|
25
|
+
const result = [...events].reverse().find((event) => event.type === 'result');
|
|
26
|
+
if (isCount(result?.num_turns)) stats.turns = result.num_turns;
|
|
27
|
+
const tools = [];
|
|
28
|
+
for (const event of events) {
|
|
29
|
+
if (event.type !== 'assistant') continue;
|
|
30
|
+
for (const block of event.message?.content || []) {
|
|
31
|
+
if (block?.type === 'tool_use' && isName(block.name) && !tools.includes(block.name)) tools.push(block.name);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (tools.length) stats.tools = tools;
|
|
35
|
+
return stats;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** The receipt for one reply, or null when there is no adapter to name. */
|
|
39
|
+
export const buildReplyReceipt = ({ adapterName, stats = {}, durationMs } = {}) => {
|
|
40
|
+
if (!isName(adapterName)) return null;
|
|
41
|
+
const receipt = { adapter: adapterName };
|
|
42
|
+
// A cloud-hosted model can be named by an ARN or a resource path that
|
|
43
|
+
// carries the account or project it runs in; that is not this reply's to
|
|
44
|
+
// publish, so such a model id is left out.
|
|
45
|
+
if (isName(stats?.model) && !/^arn:|(^|\/)projects\//i.test(stats.model)) receipt.model = stats.model;
|
|
46
|
+
// D247: the effort level the adapter passed, as a bare level name.
|
|
47
|
+
if (isName(stats?.effort) && /^[a-z0-9][a-z0-9._-]{0,19}$/i.test(stats.effort)) receipt.effort = stats.effort;
|
|
48
|
+
if (isCount(stats?.turns)) receipt.turns = stats.turns;
|
|
49
|
+
if (Array.isArray(stats?.tools)) {
|
|
50
|
+
const tools = stats.tools.filter(isName);
|
|
51
|
+
if (tools.length) receipt.tools = tools;
|
|
52
|
+
}
|
|
53
|
+
if (Number.isFinite(durationMs) && durationMs >= 0) receipt.durationMs = Math.round(durationMs);
|
|
54
|
+
return receipt;
|
|
55
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { accessSync, constants as fsConstants, statSync } from 'node:fs';
|
|
2
|
+
import { delimiter, isAbsolute, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The first executable `name` on `pathValue`, absolute, or null. No shell
|
|
6
|
+
* involved, and only absolute PATH entries count: a relative entry would
|
|
7
|
+
* resolve against whatever the daemon's working directory happens to be.
|
|
8
|
+
* Only a file counts: X_OK passes a directory, and execvp passes over one
|
|
9
|
+
* (EACCES) to the next entry, so a same-named folder earlier on PATH must not
|
|
10
|
+
* be returned as the binary (F146).
|
|
11
|
+
*/
|
|
12
|
+
export const resolveOnPath = (name, pathValue) => {
|
|
13
|
+
for (const dir of String(pathValue || '').split(delimiter)) {
|
|
14
|
+
if (!dir || !isAbsolute(dir)) continue;
|
|
15
|
+
const candidate = join(dir, name);
|
|
16
|
+
try {
|
|
17
|
+
accessSync(candidate, fsConstants.X_OK);
|
|
18
|
+
if (statSync(candidate).isFile()) return candidate;
|
|
19
|
+
} catch { /* next */ }
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Probe only the walked adapter's version command. Never enumerate workspace
|
|
2
|
+
// extensions to discover models: a model picker must not execute project code.
|
|
3
|
+
export const WALKED_HARNESSES = ['claude', 'codex', 'opencode', 'hermes', 'dsh'];
|
|
4
|
+
// `loginState` (F458), when given, adds `signedIn` to an installed harness it
|
|
5
|
+
// has a verdict for, so the page can say "not signed in" before the first
|
|
6
|
+
// message fails instead of after.
|
|
7
|
+
export const collectRuntimeInventory = async (getAdapter, capabilities, harnesses = WALKED_HARNESSES, { loginState } = {}) => Promise.all(harnesses.map(async (harness) => {
|
|
8
|
+
const adapter = getAdapter(harness);
|
|
9
|
+
let detected = null;
|
|
10
|
+
try { detected = await adapter?.detect(); } catch { /* unavailable */ }
|
|
11
|
+
let choices = { models: null, efforts: [] };
|
|
12
|
+
if (detected) { try { choices = (await (capabilities ? capabilities(harness) : adapter?.inventory?.())) || choices; } catch { /* unavailable */ } }
|
|
13
|
+
let signedIn = null;
|
|
14
|
+
if (detected && loginState) { try { signedIn = await loginState(harness); } catch { /* no verdict */ } }
|
|
15
|
+
return { harness, installed: Boolean(detected), version: detected?.version || null,
|
|
16
|
+
models: choices.models, efforts: choices.efforts, ...(typeof signedIn === 'boolean' ? { signedIn } : {}) };
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
// The daemon's inventory, refreshed in full every five minutes. A harness it
|
|
20
|
+
// didn't find is probed again sooner, so one installed while the BYO page is
|
|
21
|
+
// open shows up within a heartbeat or two, without a reload (F370 #8). One
|
|
22
|
+
// that reported signed out is rechecked the same way, so a sign-in does too (F458).
|
|
23
|
+
// Probes are synchronous: a binary that isn't there fails at once, but one
|
|
24
|
+
// that's there and hangs on --version blocks the daemon for its timeout. A
|
|
25
|
+
// re-probe slower than INVENTORY_SLOW_MS waits for the full refresh instead.
|
|
26
|
+
export const INVENTORY_FULL_MS = 300_000;
|
|
27
|
+
export const INVENTORY_MISSING_MS = 25_000;
|
|
28
|
+
export const INVENTORY_SLOW_MS = 1_000;
|
|
29
|
+
export const createInventoryCache = (collect, { now = Date.now } = {}) => {
|
|
30
|
+
let cached;
|
|
31
|
+
let fullAt = 0;
|
|
32
|
+
let missingAt = 0;
|
|
33
|
+
let slow = new Set();
|
|
34
|
+
return async () => {
|
|
35
|
+
const at = now();
|
|
36
|
+
if (!cached || at - fullAt > INVENTORY_FULL_MS) {
|
|
37
|
+
cached = await collect(WALKED_HARNESSES);
|
|
38
|
+
fullAt = at;
|
|
39
|
+
missingAt = at;
|
|
40
|
+
slow = new Set();
|
|
41
|
+
return cached;
|
|
42
|
+
}
|
|
43
|
+
const missing = cached.filter((row) => (!row.installed || row.signedIn === false) && !slow.has(row.harness))
|
|
44
|
+
.map((row) => row.harness);
|
|
45
|
+
if (missing.length && at - missingAt > INVENTORY_MISSING_MS) {
|
|
46
|
+
missingAt = at;
|
|
47
|
+
for (const harness of missing) {
|
|
48
|
+
const started = now();
|
|
49
|
+
const [fresh] = await collect([harness]);
|
|
50
|
+
if (now() - started > INVENTORY_SLOW_MS) slow.add(harness);
|
|
51
|
+
if (fresh) cached = cached.map((row) => (row.harness === harness ? fresh : row));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return cached;
|
|
55
|
+
};
|
|
56
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// controlsFromArguments and observeSpawn live in the seats runtime package;
|
|
2
|
+
// the reporter below posts to a Commonly instance, so it stays in the CLI.
|
|
3
|
+
export { controlsFromArguments, observeSpawn } from '@commonlyai/seats/runtime-observation';
|
|
4
|
+
|
|
5
|
+
export const createRuntimeReporter = ({ client, harness, detect }) => {
|
|
6
|
+
let queue = Promise.resolve();
|
|
7
|
+
const send = (body) => {
|
|
8
|
+
// Serialize so a fast process cannot have its settled report overtake its
|
|
9
|
+
// spawn report. Old backends and diagnostic outages don't stop a turn.
|
|
10
|
+
queue = queue.then(() => client.post('/api/agents/runtime/observation', body)).catch(() => {});
|
|
11
|
+
return queue;
|
|
12
|
+
};
|
|
13
|
+
return {
|
|
14
|
+
async connect() {
|
|
15
|
+
return send({ event: 'connected' });
|
|
16
|
+
},
|
|
17
|
+
spawn: ({ model, effort, at }) => {
|
|
18
|
+
const receipt = { event: 'spawn', harness, version: null, model, effort, ...(at ? { at } : {}) };
|
|
19
|
+
queue = queue.then(async () => {
|
|
20
|
+
try { receipt.version = (await detect())?.version || null; } catch { /* unavailable */ }
|
|
21
|
+
await client.post('/api/agents/runtime/observation', receipt);
|
|
22
|
+
}).catch(() => {});
|
|
23
|
+
return queue;
|
|
24
|
+
},
|
|
25
|
+
// A failed spawn carries its reason code (F370 #11). A backend from before
|
|
26
|
+
// the field refuses the whole report with a 400, so it's sent once more
|
|
27
|
+
// without the reason, and the outcome still lands.
|
|
28
|
+
settled: (outcome = null, reason = null) => {
|
|
29
|
+
const body = { event: 'settled', ...(outcome ? { outcome } : {}) };
|
|
30
|
+
if (!(outcome === 'failed' && reason)) return send(body);
|
|
31
|
+
queue = queue.then(() => client.post('/api/agents/runtime/observation', { ...body, reason })
|
|
32
|
+
.catch((error) => (error?.status === 400 ? client.post('/api/agents/runtime/observation', body) : undefined)))
|
|
33
|
+
.catch(() => {});
|
|
34
|
+
return queue;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
};
|