@ravefox/space 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/LICENSE +21 -205
  2. package/README.md +104 -31
  3. package/bin/space.js +113 -0
  4. package/lib/agents.js +181 -0
  5. package/lib/boardTools.js +89 -0
  6. package/lib/credentials.js +95 -0
  7. package/lib/daemon.js +294 -0
  8. package/lib/harness.js +166 -0
  9. package/lib/http.js +101 -0
  10. package/lib/login.js +68 -0
  11. package/lib/mcp.js +39 -0
  12. package/lib/runtime.js +253 -0
  13. package/lib/store.js +96 -0
  14. package/lib/tools.js +93 -0
  15. package/lib/workspaceSandbox.js +77 -0
  16. package/lib/workspaceTools.js +160 -0
  17. package/lib/workspaceWorker.js +17 -0
  18. package/package.json +27 -46
  19. package/NOTICE +0 -55
  20. package/assets/webhook-sdk/python/bot.py +0 -63
  21. package/assets/webhook-sdk/python/commonly.py +0 -232
  22. package/node_modules/@commonlyai/seats/README.md +0 -25
  23. package/node_modules/@commonlyai/seats/package.json +0 -22
  24. package/node_modules/@commonlyai/seats/src/adapters/hermes.js +0 -264
  25. package/node_modules/@commonlyai/seats/src/adapters/stub.js +0 -24
  26. package/node_modules/@commonlyai/seats/src/contract.js +0 -48
  27. package/node_modules/@commonlyai/seats/src/failures.js +0 -44
  28. package/node_modules/@commonlyai/seats/src/harness-plan.js +0 -329
  29. package/node_modules/@commonlyai/seats/src/index.js +0 -22
  30. package/node_modules/@commonlyai/seats/src/process-group.js +0 -269
  31. package/node_modules/@commonlyai/seats/src/record/format.js +0 -737
  32. package/node_modules/@commonlyai/seats/src/record/otel.js +0 -238
  33. package/node_modules/@commonlyai/seats/src/runtime-observation.js +0 -29
  34. package/node_modules/@commonlyai/seats/src/seat-model.js +0 -17
  35. package/node_modules/@commonlyai/seats/src/supervisor.js +0 -308
  36. package/node_modules/@commonlyai/seats/src/trace/events.js +0 -46
  37. package/node_modules/@commonlyai/seats/src/trace/store.js +0 -276
  38. package/node_modules/@commonlyai/seats/src/turn-processes.js +0 -226
  39. package/node_modules/@commonlyai/seats/src/upstream-refusal.js +0 -418
  40. package/node_modules/@commonlyai/seats/src/valet/env.js +0 -201
  41. package/node_modules/@commonlyai/seats/src/valet/grant.js +0 -123
  42. package/node_modules/@commonlyai/seats/src/valet/permissions.js +0 -213
  43. package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +0 -529
  44. package/skills/commonly/SKILL.md +0 -233
  45. package/src/commands/agent.js +0 -6334
  46. package/src/commands/daemon.js +0 -933
  47. package/src/commands/dev.js +0 -153
  48. package/src/commands/doctor.js +0 -141
  49. package/src/commands/inbox.js +0 -330
  50. package/src/commands/login.js +0 -194
  51. package/src/commands/mcp.js +0 -56
  52. package/src/commands/pod.js +0 -120
  53. package/src/commands/task.js +0 -279
  54. package/src/commands/verify.js +0 -48
  55. package/src/index.js +0 -99
  56. package/src/lib/adapter-login.js +0 -76
  57. package/src/lib/adapters/broker-bridge-entry.js +0 -76
  58. package/src/lib/adapters/claude.js +0 -2207
  59. package/src/lib/adapters/codex-broker-bridge.mjs +0 -298
  60. package/src/lib/adapters/codex.js +0 -1898
  61. package/src/lib/adapters/dsh.js +0 -513
  62. package/src/lib/adapters/goose.js +0 -510
  63. package/src/lib/adapters/hermes.js +0 -18
  64. package/src/lib/adapters/index.js +0 -68
  65. package/src/lib/adapters/opencode.js +0 -1681
  66. package/src/lib/adapters/pi-commonly-mcp.mjs +0 -71
  67. package/src/lib/adapters/pi-mcp-client.mjs +0 -617
  68. package/src/lib/adapters/pi-shell-env.mjs +0 -41
  69. package/src/lib/adapters/pi.js +0 -1025
  70. package/src/lib/adapters/qwen.js +0 -523
  71. package/src/lib/adapters/seat-model.js +0 -8
  72. package/src/lib/adapters/stub.js +0 -1
  73. package/src/lib/adapters/tested-version.js +0 -106
  74. package/src/lib/api.js +0 -205
  75. package/src/lib/claim-outcome.js +0 -128
  76. package/src/lib/claimed-work-gate.js +0 -73
  77. package/src/lib/cli-hint.js +0 -33
  78. package/src/lib/commit-attribution.js +0 -391
  79. package/src/lib/config.js +0 -155
  80. package/src/lib/content-refusal.js +0 -94
  81. package/src/lib/credential-file.js +0 -208
  82. package/src/lib/daemon-agent-states.js +0 -95
  83. package/src/lib/daemon-hint.js +0 -18
  84. package/src/lib/daemon-logs.js +0 -43
  85. package/src/lib/daemon-service-windows.js +0 -263
  86. package/src/lib/daemon-service.js +0 -486
  87. package/src/lib/daemon-state.js +0 -84
  88. package/src/lib/daemon-store.js +0 -119
  89. package/src/lib/daemon-supervisor.js +0 -629
  90. package/src/lib/daemon-watch.js +0 -115
  91. package/src/lib/declared-mcp-guard.js +0 -284
  92. package/src/lib/default-environment.js +0 -207
  93. package/src/lib/device-login.js +0 -126
  94. package/src/lib/digest-frame.js +0 -151
  95. package/src/lib/doctor.js +0 -291
  96. package/src/lib/egress-proxy.js +0 -160
  97. package/src/lib/enforcement.js +0 -946
  98. package/src/lib/environment.js +0 -681
  99. package/src/lib/first-output-watchdog.js +0 -191
  100. package/src/lib/git-readonly-server.js +0 -138
  101. package/src/lib/git-readonly.js +0 -463
  102. package/src/lib/git-remote-commonly.mjs +0 -100
  103. package/src/lib/grant-broker-guard.js +0 -345
  104. package/src/lib/hooks-config.js +0 -238
  105. package/src/lib/host-gh-broker.js +0 -152
  106. package/src/lib/host-gh-shim.mjs +0 -98
  107. package/src/lib/inbox.js +0 -483
  108. package/src/lib/instance-proxy.js +0 -76
  109. package/src/lib/machine-host.js +0 -29
  110. package/src/lib/mcp-credential-delivery.js +0 -121
  111. package/src/lib/mcp-first-run.js +0 -50
  112. package/src/lib/mcp-home.js +0 -644
  113. package/src/lib/mcp-ready.js +0 -68
  114. package/src/lib/mcp-server-version.js +0 -123
  115. package/src/lib/mcp-token.js +0 -36
  116. package/src/lib/mcp-warm-child.mjs +0 -412
  117. package/src/lib/memory-bridge.js +0 -191
  118. package/src/lib/memory-import.js +0 -156
  119. package/src/lib/missing-harness.js +0 -31
  120. package/src/lib/model-unavailable.js +0 -154
  121. package/src/lib/native-runtime-inventory.js +0 -40
  122. package/src/lib/outsider-grant-mcp.js +0 -82
  123. package/src/lib/own-key-launch.js +0 -42
  124. package/src/lib/pod-catch-up.js +0 -55
  125. package/src/lib/pod-focus.js +0 -224
  126. package/src/lib/pod-tail.js +0 -68
  127. package/src/lib/poll-liveness.js +0 -103
  128. package/src/lib/poll-retry.js +0 -81
  129. package/src/lib/poller.js +0 -200
  130. package/src/lib/provider-proxy.js +0 -42
  131. package/src/lib/public-seat-mcp-flag.js +0 -36
  132. package/src/lib/public-tool-error.js +0 -60
  133. package/src/lib/public-withheld-tools.js +0 -48
  134. package/src/lib/reply-receipt.js +0 -55
  135. package/src/lib/resolve-on-path.js +0 -22
  136. package/src/lib/runtime-inventory.js +0 -56
  137. package/src/lib/runtime-observation.js +0 -37
  138. package/src/lib/sandbox/bwrap.js +0 -275
  139. package/src/lib/sandbox/codex-perimeter.js +0 -16
  140. package/src/lib/sandbox/host-check.js +0 -268
  141. package/src/lib/sandbox/mode.js +0 -49
  142. package/src/lib/sandbox/opencode-loopback.mjs +0 -68
  143. package/src/lib/sandbox/seatbelt.js +0 -684
  144. package/src/lib/sandbox/turn-dir.js +0 -56
  145. package/src/lib/sandbox/windows-unsandboxed.js +0 -183
  146. package/src/lib/sandbox/workspace-git-config.js +0 -243
  147. package/src/lib/sandbox/workspace-pin.js +0 -158
  148. package/src/lib/seat-runtime-controls.js +0 -61
  149. package/src/lib/seat-stop.js +0 -97
  150. package/src/lib/server-skills.js +0 -32
  151. package/src/lib/session-recycle.js +0 -219
  152. package/src/lib/session-store.js +0 -215
  153. package/src/lib/skill-prompt.js +0 -74
  154. package/src/lib/skills-import.js +0 -113
  155. package/src/lib/spawn-credential.js +0 -402
  156. package/src/lib/spawn-retry.js +0 -311
  157. package/src/lib/stable-install.js +0 -176
  158. package/src/lib/supervised-turn.js +0 -352
  159. package/src/lib/task.js +0 -235
  160. package/src/lib/turn-failure-notice.js +0 -177
  161. package/src/lib/unconfined-seat-guard.js +0 -94
  162. package/src/lib/update-check.js +0 -192
  163. package/src/lib/upstream-refusal.js +0 -1
  164. package/src/lib/webhook-server.js +0 -109
  165. package/src/lib/windows-command.js +0 -393
  166. package/src/lib/workspace-git.js +0 -87
  167. package/src/lib/workspace-trust.js +0 -162
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) => ({ '<':'&lt;', '>':'&gt;', '&':'&amp;', '"':'&quot;', "'":'&apos;' }[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(), '.space'), join(homedir(), '.commonly'), join(homedir(), '.codex'),
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
+ }
package/lib/http.js ADDED
@@ -0,0 +1,101 @@
1
+ // Copyright 2026 Ravefox. SPDX-License-Identifier: MIT
2
+ import fs from 'node:fs/promises';
3
+ import { constants } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { normalizeInstance } from './store.js';
6
+
7
+ const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
8
+ export class SpaceHttpError extends Error {
9
+ constructor(status, code) { super(`Space API request failed (HTTP ${status})`); this.name = 'SpaceHttpError'; this.status = status; if (code) this.body = { code }; }
10
+ }
11
+ export class SpaceClient {
12
+ constructor({ url, token, timeoutMs = 30000, signal } = {}) {
13
+ this.url = normalizeInstance(url);
14
+ if (token !== undefined && (typeof token !== 'string' || /[\r\n]/.test(token))) throw new Error('Invalid Space token');
15
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2147483647) throw new Error('Invalid request timeout');
16
+ this.token = token; this.timeoutMs = timeoutMs; this.signal = signal;
17
+ }
18
+ endpoint(route, query = {}) {
19
+ const pathname = typeof route === 'string' ? route.split('?')[0] : '';
20
+ if (typeof route !== 'string' || !route.startsWith('/') || route.startsWith('//')
21
+ || /[\\\x00-\x20\x7f#]/.test(route) || /(?:^|\/)\.{1,2}(?:\/|$)/.test(pathname)
22
+ || /%(?:2e|2f|5c)/i.test(pathname)) throw new Error('Space API paths must be same-origin relative paths');
23
+ const target = new URL(`${this.url}${route}`);
24
+ if (target.origin !== new URL(this.url).origin || target.username || target.password) throw new Error('Invalid Space API origin');
25
+ for (const [key, value] of Object.entries(query)) {
26
+ if (value == null) continue;
27
+ for (const item of Array.isArray(value) ? value : [value]) target.searchParams.append(key, String(item));
28
+ }
29
+ return target;
30
+ }
31
+ async request(method, route, body, query, form = false) {
32
+ const target = this.endpoint(route, query);
33
+ const headers = { Accept: 'application/json', 'User-Agent': 'space-cli/0.4.0' };
34
+ if (this.token) headers.Authorization = `Bearer ${this.token}`;
35
+ if (body !== undefined && !form) headers['Content-Type'] = 'application/json';
36
+ const timeout = AbortSignal.timeout(this.timeoutMs);
37
+ const signal = this.signal ? AbortSignal.any([timeout, this.signal]) : timeout;
38
+ let response;
39
+ try {
40
+ response = await fetch(target, { method, headers, redirect: 'error', signal,
41
+ ...(body !== undefined ? { body: form ? body : JSON.stringify(body) } : {}) });
42
+ } catch {
43
+ if (this.signal?.aborted) throw new Error('Space request cancelled');
44
+ if (timeout.aborted) throw new Error('Space request timed out');
45
+ throw new Error('Unable to reach Space API');
46
+ }
47
+ // Keep only protocol control codes, never arbitrary response messages or
48
+ // private context. The runner must distinguish freshness from stale leases.
49
+ if (!response.ok) {
50
+ const parts = []; let count = 0, code;
51
+ try {
52
+ for await (const chunk of response.body || []) {
53
+ count += chunk.length; if (count > 16384) break; parts.push(chunk);
54
+ }
55
+ if (count <= 16384) {
56
+ const value = JSON.parse(Buffer.concat(parts).toString('utf8'));
57
+ if (['unseen_messages','stale_delivery','token_exists','delivery_id_required','rate_limited'].includes(value?.code)) code = value.code;
58
+ }
59
+ } catch { /* Status remains safe even if the error body is unreadable. */ }
60
+ throw new SpaceHttpError(response.status, code);
61
+ }
62
+ let size = 0;
63
+ const chunks = [];
64
+ try {
65
+ for await (const chunk of response.body || []) {
66
+ size += chunk.length;
67
+ if (size > MAX_RESPONSE_BYTES) throw new Error('Space API response is too large');
68
+ chunks.push(chunk);
69
+ }
70
+ } catch (error) {
71
+ if (signal.aborted) throw new Error(this.signal?.aborted ? 'Space request cancelled' : 'Space request timed out');
72
+ if (size > MAX_RESPONSE_BYTES) throw error;
73
+ throw new Error('Unable to read Space API response');
74
+ }
75
+ if (!size) return null;
76
+ try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); }
77
+ catch { throw new Error('Space API returned invalid JSON'); }
78
+ }
79
+ get(route, query = {}) { return this.request('GET', route, undefined, query); }
80
+ post(route, body) { return this.request('POST', route, body); }
81
+ put(route, body) { return this.request('PUT', route, body); }
82
+ patch(route, body) { return this.request('PATCH', route, body); }
83
+ delete(route, body) { return this.request('DELETE', route, body); }
84
+ async upload(route, { filePath, fields = {} }) {
85
+ this.endpoint(route);
86
+ const file = await fs.open(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
87
+ let bytes;
88
+ try {
89
+ const stat = await file.stat();
90
+ if (!stat.isFile() || stat.size > 64 * 1024 * 1024) throw new Error('Upload must be a regular file of at most 64 MiB');
91
+ bytes = await file.readFile();
92
+ if (bytes.length > 64 * 1024 * 1024) throw new Error('Upload is too large');
93
+ } finally { await file.close(); }
94
+ const form = new FormData();
95
+ for (const [key, value] of Object.entries(fields)) {
96
+ if (value !== undefined && value !== null) form.append(key, String(value));
97
+ }
98
+ form.append('file', new Blob([bytes]), path.basename(filePath));
99
+ return this.request('POST', route, form, undefined, true);
100
+ }
101
+ }
package/lib/login.js ADDED
@@ -0,0 +1,68 @@
1
+ // Copyright 2026 Ravefox. SPDX-License-Identifier: MIT
2
+ import { hostname } from 'node:os';
3
+ import { execFile } from 'node:child_process';
4
+ import { setTimeout as sleep } from 'node:timers/promises';
5
+ import { saveSession, sessionFor } from './store.js';
6
+ import { SpaceClient } from './http.js';
7
+
8
+ function openBrowser(url) {
9
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32' : 'xdg-open';
10
+ const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : process.platform === 'darwin' ? ['--', url] : [url];
11
+ execFile(command, args, { timeout: 10000, windowsHide: true }, () => {});
12
+ }
13
+ export async function login({ instance, open = true } = {}) {
14
+ const url = sessionFor(instance).url;
15
+ const controller = new AbortController();
16
+ const interrupt = () => controller.abort();
17
+ process.once('SIGINT', interrupt);
18
+ try {
19
+ const client = new SpaceClient({ url, signal: controller.signal });
20
+ const start = await client.post('/api/auth/device/start', { clientName: 'Space CLI', clientVersion: '0.4.0', hostname: hostname() });
21
+ if (!start || typeof start.deviceCode !== 'string' || !start.deviceCode || typeof start.userCode !== 'string'
22
+ || !/^[A-Z0-9-]{4,32}$/.test(start.userCode) || !Number.isFinite(start.expiresIn) || start.expiresIn <= 0
23
+ || !Number.isFinite(start.interval) || start.interval <= 0) throw new Error('Invalid Space device authorization response');
24
+ let verify;
25
+ try { verify = new URL(start.verifyUrl); } catch { throw new Error('Invalid Space verification URL'); }
26
+ if (verify.username || verify.password || (verify.protocol !== 'https:' && !(verify.protocol === 'http:'
27
+ && ['localhost', '127.0.0.1', '[::1]'].includes(verify.hostname)))) throw new Error('Invalid Space verification URL');
28
+ // Display only the public approval URL/code; the secret device code stays in memory.
29
+ process.stderr.write(`Open ${verify.href}\nEnter code: ${start.userCode}\n`);
30
+ if (open) openBrowser(verify.href);
31
+ const expires = Date.now() + Math.min(start.expiresIn, 3600) * 1000;
32
+ let interval = Math.max(1000, Math.min(start.interval * 1000, 60000));
33
+ while (Date.now() < expires) {
34
+ await sleep(Math.min(interval, expires - Date.now()), undefined, { signal: controller.signal });
35
+ if (Date.now() >= expires) break;
36
+ let result;
37
+ try { result = await client.post('/api/auth/device/poll', { deviceCode: start.deviceCode }); }
38
+ catch (error) {
39
+ if (error.status === 429) { interval = Math.min(interval + 5000, 60000); continue; }
40
+ throw error;
41
+ }
42
+ switch (result?.status) {
43
+ case 'authorized': {
44
+ if (controller.signal.aborted) throw new Error('Space login cancelled');
45
+ if (Date.now() >= expires) throw new Error('Space login expired; run login again');
46
+ const session = saveSession({ url, token: result.token,
47
+ user: { id: result.userId, username: result.username } });
48
+ process.stderr.write('Space login complete.\n');
49
+ return session;
50
+ }
51
+ case 'authorization_pending': case 'pending': break;
52
+ case 'slow_down': interval = Math.min(interval + 5000, 60000); break;
53
+ case 'denied': throw new Error('Space login was denied');
54
+ case 'expired': throw new Error('Space login expired; run login again');
55
+ case 'already_used': throw new Error('Space login code was already used; run login again');
56
+ default: throw new Error('Space login was not authorized; run login again');
57
+ }
58
+ }
59
+ throw new Error('Space login expired; run login again');
60
+ } catch (error) {
61
+ if (controller.signal.aborted) throw new Error('Space login cancelled');
62
+ throw error;
63
+ } finally { process.removeListener('SIGINT', interrupt); }
64
+ }
65
+ export async function ensureLogin(options = {}) {
66
+ const session = sessionFor(options.instance);
67
+ return session.token ? session : login({ ...options, instance: session.url });
68
+ }