@shardflux/cli 0.13.6 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/README.md +44 -4
- package/dist/acp-catalog.d.ts +7 -0
- package/dist/acp-catalog.js +40 -0
- package/dist/acp-journal.d.ts +18 -0
- package/dist/acp-journal.js +232 -0
- package/dist/acp-models.d.ts +36 -0
- package/dist/acp-models.js +161 -0
- package/dist/acp-rpc.d.ts +41 -0
- package/dist/acp-rpc.js +115 -0
- package/dist/acp.d.ts +29 -0
- package/dist/acp.js +464 -0
- package/dist/agent-commands.d.ts +10 -2
- package/dist/agent-commands.js +37 -18
- package/dist/agent-machine.d.ts +6 -0
- package/dist/agent-machine.js +93 -0
- package/dist/agent-repository.d.ts +2 -0
- package/dist/agent-repository.js +42 -31
- package/dist/agent-state.d.ts +3 -0
- package/dist/agent-state.js +33 -12
- package/dist/agent-target-commands.d.ts +4 -1
- package/dist/agent-target-commands.js +110 -40
- package/dist/agent-targets.d.ts +6 -4
- package/dist/agent-targets.js +72 -31
- package/dist/args.d.ts +1 -1
- package/dist/args.js +53 -14
- package/dist/command-center-actions.js +26 -3
- package/dist/command-center.js +20 -4
- package/dist/conversation-commands.d.ts +3 -0
- package/dist/conversation-commands.js +36 -0
- package/dist/conversation-local-resume.d.ts +8 -0
- package/dist/conversation-local-resume.js +56 -0
- package/dist/conversation-local.d.ts +7 -0
- package/dist/conversation-local.js +61 -0
- package/dist/conversation-router.d.ts +12 -0
- package/dist/conversation-router.js +92 -0
- package/dist/conversation-stash.d.ts +11 -0
- package/dist/conversation-stash.js +158 -0
- package/dist/conversation-terminal.d.ts +3 -0
- package/dist/conversation-terminal.js +74 -0
- package/dist/conversation-worktree.d.ts +4 -0
- package/dist/conversation-worktree.js +22 -0
- package/dist/conversations.d.ts +53 -0
- package/dist/conversations.js +119 -0
- package/dist/device-bridge.d.ts +80 -0
- package/dist/device-bridge.js +409 -0
- package/dist/device-helper.d.ts +3 -0
- package/dist/device-helper.js +104 -0
- package/dist/errors.js +5 -0
- package/dist/format.js +3 -2
- package/dist/git-remote-bin.js +3 -1
- package/dist/git-remote.d.ts +3 -1
- package/dist/git-remote.js +2 -1
- package/dist/handlers.js +3 -1
- package/dist/main.js +15 -3
- package/dist/project-sync.d.ts +5 -1
- package/dist/project-sync.js +7 -6
- package/dist/t3-provider.d.ts +6 -0
- package/dist/t3-provider.js +99 -0
- package/dist/t3-runtime.d.ts +6 -0
- package/dist/t3-runtime.js +63 -0
- package/dist/target-lock.d.ts +3 -0
- package/dist/target-lock.js +143 -0
- package/package.json +3 -3
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from 'node:fs';
|
|
4
|
+
import { readFile, writeFile, mkdir, realpath, stat } from 'node:fs/promises';
|
|
5
|
+
import { createConnection, createServer } from 'node:net';
|
|
6
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
7
|
+
import { WorkspaceTunnels } from '@shardflux/sdk';
|
|
8
|
+
import { writePrivateFile } from "./credentials.js";
|
|
9
|
+
import { conversationName, conversationWorkspace, getConversation, localConversations } from "./conversations.js";
|
|
10
|
+
import { gitRunner } from "./agent-git.js";
|
|
11
|
+
import { shardRemote } from "./git-remote.js";
|
|
12
|
+
import { writeSyncBlob } from "./project-sync-transfer.js";
|
|
13
|
+
import { UsageError } from "./errors.js";
|
|
14
|
+
import { conversationClient } from "./conversation-commands.js";
|
|
15
|
+
import { withTargetLock } from "./target-lock.js";
|
|
16
|
+
import { checkConversationWorktree, rememberConversationWorktree } from "./conversation-worktree.js";
|
|
17
|
+
const LIMIT = 256 << 20, OUTPUT = (192 << 10) - 128;
|
|
18
|
+
const inside = (root, target) => target === root || target.startsWith(root + sep);
|
|
19
|
+
const safeId = (value) => { if (!/^[a-zA-Z0-9_-]{1,96}$/.test(value))
|
|
20
|
+
throw new Error('Invalid device request identity'); return value; };
|
|
21
|
+
function canonicalDevicePath(target) {
|
|
22
|
+
let ancestor = target;
|
|
23
|
+
while (!existsSync(ancestor)) {
|
|
24
|
+
const parent = dirname(ancestor);
|
|
25
|
+
if (parent === ancestor)
|
|
26
|
+
throw new UsageError('Device path is unavailable.');
|
|
27
|
+
ancestor = parent;
|
|
28
|
+
}
|
|
29
|
+
return resolve(realpathSync(ancestor), relative(ancestor, target));
|
|
30
|
+
}
|
|
31
|
+
/** Resolve existing ancestors even before the conversation worktree is created. */
|
|
32
|
+
export function devicePath(root, requested = '.') {
|
|
33
|
+
if (/[\0\r\n]/.test(requested))
|
|
34
|
+
throw new UsageError('Device path contains invalid characters.');
|
|
35
|
+
const base = canonicalDevicePath(resolve(root)), target = isAbsolute(requested) ? resolve(requested) : resolve(base, requested);
|
|
36
|
+
const canonical = canonicalDevicePath(target);
|
|
37
|
+
return { path: canonical, outside: !inside(base, canonical) };
|
|
38
|
+
}
|
|
39
|
+
export class DeviceGrants {
|
|
40
|
+
file;
|
|
41
|
+
constructor(dir) { this.file = join(dir, 'device-grants.json'); }
|
|
42
|
+
key(api, project) { return JSON.stringify([api, project]); }
|
|
43
|
+
read() {
|
|
44
|
+
try {
|
|
45
|
+
if (lstatSync(this.file).isSymbolicLink() || (statSync(this.file).mode & 0o077))
|
|
46
|
+
throw new UsageError('Device grants must be a private file.');
|
|
47
|
+
const value = JSON.parse(readFileSync(this.file, 'utf8'));
|
|
48
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
49
|
+
throw new UsageError('Device grants must be an object.');
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
if (e.code === 'ENOENT')
|
|
54
|
+
return {};
|
|
55
|
+
throw e;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
allows(api, project, root, kind, outside) {
|
|
59
|
+
const grant = this.read()[this.key(api, project)];
|
|
60
|
+
return !outside && grant?.root === realpathSync(root) && Array.isArray(grant.kinds) && grant.kinds.includes(kind);
|
|
61
|
+
}
|
|
62
|
+
async save(api, project, root, kind) {
|
|
63
|
+
await withTargetLock(join(dirname(this.file), 'device-grants-lock'), () => {
|
|
64
|
+
const all = this.read(), key = this.key(api, project), canonical = realpathSync(root);
|
|
65
|
+
all[key] = { root: canonical, kinds: [...new Set([...(all[key]?.root === canonical ? all[key].kinds : []), kind])] };
|
|
66
|
+
writePrivateFile(dirname(this.file), this.file, JSON.stringify(all) + '\n');
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export function deviceSummary(r) {
|
|
71
|
+
if (tryLocallyAction(r))
|
|
72
|
+
return tryLocallyAction(r) === 'stop' ? 'Stop this conversation’s local sync and ports.' : 'Try this conversation in an isolated local worktree with two-way sync and localhost ports.';
|
|
73
|
+
if (r.kind === 'exec')
|
|
74
|
+
return `Run on your Mac: ${JSON.stringify(r.exec?.argv)} · directory ${r.exec?.cwd_relative ?? '.'}`;
|
|
75
|
+
if (r.files)
|
|
76
|
+
return `${r.kind === 'files_pull' ? 'Send to VM' : 'Save on your Mac'}: ${r.files.local_path_relative} ↔ ${r.files.remote_path}`;
|
|
77
|
+
return `${r.kind === 'port_forward' ? 'Open VM port on your Mac' : 'Reach your Mac from VM'}: ${r.port?.remote_port} ↔ localhost:${r.port?.local_port}`;
|
|
78
|
+
}
|
|
79
|
+
export function tryLocallyAction(r) {
|
|
80
|
+
if (r.kind === 'try_locally')
|
|
81
|
+
return 'start';
|
|
82
|
+
const argv = r.exec?.argv;
|
|
83
|
+
if (r.kind !== 'exec' || !argv || argv[0] !== 'shard' || argv[1] !== 'conversations' || argv[2] !== 'try' || argv[3] !== r.conversation_id)
|
|
84
|
+
return null;
|
|
85
|
+
if (argv.length === 4)
|
|
86
|
+
return 'start';
|
|
87
|
+
return argv.length === 5 && argv[4] === '--stop' ? 'stop' : null;
|
|
88
|
+
}
|
|
89
|
+
export async function ensureConversationWorktree(ctx, c, folder, ws) {
|
|
90
|
+
return withTargetLock(join(ctx.store.dir, 'conversation-worktree-lock'), async () => {
|
|
91
|
+
const run = gitRunner(folder, { ...process.env, ...ctx.io.env, SHARDFLUX_CONFIG_DIR: ctx.store.dir, SHARDFLUX_API_URL: ctx.apiUrl }, ctx.signal);
|
|
92
|
+
const name = conversationName(c), worktree = join(folder, '.shard', 'worktrees', name);
|
|
93
|
+
if (existsSync(worktree))
|
|
94
|
+
checkConversationWorktree(ctx, c, folder, worktree);
|
|
95
|
+
const source = ws ?? await conversationWorkspace(ctx, c);
|
|
96
|
+
await run(['-c', 'core.hooksPath=/dev/null', 'fetch', '--no-tags', shardRemote(source.key, '/shardflux/project/git/hub.git'), `+refs/heads/${c.branch}:refs/remotes/shardflux/${name}`]);
|
|
97
|
+
if (!existsSync(worktree)) {
|
|
98
|
+
await mkdir(dirname(worktree), { recursive: true });
|
|
99
|
+
await run(['-c', 'core.hooksPath=/dev/null', 'worktree', 'add', '-b', c.branch, worktree, `refs/remotes/shardflux/${name}`]);
|
|
100
|
+
rememberConversationWorktree(ctx, c, folder, worktree);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
checkConversationWorktree(ctx, c, folder, worktree);
|
|
104
|
+
const work = gitRunner(worktree, { ...process.env, ...ctx.io.env }, ctx.signal);
|
|
105
|
+
if (await work(['symbolic-ref', '--short', 'HEAD']) !== c.branch || await work(['rev-parse', '--show-toplevel']) !== realpathSync(worktree))
|
|
106
|
+
throw new UsageError('Local conversation worktree belongs to other work.');
|
|
107
|
+
}
|
|
108
|
+
return realpathSync(worktree);
|
|
109
|
+
}, ctx.signal);
|
|
110
|
+
}
|
|
111
|
+
export async function deviceExec(argv, cwd, timeoutSeconds = 300, signal) {
|
|
112
|
+
if (!Array.isArray(argv) || argv.length < 1 || argv.length > 128 || argv.some(a => typeof a !== 'string' || a.includes('\0') || a.length > 65536) || !argv[0])
|
|
113
|
+
throw new UsageError('Device exec requires an argument array.');
|
|
114
|
+
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 3600)
|
|
115
|
+
throw new UsageError('Device timeout must be 1–3600 seconds.');
|
|
116
|
+
return new Promise((resolveResult, reject) => {
|
|
117
|
+
const child = spawn(argv[0], argv.slice(1), { cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32', env: process.env });
|
|
118
|
+
const stdout = [], stderr = [];
|
|
119
|
+
let failure;
|
|
120
|
+
const stop = () => { try {
|
|
121
|
+
if (child.pid && process.platform !== 'win32')
|
|
122
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
123
|
+
else
|
|
124
|
+
child.kill('SIGKILL');
|
|
125
|
+
}
|
|
126
|
+
catch { /* exited */ } };
|
|
127
|
+
const timeout = setTimeout(() => { failure = new Error('Device command timed out.'); stop(); }, timeoutSeconds * 1000);
|
|
128
|
+
const abort = () => { failure = new Error('Device command interrupted.'); stop(); };
|
|
129
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
130
|
+
const collect = (target) => { let bytes = 0; return (data) => { bytes += data.length; if (bytes > OUTPUT) {
|
|
131
|
+
failure = new Error('Device output exceeds 192 KiB per stream.');
|
|
132
|
+
stop();
|
|
133
|
+
}
|
|
134
|
+
else
|
|
135
|
+
target.push(data); }; };
|
|
136
|
+
child.stdout.on('data', collect(stdout));
|
|
137
|
+
child.stderr.on('data', collect(stderr));
|
|
138
|
+
child.once('error', error => { clearTimeout(timeout); signal?.removeEventListener('abort', abort); reject(error); });
|
|
139
|
+
child.once('close', code => {
|
|
140
|
+
clearTimeout(timeout);
|
|
141
|
+
signal?.removeEventListener('abort', abort);
|
|
142
|
+
resolveResult({ state: failure ? 'failed' : 'done', exit_code: code ?? 1, stdout_b64: Buffer.concat(stdout).toString('base64'), stderr_b64: Buffer.concat([...stderr, ...(failure ? [Buffer.from(failure.message)] : [])]).toString('base64') });
|
|
143
|
+
});
|
|
144
|
+
if (signal?.aborted)
|
|
145
|
+
abort();
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
export class DeviceExecutor {
|
|
149
|
+
#ctx;
|
|
150
|
+
#ask;
|
|
151
|
+
#deviceId;
|
|
152
|
+
#handles = new Map();
|
|
153
|
+
constructor(ctx, ask, deviceId) { this.#ctx = ctx; this.#ask = ask; this.#deviceId = deviceId; }
|
|
154
|
+
async result(r, result) {
|
|
155
|
+
await conversationClient(this.#ctx).deviceRequests.result(safeId(r.id), result, { idempotencyKey: `${this.#deviceId}:${r.id}:${result.state}`, signal: this.#ctx.signal });
|
|
156
|
+
}
|
|
157
|
+
async execute(r) {
|
|
158
|
+
const ctx = this.#ctx;
|
|
159
|
+
safeId(r.id);
|
|
160
|
+
if (r.state !== 'pending' || r.device_id !== this.#deviceId)
|
|
161
|
+
return;
|
|
162
|
+
const records = localConversations(ctx);
|
|
163
|
+
const matching = Object.values(records).filter(record => record.project_id === r.project_id);
|
|
164
|
+
const folders = new Set(matching.map(record => record.folder));
|
|
165
|
+
const record = records[r.conversation_id] ?? (folders.size === 1 ? matching[0] : undefined);
|
|
166
|
+
if (!record || record.project_id !== r.project_id)
|
|
167
|
+
return; // No inferred local path for unknown projects.
|
|
168
|
+
let c = await getConversation(ctx, r.conversation_id);
|
|
169
|
+
if (c.project_id !== r.project_id)
|
|
170
|
+
throw new Error('Device request project changed.');
|
|
171
|
+
const planned = devicePath(record.folder, join('.shard', 'worktrees', conversationName(c)));
|
|
172
|
+
const requested = r.exec?.cwd_relative ?? r.files?.local_path_relative ?? '.';
|
|
173
|
+
const target = devicePath(planned.path, requested), grants = new DeviceGrants(ctx.store.dir);
|
|
174
|
+
target.outside ||= planned.outside;
|
|
175
|
+
const choice = grants.allows(ctx.apiUrl, r.project_id, record.folder, r.kind, target.outside) ? 'once' : await this.#ask({ type: 'approval', request_id: r.id, project_id: r.project_id, conversation_id: c.id, summary: deviceSummary(r), outside: target.outside });
|
|
176
|
+
if (choice === 'deny') {
|
|
177
|
+
await this.result(r, { state: 'denied' });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (choice !== 'once' && choice !== 'always')
|
|
181
|
+
throw new Error('Device request was not approved.');
|
|
182
|
+
if (target.outside && choice === 'always')
|
|
183
|
+
throw new UsageError('An outside path requires Allow once for this request.');
|
|
184
|
+
if (choice === 'always')
|
|
185
|
+
await grants.save(ctx.apiUrl, r.project_id, record.folder, r.kind);
|
|
186
|
+
// Re-read after the approval, before claiming or touching data.
|
|
187
|
+
if (devicePath(record.folder, join('.shard', 'worktrees', conversationName(c))).path !== planned.path || devicePath(planned.path, requested).path !== target.path)
|
|
188
|
+
throw new Error('Device path changed during approval.');
|
|
189
|
+
const current = await getConversation(ctx, r.conversation_id);
|
|
190
|
+
if (current.project_id !== c.project_id || current.branch !== c.branch || current.state === 'done' || current.state === 'archived')
|
|
191
|
+
throw new Error('The conversation changed or ended during device approval.');
|
|
192
|
+
c = current;
|
|
193
|
+
await this.result(r, { state: 'approved' });
|
|
194
|
+
await this.result(r, { state: 'running' });
|
|
195
|
+
let ws;
|
|
196
|
+
try {
|
|
197
|
+
// Approval is independent of VM/Git availability. Bound setup and report its
|
|
198
|
+
// failure through the existing running -> failed transition.
|
|
199
|
+
const preparation = { ...ctx, signal: AbortSignal.any([ctx.signal, AbortSignal.timeout(30_000)]) };
|
|
200
|
+
ws = await conversationWorkspace(preparation, c);
|
|
201
|
+
const root = await ensureConversationWorktree(preparation, c, record.folder, ws);
|
|
202
|
+
if (root !== planned.path || devicePath(root, requested).path !== target.path)
|
|
203
|
+
throw new Error('Device path changed during preparation.');
|
|
204
|
+
const prepared = await getConversation(ctx, c.id);
|
|
205
|
+
if (prepared.project_id !== c.project_id || prepared.workspace_id !== c.workspace_id || prepared.branch !== c.branch || prepared.state === 'done' || prepared.state === 'archived')
|
|
206
|
+
throw new Error('The conversation changed or ended during device preparation.');
|
|
207
|
+
const cell = ws.cell();
|
|
208
|
+
let result;
|
|
209
|
+
if (r.kind === 'try_locally' || tryLocallyAction(r)) {
|
|
210
|
+
const action = tryLocallyAction(r);
|
|
211
|
+
for (const [id, handle] of this.#handles)
|
|
212
|
+
if (handle.conversation === c.id) {
|
|
213
|
+
await handle.stop();
|
|
214
|
+
this.#handles.delete(id);
|
|
215
|
+
}
|
|
216
|
+
if (action === 'stop') {
|
|
217
|
+
await this.result(r, { state: 'done', stdout_b64: Buffer.from('Local sync and ports stopped.').toString('base64') });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const { tryConversationLocally } = await import("./conversation-local.js");
|
|
221
|
+
const handle = await tryConversationLocally(ctx, c, record.folder);
|
|
222
|
+
this.#handles.set(r.id, { conversation: c.id, workspace: ws.id, async stop() { await handle.stop(); cell.close(); } });
|
|
223
|
+
result = { state: 'done', files_artifact: handle.folder };
|
|
224
|
+
}
|
|
225
|
+
else if (r.kind === 'exec') {
|
|
226
|
+
if (!r.exec)
|
|
227
|
+
throw new UsageError('Device exec has no command.');
|
|
228
|
+
result = await deviceExec(r.exec.argv, target.path, r.exec.timeout_seconds, ctx.signal);
|
|
229
|
+
}
|
|
230
|
+
else if (r.kind === 'files_pull' || r.kind === 'files_push') {
|
|
231
|
+
const f = r.files;
|
|
232
|
+
if (!f || !f.remote_path.startsWith('/') || /[\0\r\n]/.test(f.remote_path))
|
|
233
|
+
throw new UsageError('Device transfer requires an absolute VM path.');
|
|
234
|
+
const max = f.max_bytes ?? LIMIT;
|
|
235
|
+
if (!Number.isSafeInteger(max) || max < 1 || max > LIMIT)
|
|
236
|
+
throw new UsageError('Device transfer maximum is 256 MiB.');
|
|
237
|
+
if (r.kind === 'files_pull') {
|
|
238
|
+
if (!(await stat(target.path)).isFile() || (await stat(target.path)).size > max)
|
|
239
|
+
throw new UsageError('Local transfer file exceeds the requested size or is not a file.');
|
|
240
|
+
const bytes = await readFile(target.path);
|
|
241
|
+
if (bytes.length > max)
|
|
242
|
+
throw new UsageError('Local transfer file grew during reading.');
|
|
243
|
+
await writeSyncBlob(cell, f.remote_path, bytes);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
const info = await cell.files.stat(f.remote_path);
|
|
247
|
+
if (info.type !== 'file' || info.size > max)
|
|
248
|
+
throw new UsageError('VM transfer file exceeds the requested size or is not a file.');
|
|
249
|
+
const bytes = await cell.files.read(f.remote_path, { length: max + 1 });
|
|
250
|
+
if (bytes.length > max || bytes.length !== info.size)
|
|
251
|
+
throw new UsageError('VM transfer file changed during reading.');
|
|
252
|
+
await mkdir(dirname(target.path), { recursive: true, mode: 0o700 });
|
|
253
|
+
const parent = await realpath(dirname(target.path));
|
|
254
|
+
if (!target.outside && !inside(root, parent))
|
|
255
|
+
throw new Error('Transfer parent escaped the worktree.');
|
|
256
|
+
await writeFile(target.path, bytes, { mode: 0o600, flag: 'wx' }); // Existing files need a fresh explicit request, never silent clobber.
|
|
257
|
+
}
|
|
258
|
+
result = { state: 'done', files_artifact: r.kind === 'files_pull' ? f.remote_path : relative(root, target.path) };
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
if (!r.port || ![r.port.local_port, r.port.remote_port].every(p => Number.isInteger(p) && p > 0 && p <= 65535))
|
|
262
|
+
throw new UsageError('Device ports must be 1–65535.');
|
|
263
|
+
const handle = await devicePort(ws, cell, r.kind, r.port, ctx.signal);
|
|
264
|
+
this.#handles.set(r.id, { conversation: c.id, workspace: ws.id, stop: () => handle.stop() });
|
|
265
|
+
result = { state: 'done' };
|
|
266
|
+
}
|
|
267
|
+
await this.result(r, result);
|
|
268
|
+
}
|
|
269
|
+
catch (e) {
|
|
270
|
+
await this.result(r, { state: 'failed', stderr_b64: Buffer.from(e instanceof Error ? e.message : String(e)).toString('base64') });
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
if (!this.#handles.has(r.id))
|
|
274
|
+
ws?.cell().close();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
async reap() {
|
|
278
|
+
for (const [id, handle] of this.#handles) {
|
|
279
|
+
const c = await getConversation(this.#ctx, handle.conversation);
|
|
280
|
+
const file = join(this.#ctx.store.dir, 'device-stops', `${c.id}.json`);
|
|
281
|
+
let stopped = false;
|
|
282
|
+
if (existsSync(file)) {
|
|
283
|
+
const record = JSON.parse(readFileSync(file, 'utf8'));
|
|
284
|
+
stopped = record.api_url === this.#ctx.apiUrl;
|
|
285
|
+
}
|
|
286
|
+
if (stopped || c.state === 'done' || c.state === 'archived' || c.workspace_id !== handle.workspace) {
|
|
287
|
+
await handle.stop();
|
|
288
|
+
this.#handles.delete(id);
|
|
289
|
+
if (stopped)
|
|
290
|
+
rmSync(file);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
async close() { await Promise.all([...this.#handles.values()].map(h => h.stop())); this.#handles.clear(); }
|
|
295
|
+
}
|
|
296
|
+
export async function devicePort(ws, cell, kind, ports, signal) {
|
|
297
|
+
const tunnels = new WorkspaceTunnels(() => cell, () => ws.grantedTools);
|
|
298
|
+
if (kind === 'port_reverse') {
|
|
299
|
+
const handle = await tunnels.reverse({ remotePort: ports.remote_port, target: `127.0.0.1:${ports.local_port}`, bindAddress: '127.0.0.1', signal });
|
|
300
|
+
let stopping;
|
|
301
|
+
return { stop() { return stopping ??= handle.close().finally(() => cell.close()); } };
|
|
302
|
+
}
|
|
303
|
+
const peers = new Set();
|
|
304
|
+
const handles = new Set();
|
|
305
|
+
const server = createServer(socket => {
|
|
306
|
+
peers.add(socket);
|
|
307
|
+
socket.once('close', () => peers.delete(socket));
|
|
308
|
+
socket.on('error', () => socket.destroy());
|
|
309
|
+
void tunnels.forward({ remotePort: ports.remote_port, stream: socket, signal }).then(handle => { handles.add(handle); void handle.closed.finally(() => handles.delete(handle)).catch(() => socket.destroy()); }).catch(() => socket.destroy());
|
|
310
|
+
});
|
|
311
|
+
await new Promise((resolveListen, reject) => { server.once('error', reject); server.listen(ports.local_port, '127.0.0.1', resolveListen); });
|
|
312
|
+
let stopping;
|
|
313
|
+
const abort = () => { void stop().catch(() => undefined); };
|
|
314
|
+
const stop = () => stopping ??= (async () => { signal.removeEventListener('abort', abort); for (const socket of peers)
|
|
315
|
+
socket.destroy(); await Promise.all([...handles].map(h => h.close())); await new Promise(resolveClose => server.close(() => resolveClose())); cell.close(); })();
|
|
316
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
317
|
+
if (signal.aborted)
|
|
318
|
+
abort();
|
|
319
|
+
return { stop };
|
|
320
|
+
}
|
|
321
|
+
const approvalSocket = (dir, id) => {
|
|
322
|
+
const hash = createHash('sha256').update(resolve(dir)).update(safeId(id)).digest('hex').slice(0, 20);
|
|
323
|
+
const path = join(dir, 'acp-device', `${hash}.sock`);
|
|
324
|
+
return Buffer.byteLength(path) < 100 ? path : join('/tmp', `sf-device-${process.getuid?.() ?? 'user'}`, `${hash}.sock`);
|
|
325
|
+
};
|
|
326
|
+
export async function serveAcpDeviceApprovals(dir, id, ask) {
|
|
327
|
+
const path = approvalSocket(dir, id);
|
|
328
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
329
|
+
const parent = lstatSync(dirname(path));
|
|
330
|
+
if (parent.isSymbolicLink() || (parent.mode & 0o077) || (process.getuid && parent.uid !== process.getuid()))
|
|
331
|
+
throw new Error('Device approval socket directory must be private and owned by this user.');
|
|
332
|
+
const peers = new Set();
|
|
333
|
+
const server = createServer(socket => {
|
|
334
|
+
peers.add(socket);
|
|
335
|
+
socket.once('close', () => peers.delete(socket));
|
|
336
|
+
let body = '';
|
|
337
|
+
socket.setTimeout(3600_000, () => socket.destroy());
|
|
338
|
+
socket.on('error', () => socket.destroy());
|
|
339
|
+
socket.on('data', chunk => {
|
|
340
|
+
body += String(chunk);
|
|
341
|
+
if (body.length > 64 << 10)
|
|
342
|
+
socket.destroy();
|
|
343
|
+
if (!body.includes('\n'))
|
|
344
|
+
return;
|
|
345
|
+
socket.removeAllListeners('data');
|
|
346
|
+
let approval;
|
|
347
|
+
try {
|
|
348
|
+
approval = JSON.parse(body);
|
|
349
|
+
if (approval.conversation_id !== id || approval.type !== 'approval')
|
|
350
|
+
throw new Error('Invalid approval');
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
socket.destroy();
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
void ask(approval).then(choice => socket.end(JSON.stringify({ choice }) + '\n')).catch(() => socket.end('{"choice":"deny"}\n'));
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
await withTargetLock(join(dirname(path), `${id}-lock`), async () => {
|
|
360
|
+
if (existsSync(path)) {
|
|
361
|
+
const old = lstatSync(path);
|
|
362
|
+
if (!old.isSocket() || (process.getuid && old.uid !== process.getuid()))
|
|
363
|
+
throw new Error('Device approval socket belongs to other work.');
|
|
364
|
+
const active = await new Promise((resolveActive, reject) => {
|
|
365
|
+
const probe = createConnection(path);
|
|
366
|
+
probe.setTimeout(1000, () => { probe.destroy(); reject(new Error('Device approval socket owner is unverified.')); });
|
|
367
|
+
probe.once('connect', () => { probe.destroy(); resolveActive(true); });
|
|
368
|
+
probe.once('error', error => { if (error.code === 'ECONNREFUSED')
|
|
369
|
+
resolveActive(false);
|
|
370
|
+
else
|
|
371
|
+
reject(error); });
|
|
372
|
+
});
|
|
373
|
+
if (active)
|
|
374
|
+
throw new Error('This ACP conversation already has a laptop bridge.');
|
|
375
|
+
if (lstatSync(path).ino !== old.ino)
|
|
376
|
+
throw new Error('Device approval socket owner changed.');
|
|
377
|
+
rmSync(path);
|
|
378
|
+
}
|
|
379
|
+
await new Promise((resolveListen, reject) => { server.once('error', reject); server.listen(path, resolveListen); });
|
|
380
|
+
chmodSync(path, 0o600);
|
|
381
|
+
});
|
|
382
|
+
const inode = lstatSync(path).ino;
|
|
383
|
+
let stopping;
|
|
384
|
+
return { stop() { return stopping ??= (async () => { for (const peer of peers)
|
|
385
|
+
peer.destroy(); await new Promise(resolveClose => server.close(() => resolveClose())); if (existsSync(path) && lstatSync(path).ino === inode)
|
|
386
|
+
rmSync(path); })(); } };
|
|
387
|
+
}
|
|
388
|
+
export async function askConnectedAcp(dir, approval, signal) {
|
|
389
|
+
const path = approvalSocket(dir, approval.conversation_id);
|
|
390
|
+
if (!existsSync(path))
|
|
391
|
+
return null;
|
|
392
|
+
return new Promise((resolveChoice, reject) => {
|
|
393
|
+
const socket = createConnection(path);
|
|
394
|
+
let text = '';
|
|
395
|
+
const abort = () => { socket.destroy(); reject(signal.reason instanceof Error ? signal.reason : new Error('Device approval interrupted')); };
|
|
396
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
397
|
+
socket.on('connect', () => socket.write(JSON.stringify(approval) + '\n'));
|
|
398
|
+
socket.on('data', bytes => { text += String(bytes); if (text.length > 1024)
|
|
399
|
+
socket.destroy(); });
|
|
400
|
+
socket.once('error', () => { signal.removeEventListener('abort', abort); resolveChoice(null); });
|
|
401
|
+
socket.once('end', () => { signal.removeEventListener('abort', abort); try {
|
|
402
|
+
const choice = JSON.parse(text).choice;
|
|
403
|
+
resolveChoice(['once', 'always', 'deny'].includes(choice) ? choice : 'deny');
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
resolveChoice('deny');
|
|
407
|
+
} });
|
|
408
|
+
});
|
|
409
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
|
+
import { DeviceExecutor, askConnectedAcp } from "./device-bridge.js";
|
|
3
|
+
import { mirrorProjectConversations } from "./conversation-stash.js";
|
|
4
|
+
import { withTargetLock } from "./target-lock.js";
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { conversationsUnavailable } from "./conversations.js";
|
|
7
|
+
import { localConversations } from "./conversations.js";
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { writePrivateFile } from "./credentials.js";
|
|
11
|
+
import { conversationClient } from "./conversation-commands.js";
|
|
12
|
+
import { Readable } from 'node:stream';
|
|
13
|
+
/** The menu bar owns this helper and its stdin; VM data can request actions but cannot answer approvals. */
|
|
14
|
+
export async function runDeviceHelper(ctx) {
|
|
15
|
+
const abort = new AbortController(), signal = AbortSignal.any([ctx.signal, abort.signal]);
|
|
16
|
+
ctx = { ...ctx, signal };
|
|
17
|
+
const pending = new Map();
|
|
18
|
+
const inputSource = Readable.from(ctx.io.stdin, { objectMode: false, signal });
|
|
19
|
+
const input = (async () => {
|
|
20
|
+
let tail = '';
|
|
21
|
+
for await (const bytes of inputSource) {
|
|
22
|
+
tail += Buffer.from(bytes).toString();
|
|
23
|
+
if (tail.length > 64 << 10)
|
|
24
|
+
throw new Error('Device approval input exceeds 64 KiB.');
|
|
25
|
+
for (let newline; (newline = tail.indexOf('\n')) >= 0;) {
|
|
26
|
+
const line = tail.slice(0, newline);
|
|
27
|
+
tail = tail.slice(newline + 1);
|
|
28
|
+
const approval = JSON.parse(line);
|
|
29
|
+
const resolve = pending.get(approval.request_id);
|
|
30
|
+
if (resolve && ['once', 'always', 'deny'].includes(approval.choice)) {
|
|
31
|
+
pending.delete(approval.request_id);
|
|
32
|
+
resolve(approval.choice);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
})().finally(() => abort.abort());
|
|
37
|
+
void input.catch(() => abort.abort());
|
|
38
|
+
const ask = async (approval) => {
|
|
39
|
+
const acp = await askConnectedAcp(ctx.store.dir, approval, signal);
|
|
40
|
+
if (acp)
|
|
41
|
+
return acp;
|
|
42
|
+
return new Promise(resolve => {
|
|
43
|
+
const denied = () => { pending.delete(approval.request_id); resolve('deny'); };
|
|
44
|
+
signal.addEventListener('abort', denied, { once: true });
|
|
45
|
+
pending.set(approval.request_id, choice => { signal.removeEventListener('abort', denied); resolve(choice); });
|
|
46
|
+
ctx.io.stdout.write(JSON.stringify(approval) + '\n');
|
|
47
|
+
if (signal.aborted)
|
|
48
|
+
denied();
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
const working = new Map();
|
|
52
|
+
const mirrored = new Map();
|
|
53
|
+
const deviceFile = join(ctx.store.dir, 'device-id');
|
|
54
|
+
let deviceId;
|
|
55
|
+
try {
|
|
56
|
+
deviceId = readFileSync(deviceFile, 'utf8').trim();
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
deviceId = randomUUID();
|
|
60
|
+
writePrivateFile(ctx.store.dir, deviceFile, deviceId + '\n');
|
|
61
|
+
}
|
|
62
|
+
const executor = new DeviceExecutor(ctx, ask, deviceId);
|
|
63
|
+
const report = (e) => { ctx.io.stderr.write(`Device bridge: ${e instanceof Error ? e.message : String(e)}\n`); };
|
|
64
|
+
try {
|
|
65
|
+
return await withTargetLock(join(ctx.store.dir, 'device-helper-lock'), async () => {
|
|
66
|
+
while (!signal.aborted) {
|
|
67
|
+
try {
|
|
68
|
+
for (const projectId of new Set(Object.values(localConversations(ctx)).map(r => r.project_id))) {
|
|
69
|
+
await conversationClient(ctx).deviceRequests.heartbeat({ projectId, deviceId, leaseSeconds: 30 });
|
|
70
|
+
let cursor;
|
|
71
|
+
do {
|
|
72
|
+
const page = await conversationClient(ctx).deviceRequests.list({ projectId, state: 'pending', limit: 200, ...(cursor ? { cursor } : {}) });
|
|
73
|
+
for (const r of page.data) {
|
|
74
|
+
if (r.device_id !== deviceId || working.has(r.id) || working.size >= 8)
|
|
75
|
+
continue;
|
|
76
|
+
const work = executor.execute(r).catch(report).finally(() => working.delete(r.id));
|
|
77
|
+
working.set(r.id, work);
|
|
78
|
+
}
|
|
79
|
+
cursor = page.nextCursor ?? undefined;
|
|
80
|
+
} while (cursor && !signal.aborted);
|
|
81
|
+
}
|
|
82
|
+
await executor.reap();
|
|
83
|
+
await mirrorProjectConversations(ctx, mirrored);
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
if (!signal.aborted && !conversationsUnavailable(e))
|
|
87
|
+
report(e);
|
|
88
|
+
}
|
|
89
|
+
await delay(1000, undefined, { signal }).catch(() => undefined);
|
|
90
|
+
}
|
|
91
|
+
return 0;
|
|
92
|
+
}, signal);
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
abort.abort();
|
|
96
|
+
inputSource.destroy();
|
|
97
|
+
for (const resolve of pending.values())
|
|
98
|
+
resolve('deny');
|
|
99
|
+
pending.clear();
|
|
100
|
+
await executor.close();
|
|
101
|
+
await Promise.all([...working.values()]);
|
|
102
|
+
await input.catch(() => undefined);
|
|
103
|
+
}
|
|
104
|
+
}
|
package/dist/errors.js
CHANGED
|
@@ -146,6 +146,11 @@ function isNamed(err, name) {
|
|
|
146
146
|
return typeof err === 'object' && err !== null && err.name === name;
|
|
147
147
|
}
|
|
148
148
|
export function describeError(err) {
|
|
149
|
+
// Tunnel teardown and cleanup failures aggregate their causes: name them all, and let the first decide the code.
|
|
150
|
+
if (err instanceof AggregateError && err.errors.length) {
|
|
151
|
+
const inner = err.errors.map(e => describeError(e));
|
|
152
|
+
return { ...inner[0], message: `${err.message}: ${inner.map(i => i.message).join('; ')}` };
|
|
153
|
+
}
|
|
149
154
|
// A processful command that could not start (0.5.1; the SDK's ExecStartError): the workspace's reason, no HTTP status
|
|
150
155
|
// (the start itself was answered 201) and details.error, details.session_id.
|
|
151
156
|
if (err instanceof ExecStartError)
|
package/dist/format.js
CHANGED
|
@@ -960,15 +960,16 @@ export function dataExportText(e, out) {
|
|
|
960
960
|
]);
|
|
961
961
|
}
|
|
962
962
|
export function volumeTable(rows) {
|
|
963
|
-
return table(['ID', 'NAME', 'STATE', 'QUOTA', 'SHARED', 'ATTACHED', 'CREATED'], rows.map((v) => [v.id, v.name, `${v.state}${v.deleted_at ? ' (deleted)' : ''}`, `${v.quota_gib} GiB`, v.org_shared ? 'organization' : 'project', String(v.live_attachments), time(v.created_at)]));
|
|
963
|
+
return table(['ID', 'NAME', 'KIND', 'STATE', 'QUOTA', 'SHARED', 'ATTACHED', 'CREATED'], rows.map((v) => [v.id, v.name, v.kind ?? 'shared', `${v.state}${v.deleted_at ? ' (deleted)' : ''}`, v.kind === 'project_space' ? 'platform managed' : `${v.quota_gib} GiB`, v.org_shared ? 'organization' : 'project', String(v.live_attachments), time(v.created_at)]));
|
|
964
964
|
}
|
|
965
965
|
export function volumeDetail(v) {
|
|
966
966
|
const err = v.error;
|
|
967
967
|
return kv([
|
|
968
968
|
['id', v.id],
|
|
969
969
|
['name', v.name],
|
|
970
|
+
['kind', v.kind ?? 'shared'],
|
|
970
971
|
['state', `${v.state}${v.state_reason ? ` (${v.state_reason})` : ''}`],
|
|
971
|
-
['quota', `${v.quota_gib} GiB`],
|
|
972
|
+
['quota', v.kind === 'project_space' ? 'platform managed' : `${v.quota_gib} GiB`],
|
|
972
973
|
['shared', v.org_shared ? 'every project of the organization' : `project ${v.project_id}`],
|
|
973
974
|
['attachments', String(v.live_attachments)],
|
|
974
975
|
['usage', pairs(v.usage)],
|
package/dist/git-remote-bin.js
CHANGED
|
@@ -22,7 +22,9 @@ try {
|
|
|
22
22
|
if (!ws)
|
|
23
23
|
throw new Error(`workspace ${remote.key} does not exist`);
|
|
24
24
|
cell = ws.cell({ agentLabel: 'git-remote-shard', transitionTimeoutMs: 120_000 });
|
|
25
|
-
|
|
25
|
+
const capability = await cell.exec.run(['/usr/local/bin/sf-agent', 'git-bridge-info'], { timeoutMs: 10_000, maxOutputBytes: 4096, signal: controller.signal });
|
|
26
|
+
const platformGitBridge = capability.exitCode === 0 && JSON.parse(capability.stdout).version === 1;
|
|
27
|
+
return { ...cell.exec, platformGitBridge };
|
|
26
28
|
}, controller.signal);
|
|
27
29
|
}
|
|
28
30
|
catch (error) {
|
package/dist/git-remote.d.ts
CHANGED
|
@@ -7,7 +7,9 @@ export interface ShardRemote {
|
|
|
7
7
|
/** Keys have two segments (K9). Everything after the agent is an absolute guest path. */
|
|
8
8
|
export declare function parseShardRemote(url: string): ShardRemote;
|
|
9
9
|
export declare function shardRemote(key: string, path: string): string;
|
|
10
|
-
export type GitExec = Pick<CellClient['exec'], 'start' | 'input' | 'output' | 'cancel'
|
|
10
|
+
export type GitExec = Pick<CellClient['exec'], 'start' | 'input' | 'output' | 'cancel'> & {
|
|
11
|
+
platformGitBridge?: boolean;
|
|
12
|
+
};
|
|
11
13
|
/** No text decoding of the pack stream; offsets are acknowledged bytes, including partial stdin writes. */
|
|
12
14
|
export declare function bridgeGit(exec: GitExec, service: string, path: string, input: AsyncIterable<Uint8Array>, stdout: Writable, stderr: Writable, signal: AbortSignal): Promise<number>;
|
|
13
15
|
/** Git's `connect` capability switches from newline commands to a full-duplex smart protocol. */
|
package/dist/git-remote.js
CHANGED
|
@@ -28,7 +28,8 @@ export async function bridgeGit(exec, service, path, input, stdout, stderr, sign
|
|
|
28
28
|
throw new UsageError('unsupported Git service');
|
|
29
29
|
const controller = new AbortController();
|
|
30
30
|
const combined = AbortSignal.any([signal, controller.signal]);
|
|
31
|
-
const
|
|
31
|
+
const argv = exec.platformGitBridge ? ['/usr/local/bin/sf-agent', 'git-bridge', '--service', service, '--path', path] : [service, path];
|
|
32
|
+
const session = await exec.start({ argv, stdin_open: true, timeout_ms: 600_000 }, combined);
|
|
32
33
|
let ended = false, offset = 0;
|
|
33
34
|
const send = async () => {
|
|
34
35
|
for await (const chunk of input) {
|
package/dist/handlers.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CREDENTIAL_HANDLERS } from "./credential-prompts.js";
|
|
2
|
+
import { CONVERSATION_HANDLERS } from "./conversation-commands.js";
|
|
2
3
|
/**
|
|
3
4
|
* Every command's handler, keyed by its path ("workspaces open", "auth login"): the project-key commands of
|
|
4
5
|
* commands.ts and the account plane of account-commands.ts (0.5.0). args.test.ts checks that it matches COMMANDS.
|
|
@@ -13,4 +14,5 @@ import { AGENT_GIT_HANDLERS } from "./agent-git-commands.js";
|
|
|
13
14
|
import { MENUBAR_HANDLERS } from "./menubar.js";
|
|
14
15
|
import { UPDATE_HANDLERS } from "./update.js";
|
|
15
16
|
import { FILESYSTEM_HANDLERS } from "./filesystem-commands.js";
|
|
16
|
-
|
|
17
|
+
import { CONVERSATION_HANDLERS as LOCAL_CONVERSATION_HANDLERS } from "./conversation-local.js";
|
|
18
|
+
export const HANDLERS = { ...CONVERSATION_HANDLERS, ...LOCAL_CONVERSATION_HANDLERS, ...CREDENTIAL_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...KEY_HANDLERS, ...ACCOUNT_HANDLERS, ...AGENT_HANDLERS, ...CENTER_HANDLERS, ...TARGET_HANDLERS, ...AGENT_GIT_HANDLERS, ...MENUBAR_HANDLERS, ...FILESYSTEM_HANDLERS, ...UPDATE_HANDLERS };
|