@ravefox/space 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -203
- package/README.md +89 -26
- package/bin/space.js +106 -0
- package/lib/agents.js +119 -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 +34 -0
- package/lib/runtime.js +241 -0
- package/lib/store.js +96 -0
- package/lib/tools.js +93 -0
- package/package.json +28 -40
- package/NOTICE +0 -44
- 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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Copyright 2026 Ravefox. SPDX-License-Identifier: MIT
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
const API = '/api/agents/runtime/spawn-credentials';
|
|
5
|
+
export const TURN_CREDENTIAL_TTL_SECONDS = 600;
|
|
6
|
+
export const TURN_CREDENTIAL_RENEW_MS = 20000;
|
|
7
|
+
const CLOCK_ALLOWANCE_MS = 5000;
|
|
8
|
+
const objectId = (value) => typeof value === 'string' && /^[a-f0-9]{24}$/i.test(value);
|
|
9
|
+
const timestamp = (value) => typeof value === 'string' ? Date.parse(value) : NaN;
|
|
10
|
+
|
|
11
|
+
/** Mint a child for this event; the parent credential never enters model tools. */
|
|
12
|
+
export async function createTurnCredential({ client, event, publicSeat = true }) {
|
|
13
|
+
if (!client || typeof publicSeat !== 'boolean' || !objectId(event?._id)) throw new Error('A valid Space event and public-seat setting are required');
|
|
14
|
+
// Read the server's published bounds rather than assuming its defaults.
|
|
15
|
+
const policy = await client.get(`${API}/policy`);
|
|
16
|
+
if (!Number.isSafeInteger(policy?.minTtlSeconds) || !Number.isSafeInteger(policy?.maxTtlSeconds)
|
|
17
|
+
|| policy.minTtlSeconds <= 0 || policy.minTtlSeconds > TURN_CREDENTIAL_TTL_SECONDS
|
|
18
|
+
|| policy.maxTtlSeconds < TURN_CREDENTIAL_TTL_SECONDS
|
|
19
|
+
|| !Number.isSafeInteger(policy.absoluteLifetimeSeconds) || policy.absoluteLifetimeSeconds < TURN_CREDENTIAL_TTL_SECONDS
|
|
20
|
+
|| !Number.isSafeInteger(policy.maxSpawnIdLength) || policy.maxSpawnIdLength < 36) {
|
|
21
|
+
throw new Error('Space does not support the required bounded turn credential');
|
|
22
|
+
}
|
|
23
|
+
const spawnId = randomUUID();
|
|
24
|
+
const minted = await client.post(API, { spawnId, ttlSeconds: TURN_CREDENTIAL_TTL_SECONDS, publicSeat, eventIds: [event._id] });
|
|
25
|
+
const credentialId = minted?.credentialId;
|
|
26
|
+
const token = minted?.token;
|
|
27
|
+
const issuedAt = Date.now();
|
|
28
|
+
let expiresAt = timestamp(minted?.expiresAt);
|
|
29
|
+
const maxExpiresAt = timestamp(minted?.maxExpiresAt);
|
|
30
|
+
const valid = objectId(credentialId) && typeof token === 'string' && /^cm_agent_[A-Za-z0-9_-]+$/.test(token)
|
|
31
|
+
&& token !== client.token && minted.spawnId === spawnId
|
|
32
|
+
&& typeof minted.ownerWoken === 'boolean'
|
|
33
|
+
&& Number.isFinite(expiresAt) && expiresAt > issuedAt
|
|
34
|
+
&& expiresAt <= issuedAt + TURN_CREDENTIAL_TTL_SECONDS * 1000 + CLOCK_ALLOWANCE_MS
|
|
35
|
+
&& Number.isFinite(maxExpiresAt) && maxExpiresAt >= expiresAt
|
|
36
|
+
&& maxExpiresAt <= issuedAt + policy.absoluteLifetimeSeconds * 1000 + CLOCK_ALLOWANCE_MS;
|
|
37
|
+
if (!valid) {
|
|
38
|
+
// A malformed response can still name a minted child. Revoke that row
|
|
39
|
+
// before refusing it; never substitute the parent token as a fallback.
|
|
40
|
+
if (objectId(credentialId)) {
|
|
41
|
+
try { await client.delete(`${API}/${credentialId}`); } catch { /* The bounded TTL remains the backstop. */ }
|
|
42
|
+
}
|
|
43
|
+
throw new Error('Space did not issue a valid scoped turn credential');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let stopped = false;
|
|
47
|
+
let failed = false;
|
|
48
|
+
let renewed = null;
|
|
49
|
+
let closing = null;
|
|
50
|
+
let revoked = false;
|
|
51
|
+
return {
|
|
52
|
+
token,
|
|
53
|
+
ownerWoken: minted.ownerWoken,
|
|
54
|
+
renew() {
|
|
55
|
+
if (stopped || failed || Date.now() >= expiresAt) return Promise.reject(new Error('The Space turn credential is no longer renewable'));
|
|
56
|
+
if (renewed) return renewed;
|
|
57
|
+
renewed = (async () => {
|
|
58
|
+
try {
|
|
59
|
+
const result = await client.post(`${API}/${credentialId}/renew`, { ttlSeconds: TURN_CREDENTIAL_TTL_SECONDS });
|
|
60
|
+
const nextExpiry = timestamp(result?.expiresAt);
|
|
61
|
+
if (result?.extended !== true || !Number.isFinite(nextExpiry) || nextExpiry <= Date.now()
|
|
62
|
+
|| nextExpiry < expiresAt || nextExpiry > maxExpiresAt
|
|
63
|
+
|| nextExpiry > Date.now() + TURN_CREDENTIAL_TTL_SECONDS * 1000 + CLOCK_ALLOWANCE_MS) {
|
|
64
|
+
throw new Error('Invalid renewal');
|
|
65
|
+
}
|
|
66
|
+
expiresAt = nextExpiry;
|
|
67
|
+
return { expiresAt: result.expiresAt };
|
|
68
|
+
} catch {
|
|
69
|
+
failed = true;
|
|
70
|
+
throw new Error('Cannot renew the scoped Space turn credential safely');
|
|
71
|
+
} finally { renewed = null; }
|
|
72
|
+
})();
|
|
73
|
+
return renewed;
|
|
74
|
+
},
|
|
75
|
+
close() {
|
|
76
|
+
stopped = true;
|
|
77
|
+
if (revoked) return Promise.resolve();
|
|
78
|
+
if (closing) return closing;
|
|
79
|
+
closing = (async () => {
|
|
80
|
+
// Serialize revocation after an already-started renewal, so it cannot
|
|
81
|
+
// extend the credential after cleanup has finished.
|
|
82
|
+
await renewed?.catch(() => {});
|
|
83
|
+
try {
|
|
84
|
+
const result = await client.delete(`${API}/${credentialId}`);
|
|
85
|
+
if (!Number.isInteger(result?.revoked) || result.revoked < 0) throw new Error('Invalid revocation');
|
|
86
|
+
revoked = true;
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (error?.status === 404) { revoked = true; return; }
|
|
89
|
+
throw new Error('Cannot revoke the scoped Space turn credential');
|
|
90
|
+
} finally { closing = null; }
|
|
91
|
+
})();
|
|
92
|
+
return closing;
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
package/lib/daemon.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// Copyright 2026 Ravefox. SPDX-License-Identifier: MIT
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { readFile, writeFile, mkdir, open, unlink, chmod, realpath } from 'node:fs/promises';
|
|
5
|
+
import { homedir, hostname, platform, release, arch } from 'node:os';
|
|
6
|
+
import { join, resolve, dirname, isAbsolute, relative } from 'node:path';
|
|
7
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
8
|
+
import { readState, writeState, deleteState, stateDir, sessionFor, normalizeInstance } from './store.js';
|
|
9
|
+
import { SpaceClient } from './http.js';
|
|
10
|
+
import { login } from './login.js';
|
|
11
|
+
import { availableAdapters } from './harness.js';
|
|
12
|
+
|
|
13
|
+
const execute = promisify(execFile);
|
|
14
|
+
const runAgent = async (options) => (await import('./runtime.js')).runAgent(options);
|
|
15
|
+
const metadata = async () => JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
16
|
+
const hexId = (x) => typeof x === 'string' && /^[a-f\d]{24}$/i.test(x);
|
|
17
|
+
const identity = (x) => typeof x === 'string' && /^[a-z\d_-]{1,64}$/i.test(x);
|
|
18
|
+
const instanceId = (x) => typeof x === 'string' && /^[a-z\d._-]{1,64}$/i.test(x);
|
|
19
|
+
const keyOf = (row) => `${row.agentName.toLowerCase()}:${row.instanceId.toLowerCase()}`;
|
|
20
|
+
const machineValid = (m) => m && hexId(m.id) && typeof m.machineId === 'string'
|
|
21
|
+
&& /^[a-z\d-]{16,80}$/i.test(m.machineId) && typeof m.name === 'string' && m.name.length > 0;
|
|
22
|
+
const authError = (e) => [401, 403].includes(e?.status);
|
|
23
|
+
const hostReport = async () => ({ platform: platform(), arch: arch(), osMajor: Number.parseInt(release(), 10), cliVersion: (await metadata()).version });
|
|
24
|
+
let cachedInventory, inventoryAt = 0;
|
|
25
|
+
async function machineReport() {
|
|
26
|
+
if (!cachedInventory || Date.now() - inventoryAt > 60000) {
|
|
27
|
+
const available = await availableAdapters();
|
|
28
|
+
cachedInventory = ['codex', 'claude'].map((harness) => ({ harness, installed: available.includes(harness), version: null, models: null, efforts: [] }));
|
|
29
|
+
inventoryAt = Date.now();
|
|
30
|
+
}
|
|
31
|
+
return { host: await hostReport(), runtimeInventory: cachedInventory, sandbox: 'default' };
|
|
32
|
+
}
|
|
33
|
+
const safeMessage = (text) => console.error(`[Space daemon] ${text}`);
|
|
34
|
+
|
|
35
|
+
export async function registerMachine({ instance, name = hostname() } = {}) {
|
|
36
|
+
let session = sessionFor(instance);
|
|
37
|
+
if (!session?.token) session = await login({ instance });
|
|
38
|
+
if (!hexId(session.user?.id || session.user?._id)) throw new Error('Sign in again to identify the computer owner.');
|
|
39
|
+
const url = normalizeInstance(session.url);
|
|
40
|
+
const previous = readState('machine.json');
|
|
41
|
+
if (previous) {
|
|
42
|
+
if (previous.instanceUrl !== url || (previous.ownerUserId && previous.ownerUserId !== (session.user?.id || session.user?._id))) {
|
|
43
|
+
throw new Error('This computer is already registered. Unregister it before changing server or account.');
|
|
44
|
+
}
|
|
45
|
+
const checked = await new SpaceClient({ url, token: previous.daemonToken }).get('/api/machines/me');
|
|
46
|
+
if (!machineValid(checked?.machine) || checked.machine.machineId !== previous.machineId || checked.machine.id !== previous.machineDbId) throw new Error('Machine registration does not match the server.');
|
|
47
|
+
return { machine: checked.machine };
|
|
48
|
+
}
|
|
49
|
+
if (typeof name !== 'string' || !name.trim() || name.length > 120 || /[\x00-\x1f]/.test(name)) throw new Error('Choose a valid computer name.');
|
|
50
|
+
const client = new SpaceClient(session);
|
|
51
|
+
const result = await client.post('/api/machines', { name: name.trim(), host: await hostReport() });
|
|
52
|
+
if (!machineValid(result?.machine) || typeof result.daemonToken !== 'string' || !result.daemonToken) throw new Error('Incomplete machine registration returned by server.');
|
|
53
|
+
try {
|
|
54
|
+
writeState('machine.json', {
|
|
55
|
+
instanceUrl: url, machineDbId: result.machine.id, machineId: result.machine.machineId,
|
|
56
|
+
machineName: result.machine.name, daemonToken: result.daemonToken,
|
|
57
|
+
ownerUserId: session.user?.id || session.user?._id || null, registeredAt: new Date().toISOString(),
|
|
58
|
+
});
|
|
59
|
+
} catch (error) {
|
|
60
|
+
await client.delete(`/api/machines/${result.machine.id}`).catch(() => {});
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
return { machine: result.machine };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Dependencies keep concurrency and revocation verifiable without starting a model.
|
|
67
|
+
export function createSupervisor({ client, machine, run = runAgent, read = readState, write = writeState,
|
|
68
|
+
now = Date.now, log = safeMessage, limit = 4, report = machineReport }) {
|
|
69
|
+
const seats = new Map();
|
|
70
|
+
let revoked = false;
|
|
71
|
+
const stop = async (seat) => { seat.controller.abort(); await seat.done; };
|
|
72
|
+
const stopAll = async () => { await Promise.all([...seats.values()].map(stop)); seats.clear(); };
|
|
73
|
+
const validRow = (row) => row && identity(row.agentName) && instanceId(row.instanceId)
|
|
74
|
+
&& ['bound', 'requested'].includes(row.state) && Array.isArray(row.podIds) && row.podIds.length > 0
|
|
75
|
+
&& row.podIds.every(hexId) && row.runtime && typeof row.runtime === 'object'
|
|
76
|
+
&& ['codex', 'claude'].includes(row.runtime.adapter)
|
|
77
|
+
&& !['native', 'internal', 'openclaw', 'moltbot'].includes(row.runtime.runtimeType);
|
|
78
|
+
const status = () => [...seats.entries()].map(([key, seat]) => ({
|
|
79
|
+
agentName: seat.record.agentName, instanceId: seat.record.instanceId,
|
|
80
|
+
state: seat.running ? 'running' : 'stopped', restarts: seat.failures,
|
|
81
|
+
}));
|
|
82
|
+
const reconcile = async () => {
|
|
83
|
+
if (revoked) throw new Error('Machine authorization was revoked.');
|
|
84
|
+
try {
|
|
85
|
+
const own = await client.get('/api/machines/me');
|
|
86
|
+
if (!machineValid(own?.machine) || own.machine.id !== machine.machineDbId || own.machine.machineId !== machine.machineId) throw Object.assign(new Error('Machine identity mismatch'), { status: 403 });
|
|
87
|
+
const page = await client.get('/api/agent-binding/assigned');
|
|
88
|
+
if (!Array.isArray(page?.agents)) throw new Error('Invalid assigned-agent response');
|
|
89
|
+
const selected = new Map();
|
|
90
|
+
for (const row of page.agents) {
|
|
91
|
+
if (!validRow(row)) { log('Unsupported or invalid assignment withheld.'); continue; }
|
|
92
|
+
const key = keyOf(row);
|
|
93
|
+
if (selected.has(key)) throw new Error('Duplicate assignment identity');
|
|
94
|
+
selected.set(key, row);
|
|
95
|
+
}
|
|
96
|
+
for (const [key, seat] of seats) {
|
|
97
|
+
if (!selected.has(key) || seat.signature !== JSON.stringify(selected.get(key))) {
|
|
98
|
+
await stop(seat); seats.delete(key);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Existing assignments retain their slots; new rows never evict a running seat.
|
|
102
|
+
for (const [key, row] of selected) {
|
|
103
|
+
const current = seats.get(key);
|
|
104
|
+
if (current?.running || (current && now() < current.retryAt)) continue;
|
|
105
|
+
if (!current && seats.size >= limit) continue;
|
|
106
|
+
if (row.state === 'requested') {
|
|
107
|
+
const adopted = await client.post('/api/agent-binding/adopt', { agentName: row.agentName, instanceId: row.instanceId });
|
|
108
|
+
if (adopted?.adopted !== true || adopted.machineId !== machine.machineId || adopted.agentName !== row.agentName || adopted.instanceId !== row.instanceId) throw new Error('Invalid adoption response');
|
|
109
|
+
}
|
|
110
|
+
const environment = row.environment || {};
|
|
111
|
+
const mode = environment.sandbox?.mode || 'read-only';
|
|
112
|
+
if (!['read-only', 'workspace-write'].includes(mode)) { log('Unsupported sandbox assignment withheld.'); continue; }
|
|
113
|
+
if (row.grantBrokerRefusal) { log('Assignment requires connector permissions that are currently refused.'); continue; }
|
|
114
|
+
const { validateAgent } = await import('./runtime.js');
|
|
115
|
+
validateAgent({ agentName: row.agentName, instanceId: row.instanceId, adapter: row.runtime.adapter,
|
|
116
|
+
runtimeToken: 'cm_agent_validation', environment, sandbox: mode });
|
|
117
|
+
// Workspace declarations from the network cannot select arbitrary local directories.
|
|
118
|
+
const workspacePath = join(homedir(), '.local', 'share', 'space', 'workspaces', row.agentName.toLowerCase(), row.instanceId.toLowerCase());
|
|
119
|
+
const tokenFile = `managed/${machine.machineId}/${row.agentName.toLowerCase()}/${row.instanceId.toLowerCase()}.json`;
|
|
120
|
+
let record = read(tokenFile);
|
|
121
|
+
if (record && (record.instanceUrl !== machine.instanceUrl || record.machineId !== machine.machineId || record.agentName !== row.agentName || record.instanceId !== row.instanceId || !record.runtimeToken)) throw new Error('Saved agent credential does not match assignment');
|
|
122
|
+
if (!record || (row.reconnectRequestedAt && record.reconnectRequestedAt !== row.reconnectRequestedAt)) {
|
|
123
|
+
const minted = await client.post('/api/agent-binding/runtime-token', {
|
|
124
|
+
agentName: row.agentName, instanceId: row.instanceId,
|
|
125
|
+
...(row.reconnectRequestedAt ? { rotate: true } : {}),
|
|
126
|
+
});
|
|
127
|
+
if (minted?.agentName !== row.agentName || minted.instanceId !== row.instanceId || typeof minted.token !== 'string' || !minted.token) throw new Error('Invalid runtime credential response');
|
|
128
|
+
record = { agentName: row.agentName, instanceId: row.instanceId, instanceUrl: machine.instanceUrl,
|
|
129
|
+
machineId: machine.machineId, runtimeToken: minted.token, reconnectRequestedAt: row.reconnectRequestedAt || null };
|
|
130
|
+
write(tokenFile, record);
|
|
131
|
+
}
|
|
132
|
+
record = { ...record, podId: row.podIds[0], adapter: row.runtime.adapter,
|
|
133
|
+
model: environment.model || row.runtime.model, effort: environment.effort || row.runtime.effort,
|
|
134
|
+
environment, workspacePath, sandbox: mode };
|
|
135
|
+
const controller = new AbortController();
|
|
136
|
+
const seat = { record, controller, signature: JSON.stringify(row), running: true, failures: current?.failures || 0, retryAt: 0 };
|
|
137
|
+
seats.set(key, seat);
|
|
138
|
+
seat.done = Promise.resolve().then(() => run({ record, signal: controller.signal })).catch(() => {
|
|
139
|
+
if (!controller.signal.aborted) log(`Agent ${row.agentName} stopped; retry scheduled.`);
|
|
140
|
+
}).finally(() => { seat.running = false; seat.failures++; seat.retryAt = now() + Math.min(60000, 2000 * 2 ** Math.min(seat.failures, 5)); });
|
|
141
|
+
}
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (authError(error)) { revoked = true; await stopAll(); }
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
const heartbeat = async () => {
|
|
148
|
+
try { return await client.post(`/api/machines/${machine.machineDbId}/heartbeat`, { agents: status(), ...await report() }); }
|
|
149
|
+
catch (error) { if (authError(error)) { revoked = true; await stopAll(); } throw error; }
|
|
150
|
+
};
|
|
151
|
+
return { reconcile, heartbeat, stop: stopAll, status };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function runDaemon({ signal } = {}) {
|
|
155
|
+
const machine = readState('machine.json');
|
|
156
|
+
if (!machine?.daemonToken || !machineValid({ id: machine.machineDbId, machineId: machine.machineId, name: machine.machineName })) throw new Error('Register this computer with space daemon setup first.');
|
|
157
|
+
if (machine.revoked) { safeMessage('Machine access was revoked; setup is required.'); return; }
|
|
158
|
+
const lockPath = join(stateDir(), 'daemon.lock');
|
|
159
|
+
let lock;
|
|
160
|
+
try { lock = await open(lockPath, 'wx', 0o600); await lock.writeFile(String(process.pid)); }
|
|
161
|
+
catch (error) {
|
|
162
|
+
if (error.code !== 'EEXIST') throw error;
|
|
163
|
+
const pid = Number(await readFile(lockPath, 'utf8'));
|
|
164
|
+
if (!Number.isSafeInteger(pid) || pid <= 1) throw new Error('Invalid daemon lock; inspect it before restarting.');
|
|
165
|
+
try { process.kill(pid, 0); } catch (e) {
|
|
166
|
+
if (e.code === 'ESRCH') { await unlink(lockPath); return runDaemon({ signal }); }
|
|
167
|
+
throw e;
|
|
168
|
+
}
|
|
169
|
+
throw new Error('A Space daemon is already running.');
|
|
170
|
+
}
|
|
171
|
+
const ownSignal = new AbortController();
|
|
172
|
+
const abort = () => ownSignal.abort();
|
|
173
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
174
|
+
if (signal?.aborted) abort();
|
|
175
|
+
process.once('SIGINT', abort); process.once('SIGTERM', abort);
|
|
176
|
+
const supervisor = createSupervisor({ client: new SpaceClient({ url: machine.instanceUrl, token: machine.daemonToken, signal: ownSignal.signal }), machine });
|
|
177
|
+
let failures = 0;
|
|
178
|
+
try {
|
|
179
|
+
while (!ownSignal.signal.aborted) {
|
|
180
|
+
try {
|
|
181
|
+
await supervisor.reconcile(); await supervisor.heartbeat(); failures = 0;
|
|
182
|
+
writeState('daemon-status.json', { updatedAt: new Date().toISOString(), pid: process.pid, agents: supervisor.status() });
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (authError(error)) { writeState('machine.json', { ...machine, revoked: true }); safeMessage('Machine access revoked. Unregister and set up this computer again to reconnect.'); break; }
|
|
185
|
+
failures++; safeMessage('Connection interrupted; retrying.');
|
|
186
|
+
}
|
|
187
|
+
await delay(Math.min(60000, 5000 * 2 ** Math.min(failures, 4)), null, { signal: ownSignal.signal }).catch(() => {});
|
|
188
|
+
}
|
|
189
|
+
} finally {
|
|
190
|
+
await supervisor.stop(); await lock.close(); await unlink(lockPath).catch(() => {});
|
|
191
|
+
process.off('SIGINT', abort); process.off('SIGTERM', abort); signal?.removeEventListener('abort', abort);
|
|
192
|
+
deleteState('daemon-status.json');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function daemonStatus() {
|
|
197
|
+
const machine = readState('machine.json');
|
|
198
|
+
const status = readState('daemon-status.json');
|
|
199
|
+
let running = false;
|
|
200
|
+
if (Number.isSafeInteger(status?.pid) && status.pid > 1) { try { process.kill(status.pid, 0); running = true; } catch {} }
|
|
201
|
+
return { registered: Boolean(machine), machine: machine ? { name: machine.machineName, instanceUrl: machine.instanceUrl, machineId: machine.machineId } : null,
|
|
202
|
+
running, revoked: machine?.revoked === true, agents: running ? status.agents : [], updatedAt: status?.updatedAt || null };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const servicePaths = () => ({
|
|
206
|
+
label: 'dev.ravefox.space',
|
|
207
|
+
plist: join(homedir(), 'Library/LaunchAgents/dev.ravefox.space.plist'),
|
|
208
|
+
unit: join(homedir(), '.config/systemd/user/space.service'),
|
|
209
|
+
log: join(stateDir(), 'daemon.log'),
|
|
210
|
+
});
|
|
211
|
+
const xml = (value) => String(value).replace(/[<>&"']/g, (c) => ({ '<':'<', '>':'>', '&':'&', '"':'"', "'":''' }[c]));
|
|
212
|
+
const systemdQuote = (value) => `"${String(value).replace(/[%\\"\n\r]/g, (c) => c === '%' ? '%%' : c === '\n' || c === '\r' ? '' : `\\${c}`)}"`;
|
|
213
|
+
export function serviceEnvironmentConfig(kind) {
|
|
214
|
+
const environment = { SPACE_HOME: stateDir(), PATH: process.env.PATH || '/usr/bin:/bin' };
|
|
215
|
+
if (kind === 'darwin') return Object.entries(environment).map(([key, value]) => `<key>${xml(key)}</key><string>${xml(value)}</string>`).join('');
|
|
216
|
+
if (kind === 'linux') return Object.entries(environment).map(([key, value]) => `Environment=${systemdQuote(`${key}=${value}`)}`).join('\n');
|
|
217
|
+
throw new Error('Unsupported service platform');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function daemonService(action) {
|
|
221
|
+
if (!['install', 'uninstall', 'start', 'stop', 'restart', 'status'].includes(action)) throw new Error('Unknown daemon service action');
|
|
222
|
+
if (!['darwin', 'linux'].includes(platform())) throw new Error('Background services are supported on macOS and Linux. Use space daemon run on this platform.');
|
|
223
|
+
const paths = servicePaths();
|
|
224
|
+
const uid = process.getuid();
|
|
225
|
+
const target = `gui/${uid}/${paths.label}`;
|
|
226
|
+
if (action === 'install') {
|
|
227
|
+
const pkg = await metadata();
|
|
228
|
+
const prefix = join(stateDir(), 'install');
|
|
229
|
+
await mkdir(prefix, { recursive: true, mode: 0o700 });
|
|
230
|
+
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
231
|
+
await execute(npm, ['install', '--prefix', prefix, '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund', `@ravefox/space@${pkg.version}`], { timeout: 180000, maxBuffer: 1024 * 1024 });
|
|
232
|
+
const installed = join(prefix, 'node_modules/@ravefox/space');
|
|
233
|
+
const manifest = JSON.parse(await readFile(join(installed, 'package.json'), 'utf8'));
|
|
234
|
+
const bin = manifest.bin?.space;
|
|
235
|
+
if (manifest.name !== '@ravefox/space' || manifest.version !== pkg.version || typeof bin !== 'string' || isAbsolute(bin) || relative(installed, resolve(installed, bin)).startsWith('..')) throw new Error('Invalid installed Space package');
|
|
236
|
+
const entry = await realpath(resolve(installed, bin));
|
|
237
|
+
if (relative(await realpath(installed), entry).startsWith('..')) throw new Error('Space command escapes its installation');
|
|
238
|
+
await writeFile(paths.log, '', { flag: 'a', mode: 0o600 }); await chmod(paths.log, 0o600);
|
|
239
|
+
if (platform() === 'darwin') {
|
|
240
|
+
await mkdir(dirname(paths.plist), { recursive: true });
|
|
241
|
+
const args = [process.execPath, entry, 'daemon', 'run'].map((s) => `<string>${xml(s)}</string>`).join('');
|
|
242
|
+
await writeFile(paths.plist, `<?xml version="1.0"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict><key>Label</key><string>${paths.label}</string><key>ProgramArguments</key><array>${args}</array><key>RunAtLoad</key><true/><key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict><key>ThrottleInterval</key><integer>30</integer><key>EnvironmentVariables</key><dict>${serviceEnvironmentConfig('darwin')}</dict><key>StandardOutPath</key><string>${xml(paths.log)}</string><key>StandardErrorPath</key><string>${xml(paths.log)}</string></dict></plist>`, { mode: 0o600 });
|
|
243
|
+
} else {
|
|
244
|
+
await mkdir(dirname(paths.unit), { recursive: true });
|
|
245
|
+
await writeFile(paths.unit, `[Unit]\nDescription=Space agent connections\nAfter=network-online.target\n[Service]\nExecStart=${[process.execPath, entry, 'daemon', 'run'].map(systemdQuote).join(' ')}\nRestart=on-failure\nRestartSec=30\nUMask=0077\n${serviceEnvironmentConfig('linux')}\nStandardOutput=${systemdQuote(`append:${paths.log}`)}\nStandardError=${systemdQuote(`append:${paths.log}`)}\n[Install]\nWantedBy=default.target\n`, { mode: 0o600 });
|
|
246
|
+
await execute('systemctl', ['--user', 'daemon-reload']);
|
|
247
|
+
}
|
|
248
|
+
return { installed: true };
|
|
249
|
+
}
|
|
250
|
+
if (platform() === 'darwin') {
|
|
251
|
+
if (action === 'start') await execute('launchctl', ['bootstrap', `gui/${uid}`, paths.plist]);
|
|
252
|
+
else if (action === 'restart') { await execute('launchctl', ['bootout', target]).catch(() => {}); await execute('launchctl', ['bootstrap', `gui/${uid}`, paths.plist]); }
|
|
253
|
+
else if (action === 'status') return (await execute('launchctl', ['print', target])).stdout;
|
|
254
|
+
else { await execute('launchctl', ['bootout', target]).catch(() => {}); if (action === 'uninstall') await unlink(paths.plist).catch((e) => { if (e.code !== 'ENOENT') throw e; }); }
|
|
255
|
+
} else {
|
|
256
|
+
if (action === 'start') await execute('systemctl', ['--user', 'enable', '--now', 'space.service']);
|
|
257
|
+
else if (action === 'uninstall') { await execute('systemctl', ['--user', 'disable', '--now', 'space.service']); await unlink(paths.unit).catch((e) => { if (e.code !== 'ENOENT') throw e; }); await execute('systemctl', ['--user', 'daemon-reload']); }
|
|
258
|
+
else if (action === 'status') return (await execute('systemctl', ['--user', 'status', 'space.service'])).stdout;
|
|
259
|
+
else { if (action === 'restart') await execute('systemctl', ['--user', 'enable', 'space.service']); await execute('systemctl', ['--user', action, 'space.service']); }
|
|
260
|
+
}
|
|
261
|
+
return { action };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export async function setupDaemon(options = {}) {
|
|
265
|
+
if (!['darwin', 'linux'].includes(platform())) throw new Error('Background setup is supported on macOS and Linux. Use space agent run on this platform.');
|
|
266
|
+
const registered = await registerMachine(options);
|
|
267
|
+
await daemonService('install'); await daemonService('restart');
|
|
268
|
+
return { ...registered, background: true };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function daemonLogs({ lines = 50 } = {}) {
|
|
272
|
+
const count = Math.max(1, Math.min(1000, Number(lines) || 50));
|
|
273
|
+
let file;
|
|
274
|
+
try {
|
|
275
|
+
file = await open(servicePaths().log, 'r');
|
|
276
|
+
const size = (await file.stat()).size;
|
|
277
|
+
const length = Math.min(size, 1024 * 1024), data = Buffer.alloc(length);
|
|
278
|
+
await file.read(data, 0, length, size - length);
|
|
279
|
+
return data.toString('utf8').split('\n').slice(-count).join('\n');
|
|
280
|
+
}
|
|
281
|
+
catch (e) { if (e.code === 'ENOENT') return ''; throw e; }
|
|
282
|
+
finally { await file?.close(); }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function unregisterMachine({ instance } = {}) {
|
|
286
|
+
const machine = readState('machine.json');
|
|
287
|
+
if (!machine) return { removed: false };
|
|
288
|
+
let session = sessionFor(instance || machine.instanceUrl);
|
|
289
|
+
if (!session?.token) session = await login({ instance: machine.instanceUrl });
|
|
290
|
+
if (normalizeInstance(session.url) !== machine.instanceUrl || (machine.ownerUserId && machine.ownerUserId !== (session.user?.id || session.user?._id))) throw new Error('Sign in as the owner on the registered server.');
|
|
291
|
+
await new SpaceClient(session).delete(`/api/machines/${machine.machineDbId}`);
|
|
292
|
+
await daemonService('stop'); deleteState('machine.json'); deleteState('daemon-status.json');
|
|
293
|
+
return { removed: true };
|
|
294
|
+
}
|
package/lib/harness.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Copyright 2026 Ravefox. SPDX-License-Identifier: MIT
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { access, realpath, readFile, mkdir, copyFile, chmod, lstat } from 'node:fs/promises';
|
|
4
|
+
import { constants } from 'node:fs';
|
|
5
|
+
import { delimiter, join, dirname, resolve } from 'node:path';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { stateDir } from './store.js';
|
|
10
|
+
|
|
11
|
+
export const ADAPTERS = ['codex', 'claude'];
|
|
12
|
+
const entry = fileURLToPath(new URL('../bin/space.js', import.meta.url));
|
|
13
|
+
|
|
14
|
+
export async function executable(name, env = process.env) {
|
|
15
|
+
if (!ADAPTERS.includes(name)) throw new Error(`Unsupported adapter: ${name}. Supported: ${ADAPTERS.join(', ')}`);
|
|
16
|
+
for (const directory of (env.PATH || '').split(delimiter).filter(Boolean)) {
|
|
17
|
+
const candidate = join(directory, name);
|
|
18
|
+
try { await access(candidate, constants.X_OK); return await realpath(candidate); } catch { /* next PATH entry */ }
|
|
19
|
+
}
|
|
20
|
+
throw new Error(`${name} is not installed. Install it and sign in on this computer first.`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function availableAdapters() {
|
|
24
|
+
const result = [];
|
|
25
|
+
for (const name of ADAPTERS) { try { await executable(name); result.push(name); } catch { /* absent */ } }
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Native helpers must live outside a denied credential directory. Copy only
|
|
30
|
+
// executable bytes; model credentials remain in the provider's own storage.
|
|
31
|
+
export async function codexExecutable() {
|
|
32
|
+
const original = await executable('codex');
|
|
33
|
+
const credentialRoot = resolve(process.env.CODEX_HOME || join(homedir(), '.codex'));
|
|
34
|
+
if (!original.startsWith(credentialRoot + '/')) return original;
|
|
35
|
+
const bytes = await readFile(original);
|
|
36
|
+
const signature = bytes.subarray(0, 4).toString('hex');
|
|
37
|
+
if (!['7f454c46', 'cffaedfe', 'feedfacf', 'cafebabe'].includes(signature)) {
|
|
38
|
+
throw new Error('Codex inside its credential directory must be a standalone native executable. Install the official standalone Codex CLI.');
|
|
39
|
+
}
|
|
40
|
+
const folder = join(homedir(), '.local', 'share', 'space', 'executables', createHash('sha256').update(bytes).digest('hex'));
|
|
41
|
+
await mkdir(folder, { recursive: true, mode: 0o700 });
|
|
42
|
+
const stat = await lstat(folder);
|
|
43
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid())) throw new Error('Unsafe executable cache');
|
|
44
|
+
const target = join(folder, 'codex');
|
|
45
|
+
try {
|
|
46
|
+
const existing = await lstat(target);
|
|
47
|
+
if (!existing.isFile() || existing.isSymbolicLink()) throw new Error('Unsafe cached Codex executable');
|
|
48
|
+
if (createHash('sha256').update(await readFile(target)).digest('hex') !== createHash('sha256').update(bytes).digest('hex')) throw new Error('Cached Codex executable changed');
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error.code !== 'ENOENT') throw error;
|
|
51
|
+
await copyFile(original, target, constants.COPYFILE_EXCL);
|
|
52
|
+
}
|
|
53
|
+
await chmod(target, 0o700);
|
|
54
|
+
return target;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function providerEnvironment(env = process.env) {
|
|
58
|
+
const names = ['PATH', 'HOME', 'USER', 'LOGNAME', 'LANG', 'LC_ALL', 'TERM', 'TMPDIR', 'CODEX_HOME', 'OPENAI_API_KEY', 'CODEX_API_KEY', 'ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN', 'HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY', 'SSL_CERT_FILE', 'NODE_EXTRA_CA_CERTS'];
|
|
59
|
+
return Object.fromEntries(names.filter(name => typeof env[name] === 'string').map(name => [name, env[name]]));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const toml = value => Array.isArray(value) ? `[${value.map(toml).join(',')}]`
|
|
63
|
+
: value && typeof value === 'object' ? `{${Object.entries(value).map(([key, item]) => `${JSON.stringify(key)}=${toml(item)}`).join(',')}}`
|
|
64
|
+
: JSON.stringify(value);
|
|
65
|
+
const setting = (key, value) => ['-c', `${key}=${toml(value)}`];
|
|
66
|
+
|
|
67
|
+
export function codexPermissions(record = {}, runtimeRoots = []) {
|
|
68
|
+
const mode = record.sandbox || 'read-only';
|
|
69
|
+
if (!['read-only', 'workspace-write'].includes(mode)) throw new Error('Space supports read-only or workspace-write; unrestricted model access is not supported.');
|
|
70
|
+
const args = [
|
|
71
|
+
...setting('default_permissions', 'space'),
|
|
72
|
+
...setting('approval_policy', 'never'),
|
|
73
|
+
...setting('shell_environment_policy.inherit', 'none'),
|
|
74
|
+
];
|
|
75
|
+
const denied = new Set([
|
|
76
|
+
stateDir(), join(homedir(), '.commonly'),
|
|
77
|
+
process.env.CODEX_HOME || join(homedir(), '.codex'),
|
|
78
|
+
...['.ssh', '.aws', '.azure', '.kube', '.config/gcloud', '.npmrc', '.claude'].map(p => join(homedir(), p)),
|
|
79
|
+
]);
|
|
80
|
+
// Override the whole table: CLI dotted-key parsing must not split periods
|
|
81
|
+
// inside literal filesystem paths such as ~/.space or ~/.codex.
|
|
82
|
+
const filesystem = {
|
|
83
|
+
':minimal': 'read',
|
|
84
|
+
[resolve(record.workspacePath || process.cwd())]: mode === 'workspace-write' ? 'write' : 'read',
|
|
85
|
+
[join(homedir(), '.local', 'share', 'space', 'executables')]: 'read',
|
|
86
|
+
[dirname(process.execPath)]: 'read',
|
|
87
|
+
...Object.fromEntries(runtimeRoots.map(path => [resolve(path), 'read'])),
|
|
88
|
+
...Object.fromEntries([...denied].map(path => [resolve(path), 'deny'])),
|
|
89
|
+
};
|
|
90
|
+
args.push(...setting('permissions', { space: { network: { enabled: false }, filesystem } }));
|
|
91
|
+
return args;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function harnessArguments({ record, recordName, podId, executablePath }) {
|
|
95
|
+
if (!recordName || recordName.includes('..') || !/^turns\/[a-z0-9-]+\.json$/.test(recordName)) throw new Error('Invalid private turn reference');
|
|
96
|
+
if (!record.workspacePath) throw new Error('An explicit workspace is required');
|
|
97
|
+
const mcpArgs = [entry, 'mcp', 'serve', '--record', recordName, '--pod', String(podId)];
|
|
98
|
+
if (record.adapter === 'codex') {
|
|
99
|
+
return ['exec', '--strict-config', '--ignore-user-config', '--ignore-rules', '--ephemeral', '--json', '--skip-git-repo-check', '-C', record.workspacePath,
|
|
100
|
+
...codexPermissions(record, executablePath ? [executablePath.endsWith('.js') ? dirname(dirname(dirname(executablePath))) : dirname(executablePath)] : []),
|
|
101
|
+
...setting('mcp_servers.space.command', process.execPath),
|
|
102
|
+
...setting('mcp_servers.space.args', mcpArgs),
|
|
103
|
+
...setting('mcp_servers.space.required', true),
|
|
104
|
+
...setting('mcp_servers.space.default_tools_approval_mode', 'approve'),
|
|
105
|
+
...setting('mcp_servers.space.env', { SPACE_HOME: stateDir() }),
|
|
106
|
+
...(record.effort ? setting('model_reasoning_effort', record.effort) : []),
|
|
107
|
+
...(record.model ? ['--model', record.model] : []), '-'];
|
|
108
|
+
}
|
|
109
|
+
if (record.adapter === 'claude') {
|
|
110
|
+
return ['--print', '--output-format', 'json', '--no-session-persistence', '--permission-mode', 'dontAsk', '--tools', '',
|
|
111
|
+
'--setting-sources', '', '--disable-slash-commands', '--settings', JSON.stringify({ disableAllHooks: true, enabledPlugins: {}, autoMemoryEnabled: false, claudeMdExcludes: ['**'], syncClaudeAiSkills: false, syncClaudeAiPlugins: false, disableClaudeAiConnectors: true }),
|
|
112
|
+
'--strict-mcp-config', '--mcp-config', JSON.stringify({ mcpServers: { space: { command: process.execPath, args: mcpArgs, env: { SPACE_HOME: stateDir() } } } }),
|
|
113
|
+
'--allowedTools', 'mcp__space__*', ...(record.model ? ['--model', record.model] : []), ...(record.effort ? ['--effort', record.effort] : [])];
|
|
114
|
+
}
|
|
115
|
+
throw new Error(`Unsupported adapter: ${record.adapter}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function decodeResponse(adapter, output) {
|
|
119
|
+
if (adapter === 'claude') {
|
|
120
|
+
const value = JSON.parse(output);
|
|
121
|
+
if (value.is_error) throw new Error('Claude could not complete this turn. Check model authorization and quota.');
|
|
122
|
+
return String(value.result || '').trim();
|
|
123
|
+
}
|
|
124
|
+
let text = '';
|
|
125
|
+
let completed = false;
|
|
126
|
+
for (const line of output.split('\n').filter(Boolean)) {
|
|
127
|
+
const value = JSON.parse(line);
|
|
128
|
+
if (value.type === 'turn.failed' || value.type === 'error') throw new Error('Codex could not complete this turn. Check model authorization and quota.');
|
|
129
|
+
if (value.type === 'item.completed' && value.item?.type === 'agent_message') text = String(value.item.text || '');
|
|
130
|
+
if (value.type === 'turn.completed') completed = true;
|
|
131
|
+
}
|
|
132
|
+
if (!completed) throw new Error('Codex ended before completing the turn');
|
|
133
|
+
return text.trim();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function runHarness({ record, prompt, podId, recordName, signal, timeoutMs = 600000, spawnProcess = spawn }) {
|
|
137
|
+
const command = record.adapter === 'codex' ? await codexExecutable() : await executable(record.adapter);
|
|
138
|
+
const args = harnessArguments({ record, podId, recordName, executablePath: command });
|
|
139
|
+
if (signal?.aborted) throw signal.reason || new Error('Stopped');
|
|
140
|
+
return await new Promise((accept, reject) => {
|
|
141
|
+
const child = spawnProcess(command, args, { cwd: record.workspacePath, env: providerEnvironment(), stdio: ['pipe', 'pipe', 'pipe'], detached: process.platform !== 'win32', shell: false });
|
|
142
|
+
let output = '', bytes = 0, ended = false, failure = null, killer;
|
|
143
|
+
const terminate = (error) => {
|
|
144
|
+
failure ||= error;
|
|
145
|
+
try { process.platform === 'win32' ? child.kill('SIGTERM') : process.kill(-child.pid, 'SIGTERM'); } catch { /* already exited */ }
|
|
146
|
+
killer ||= setTimeout(() => { try { process.platform === 'win32' ? child.kill('SIGKILL') : process.kill(-child.pid, 'SIGKILL'); } catch { /* exited */ } }, 5000);
|
|
147
|
+
killer.unref?.();
|
|
148
|
+
};
|
|
149
|
+
const abort = () => terminate(signal.reason || new Error('Stopped'));
|
|
150
|
+
const timer = setTimeout(() => terminate(new Error('Model turn exceeded ten minutes')), timeoutMs);
|
|
151
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
152
|
+
child.stdout.on('data', chunk => { bytes += chunk.length; if (bytes > 8 * 1024 * 1024) terminate(new Error('Model output exceeded 8 MiB')); else output += chunk; });
|
|
153
|
+
// Provider stderr can contain prompt fragments; never forward it into
|
|
154
|
+
// shared logs or user chat. The status and private provider logs suffice.
|
|
155
|
+
child.stderr.on('data', () => {});
|
|
156
|
+
const finish = (error, code) => {
|
|
157
|
+
if (ended) return; ended = true; clearTimeout(timer); clearTimeout(killer); signal?.removeEventListener('abort', abort);
|
|
158
|
+
if (error || failure || code !== 0) return reject(error || failure || new Error(`${record.adapter} exited with status ${code}`));
|
|
159
|
+
try { accept(decodeResponse(record.adapter, output)); } catch (e) { reject(e); }
|
|
160
|
+
};
|
|
161
|
+
child.on('error', error => finish(error));
|
|
162
|
+
child.on('close', code => finish(null, code));
|
|
163
|
+
child.stdin.on('error', () => {});
|
|
164
|
+
child.stdin.end(prompt);
|
|
165
|
+
});
|
|
166
|
+
}
|