@shardflux/cli 0.13.5 → 0.14.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/CHANGELOG.md +33 -0
- package/README.md +40 -4
- package/dist/acp-catalog.d.ts +152 -0
- package/dist/acp-catalog.js +287 -0
- package/dist/acp-journal.d.ts +18 -0
- package/dist/acp-journal.js +232 -0
- package/dist/acp-rpc.d.ts +41 -0
- package/dist/acp-rpc.js +115 -0
- package/dist/acp.d.ts +27 -0
- package/dist/acp.js +290 -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.js +1 -1
- 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 +64 -22
- 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 +89 -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 +118 -0
- package/dist/device-bridge.d.ts +80 -0
- package/dist/device-bridge.js +398 -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/handlers.js +3 -1
- package/dist/main.js +15 -3
- package/dist/menubar.d.ts +5 -0
- package/dist/menubar.js +22 -1
- 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,398 @@
|
|
|
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
|
+
/** Resolve every existing ancestor, including symlinks. Missing leaf files do not hide an outside parent. */
|
|
22
|
+
export function devicePath(root, requested = '.') {
|
|
23
|
+
if (/[\0\r\n]/.test(requested))
|
|
24
|
+
throw new UsageError('Device path contains invalid characters.');
|
|
25
|
+
const base = realpathSync(root), target = isAbsolute(requested) ? resolve(requested) : resolve(base, requested);
|
|
26
|
+
let ancestor = target;
|
|
27
|
+
while (!existsSync(ancestor)) {
|
|
28
|
+
const parent = dirname(ancestor);
|
|
29
|
+
if (parent === ancestor)
|
|
30
|
+
throw new UsageError('Device path is unavailable.');
|
|
31
|
+
ancestor = parent;
|
|
32
|
+
}
|
|
33
|
+
const canonical = resolve(realpathSync(ancestor), relative(ancestor, target));
|
|
34
|
+
return { path: canonical, outside: !inside(base, canonical) };
|
|
35
|
+
}
|
|
36
|
+
export class DeviceGrants {
|
|
37
|
+
file;
|
|
38
|
+
constructor(dir) { this.file = join(dir, 'device-grants.json'); }
|
|
39
|
+
key(api, project) { return JSON.stringify([api, project]); }
|
|
40
|
+
read() {
|
|
41
|
+
try {
|
|
42
|
+
if (lstatSync(this.file).isSymbolicLink() || (statSync(this.file).mode & 0o077))
|
|
43
|
+
throw new UsageError('Device grants must be a private file.');
|
|
44
|
+
const value = JSON.parse(readFileSync(this.file, 'utf8'));
|
|
45
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
46
|
+
throw new UsageError('Device grants must be an object.');
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
if (e.code === 'ENOENT')
|
|
51
|
+
return {};
|
|
52
|
+
throw e;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
allows(api, project, root, kind, outside) {
|
|
56
|
+
const grant = this.read()[this.key(api, project)];
|
|
57
|
+
return !outside && grant?.root === realpathSync(root) && Array.isArray(grant.kinds) && grant.kinds.includes(kind);
|
|
58
|
+
}
|
|
59
|
+
async save(api, project, root, kind) {
|
|
60
|
+
await withTargetLock(join(dirname(this.file), 'device-grants-lock'), () => {
|
|
61
|
+
const all = this.read(), key = this.key(api, project), canonical = realpathSync(root);
|
|
62
|
+
all[key] = { root: canonical, kinds: [...new Set([...(all[key]?.root === canonical ? all[key].kinds : []), kind])] };
|
|
63
|
+
writePrivateFile(dirname(this.file), this.file, JSON.stringify(all) + '\n');
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function deviceSummary(r) {
|
|
68
|
+
if (tryLocallyAction(r))
|
|
69
|
+
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.';
|
|
70
|
+
if (r.kind === 'exec')
|
|
71
|
+
return `Run on your Mac: ${JSON.stringify(r.exec?.argv)} · directory ${r.exec?.cwd_relative ?? '.'}`;
|
|
72
|
+
if (r.files)
|
|
73
|
+
return `${r.kind === 'files_pull' ? 'Send to VM' : 'Save on your Mac'}: ${r.files.local_path_relative} ↔ ${r.files.remote_path}`;
|
|
74
|
+
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}`;
|
|
75
|
+
}
|
|
76
|
+
export function tryLocallyAction(r) {
|
|
77
|
+
if (r.kind === 'try_locally')
|
|
78
|
+
return 'start';
|
|
79
|
+
const argv = r.exec?.argv;
|
|
80
|
+
if (r.kind !== 'exec' || !argv || argv[0] !== 'shard' || argv[1] !== 'conversations' || argv[2] !== 'try' || argv[3] !== r.conversation_id)
|
|
81
|
+
return null;
|
|
82
|
+
if (argv.length === 4)
|
|
83
|
+
return 'start';
|
|
84
|
+
return argv.length === 5 && argv[4] === '--stop' ? 'stop' : null;
|
|
85
|
+
}
|
|
86
|
+
export async function ensureConversationWorktree(ctx, c, folder, ws) {
|
|
87
|
+
return withTargetLock(join(ctx.store.dir, 'conversation-worktree-lock'), async () => {
|
|
88
|
+
const run = gitRunner(folder, { ...process.env, ...ctx.io.env, SHARDFLUX_CONFIG_DIR: ctx.store.dir, SHARDFLUX_API_URL: ctx.apiUrl }, ctx.signal);
|
|
89
|
+
const name = conversationName(c), worktree = join(folder, '.shard', 'worktrees', name);
|
|
90
|
+
if (existsSync(worktree))
|
|
91
|
+
checkConversationWorktree(ctx, c, folder, worktree);
|
|
92
|
+
const source = ws ?? await conversationWorkspace(ctx, c);
|
|
93
|
+
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}`]);
|
|
94
|
+
if (!existsSync(worktree)) {
|
|
95
|
+
await mkdir(dirname(worktree), { recursive: true });
|
|
96
|
+
await run(['-c', 'core.hooksPath=/dev/null', 'worktree', 'add', '-b', c.branch, worktree, `refs/remotes/shardflux/${name}`]);
|
|
97
|
+
rememberConversationWorktree(ctx, c, folder, worktree);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
checkConversationWorktree(ctx, c, folder, worktree);
|
|
101
|
+
const work = gitRunner(worktree, { ...process.env, ...ctx.io.env }, ctx.signal);
|
|
102
|
+
if (await work(['symbolic-ref', '--short', 'HEAD']) !== c.branch || await work(['rev-parse', '--show-toplevel']) !== realpathSync(worktree))
|
|
103
|
+
throw new UsageError('Local conversation worktree belongs to other work.');
|
|
104
|
+
}
|
|
105
|
+
return realpathSync(worktree);
|
|
106
|
+
}, ctx.signal);
|
|
107
|
+
}
|
|
108
|
+
export async function deviceExec(argv, cwd, timeoutSeconds = 300, signal) {
|
|
109
|
+
if (!Array.isArray(argv) || argv.length < 1 || argv.length > 128 || argv.some(a => typeof a !== 'string' || a.includes('\0') || a.length > 65536) || !argv[0])
|
|
110
|
+
throw new UsageError('Device exec requires an argument array.');
|
|
111
|
+
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 3600)
|
|
112
|
+
throw new UsageError('Device timeout must be 1–3600 seconds.');
|
|
113
|
+
return new Promise((resolveResult, reject) => {
|
|
114
|
+
const child = spawn(argv[0], argv.slice(1), { cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32', env: process.env });
|
|
115
|
+
const stdout = [], stderr = [];
|
|
116
|
+
let failure;
|
|
117
|
+
const stop = () => { try {
|
|
118
|
+
if (child.pid && process.platform !== 'win32')
|
|
119
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
120
|
+
else
|
|
121
|
+
child.kill('SIGKILL');
|
|
122
|
+
}
|
|
123
|
+
catch { /* exited */ } };
|
|
124
|
+
const timeout = setTimeout(() => { failure = new Error('Device command timed out.'); stop(); }, timeoutSeconds * 1000);
|
|
125
|
+
const abort = () => { failure = new Error('Device command interrupted.'); stop(); };
|
|
126
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
127
|
+
const collect = (target) => { let bytes = 0; return (data) => { bytes += data.length; if (bytes > OUTPUT) {
|
|
128
|
+
failure = new Error('Device output exceeds 192 KiB per stream.');
|
|
129
|
+
stop();
|
|
130
|
+
}
|
|
131
|
+
else
|
|
132
|
+
target.push(data); }; };
|
|
133
|
+
child.stdout.on('data', collect(stdout));
|
|
134
|
+
child.stderr.on('data', collect(stderr));
|
|
135
|
+
child.once('error', error => { clearTimeout(timeout); signal?.removeEventListener('abort', abort); reject(error); });
|
|
136
|
+
child.once('close', code => {
|
|
137
|
+
clearTimeout(timeout);
|
|
138
|
+
signal?.removeEventListener('abort', abort);
|
|
139
|
+
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') });
|
|
140
|
+
});
|
|
141
|
+
if (signal?.aborted)
|
|
142
|
+
abort();
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
export class DeviceExecutor {
|
|
146
|
+
#ctx;
|
|
147
|
+
#ask;
|
|
148
|
+
#deviceId;
|
|
149
|
+
#handles = new Map();
|
|
150
|
+
constructor(ctx, ask, deviceId) { this.#ctx = ctx; this.#ask = ask; this.#deviceId = deviceId; }
|
|
151
|
+
async result(r, result) {
|
|
152
|
+
await conversationClient(this.#ctx).deviceRequests.result(safeId(r.id), result, { idempotencyKey: `${this.#deviceId}:${r.id}:${result.state}`, signal: this.#ctx.signal });
|
|
153
|
+
}
|
|
154
|
+
async execute(r) {
|
|
155
|
+
const ctx = this.#ctx;
|
|
156
|
+
safeId(r.id);
|
|
157
|
+
if (r.state !== 'pending' || r.device_id !== this.#deviceId)
|
|
158
|
+
return;
|
|
159
|
+
const records = localConversations(ctx);
|
|
160
|
+
const matching = Object.values(records).filter(record => record.project_id === r.project_id);
|
|
161
|
+
const folders = new Set(matching.map(record => record.folder));
|
|
162
|
+
const record = records[r.conversation_id] ?? (folders.size === 1 ? matching[0] : undefined);
|
|
163
|
+
if (!record || record.project_id !== r.project_id)
|
|
164
|
+
return; // No inferred local path for unknown projects.
|
|
165
|
+
let c = await getConversation(ctx, r.conversation_id), ws = await conversationWorkspace(ctx, c);
|
|
166
|
+
if (c.project_id !== r.project_id)
|
|
167
|
+
throw new Error('Device request project changed.');
|
|
168
|
+
const root = await ensureConversationWorktree(ctx, c, record.folder, ws);
|
|
169
|
+
const requested = r.exec?.cwd_relative ?? r.files?.local_path_relative ?? '.';
|
|
170
|
+
const target = devicePath(root, requested), grants = new DeviceGrants(ctx.store.dir);
|
|
171
|
+
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 });
|
|
172
|
+
if (choice === 'deny') {
|
|
173
|
+
await this.result(r, { state: 'denied' });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (choice !== 'once' && choice !== 'always')
|
|
177
|
+
throw new Error('Device request was not approved.');
|
|
178
|
+
if (target.outside && choice === 'always')
|
|
179
|
+
throw new UsageError('An outside path requires Allow once for this request.');
|
|
180
|
+
if (choice === 'always')
|
|
181
|
+
await grants.save(ctx.apiUrl, r.project_id, record.folder, r.kind);
|
|
182
|
+
// Re-read after the approval, before claiming or touching data.
|
|
183
|
+
if (devicePath(root, requested).path !== target.path)
|
|
184
|
+
throw new Error('Device path changed during approval.');
|
|
185
|
+
const current = await getConversation(ctx, r.conversation_id);
|
|
186
|
+
if (current.project_id !== c.project_id || current.branch !== c.branch || current.state === 'done' || current.state === 'archived')
|
|
187
|
+
throw new Error('The conversation changed or ended during device approval.');
|
|
188
|
+
if (current.workspace_id !== c.workspace_id) {
|
|
189
|
+
ws.cell().close();
|
|
190
|
+
ws = await conversationWorkspace(ctx, current);
|
|
191
|
+
}
|
|
192
|
+
c = current;
|
|
193
|
+
await this.result(r, { state: 'approved' });
|
|
194
|
+
await this.result(r, { state: 'running' });
|
|
195
|
+
const cell = ws.cell();
|
|
196
|
+
try {
|
|
197
|
+
let result;
|
|
198
|
+
if (r.kind === 'try_locally' || tryLocallyAction(r)) {
|
|
199
|
+
const action = tryLocallyAction(r);
|
|
200
|
+
for (const [id, handle] of this.#handles)
|
|
201
|
+
if (handle.conversation === c.id) {
|
|
202
|
+
await handle.stop();
|
|
203
|
+
this.#handles.delete(id);
|
|
204
|
+
}
|
|
205
|
+
if (action === 'stop') {
|
|
206
|
+
await this.result(r, { state: 'done', stdout_b64: Buffer.from('Local sync and ports stopped.').toString('base64') });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const { tryConversationLocally } = await import("./conversation-local.js");
|
|
210
|
+
const handle = await tryConversationLocally(ctx, c, record.folder);
|
|
211
|
+
this.#handles.set(r.id, { conversation: c.id, workspace: ws.id, async stop() { await handle.stop(); cell.close(); } });
|
|
212
|
+
result = { state: 'done', files_artifact: handle.folder };
|
|
213
|
+
}
|
|
214
|
+
else if (r.kind === 'exec') {
|
|
215
|
+
if (!r.exec)
|
|
216
|
+
throw new UsageError('Device exec has no command.');
|
|
217
|
+
result = await deviceExec(r.exec.argv, target.path, r.exec.timeout_seconds, ctx.signal);
|
|
218
|
+
}
|
|
219
|
+
else if (r.kind === 'files_pull' || r.kind === 'files_push') {
|
|
220
|
+
const f = r.files;
|
|
221
|
+
if (!f || !f.remote_path.startsWith('/') || /[\0\r\n]/.test(f.remote_path))
|
|
222
|
+
throw new UsageError('Device transfer requires an absolute VM path.');
|
|
223
|
+
const max = f.max_bytes ?? LIMIT;
|
|
224
|
+
if (!Number.isSafeInteger(max) || max < 1 || max > LIMIT)
|
|
225
|
+
throw new UsageError('Device transfer maximum is 256 MiB.');
|
|
226
|
+
if (r.kind === 'files_pull') {
|
|
227
|
+
if (!(await stat(target.path)).isFile() || (await stat(target.path)).size > max)
|
|
228
|
+
throw new UsageError('Local transfer file exceeds the requested size or is not a file.');
|
|
229
|
+
const bytes = await readFile(target.path);
|
|
230
|
+
if (bytes.length > max)
|
|
231
|
+
throw new UsageError('Local transfer file grew during reading.');
|
|
232
|
+
await writeSyncBlob(cell, f.remote_path, bytes);
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
const info = await cell.files.stat(f.remote_path);
|
|
236
|
+
if (info.type !== 'file' || info.size > max)
|
|
237
|
+
throw new UsageError('VM transfer file exceeds the requested size or is not a file.');
|
|
238
|
+
const bytes = await cell.files.read(f.remote_path, { length: max + 1 });
|
|
239
|
+
if (bytes.length > max || bytes.length !== info.size)
|
|
240
|
+
throw new UsageError('VM transfer file changed during reading.');
|
|
241
|
+
await mkdir(dirname(target.path), { recursive: true, mode: 0o700 });
|
|
242
|
+
const parent = await realpath(dirname(target.path));
|
|
243
|
+
if (!target.outside && !inside(root, parent))
|
|
244
|
+
throw new Error('Transfer parent escaped the worktree.');
|
|
245
|
+
await writeFile(target.path, bytes, { mode: 0o600, flag: 'wx' }); // Existing files need a fresh explicit request, never silent clobber.
|
|
246
|
+
}
|
|
247
|
+
result = { state: 'done', files_artifact: r.kind === 'files_pull' ? f.remote_path : relative(root, target.path) };
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
if (!r.port || ![r.port.local_port, r.port.remote_port].every(p => Number.isInteger(p) && p > 0 && p <= 65535))
|
|
251
|
+
throw new UsageError('Device ports must be 1–65535.');
|
|
252
|
+
const handle = await devicePort(ws, cell, r.kind, r.port, ctx.signal);
|
|
253
|
+
this.#handles.set(r.id, { conversation: c.id, workspace: ws.id, stop: () => handle.stop() });
|
|
254
|
+
result = { state: 'done' };
|
|
255
|
+
}
|
|
256
|
+
await this.result(r, result);
|
|
257
|
+
}
|
|
258
|
+
catch (e) {
|
|
259
|
+
await this.result(r, { state: 'failed', stderr_b64: Buffer.from(e instanceof Error ? e.message : String(e)).toString('base64') });
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
if (!this.#handles.has(r.id))
|
|
263
|
+
cell.close();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
async reap() {
|
|
267
|
+
for (const [id, handle] of this.#handles) {
|
|
268
|
+
const c = await getConversation(this.#ctx, handle.conversation);
|
|
269
|
+
const file = join(this.#ctx.store.dir, 'device-stops', `${c.id}.json`);
|
|
270
|
+
let stopped = false;
|
|
271
|
+
if (existsSync(file)) {
|
|
272
|
+
const record = JSON.parse(readFileSync(file, 'utf8'));
|
|
273
|
+
stopped = record.api_url === this.#ctx.apiUrl;
|
|
274
|
+
}
|
|
275
|
+
if (stopped || c.state === 'done' || c.state === 'archived' || c.workspace_id !== handle.workspace) {
|
|
276
|
+
await handle.stop();
|
|
277
|
+
this.#handles.delete(id);
|
|
278
|
+
if (stopped)
|
|
279
|
+
rmSync(file);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
async close() { await Promise.all([...this.#handles.values()].map(h => h.stop())); this.#handles.clear(); }
|
|
284
|
+
}
|
|
285
|
+
export async function devicePort(ws, cell, kind, ports, signal) {
|
|
286
|
+
const tunnels = new WorkspaceTunnels(() => cell, () => ws.grantedTools);
|
|
287
|
+
if (kind === 'port_reverse') {
|
|
288
|
+
const handle = await tunnels.reverse({ remotePort: ports.remote_port, target: `127.0.0.1:${ports.local_port}`, bindAddress: '127.0.0.1', signal });
|
|
289
|
+
let stopping;
|
|
290
|
+
return { stop() { return stopping ??= handle.close().finally(() => cell.close()); } };
|
|
291
|
+
}
|
|
292
|
+
const peers = new Set();
|
|
293
|
+
const handles = new Set();
|
|
294
|
+
const server = createServer(socket => {
|
|
295
|
+
peers.add(socket);
|
|
296
|
+
socket.once('close', () => peers.delete(socket));
|
|
297
|
+
socket.on('error', () => socket.destroy());
|
|
298
|
+
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());
|
|
299
|
+
});
|
|
300
|
+
await new Promise((resolveListen, reject) => { server.once('error', reject); server.listen(ports.local_port, '127.0.0.1', resolveListen); });
|
|
301
|
+
let stopping;
|
|
302
|
+
const abort = () => { void stop().catch(() => undefined); };
|
|
303
|
+
const stop = () => stopping ??= (async () => { signal.removeEventListener('abort', abort); for (const socket of peers)
|
|
304
|
+
socket.destroy(); await Promise.all([...handles].map(h => h.close())); await new Promise(resolveClose => server.close(() => resolveClose())); cell.close(); })();
|
|
305
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
306
|
+
if (signal.aborted)
|
|
307
|
+
abort();
|
|
308
|
+
return { stop };
|
|
309
|
+
}
|
|
310
|
+
const approvalSocket = (dir, id) => {
|
|
311
|
+
const hash = createHash('sha256').update(resolve(dir)).update(safeId(id)).digest('hex').slice(0, 20);
|
|
312
|
+
const path = join(dir, 'acp-device', `${hash}.sock`);
|
|
313
|
+
return Buffer.byteLength(path) < 100 ? path : join('/tmp', `sf-device-${process.getuid?.() ?? 'user'}`, `${hash}.sock`);
|
|
314
|
+
};
|
|
315
|
+
export async function serveAcpDeviceApprovals(dir, id, ask) {
|
|
316
|
+
const path = approvalSocket(dir, id);
|
|
317
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
318
|
+
const parent = lstatSync(dirname(path));
|
|
319
|
+
if (parent.isSymbolicLink() || (parent.mode & 0o077) || (process.getuid && parent.uid !== process.getuid()))
|
|
320
|
+
throw new Error('Device approval socket directory must be private and owned by this user.');
|
|
321
|
+
const peers = new Set();
|
|
322
|
+
const server = createServer(socket => {
|
|
323
|
+
peers.add(socket);
|
|
324
|
+
socket.once('close', () => peers.delete(socket));
|
|
325
|
+
let body = '';
|
|
326
|
+
socket.setTimeout(3600_000, () => socket.destroy());
|
|
327
|
+
socket.on('error', () => socket.destroy());
|
|
328
|
+
socket.on('data', chunk => {
|
|
329
|
+
body += String(chunk);
|
|
330
|
+
if (body.length > 64 << 10)
|
|
331
|
+
socket.destroy();
|
|
332
|
+
if (!body.includes('\n'))
|
|
333
|
+
return;
|
|
334
|
+
socket.removeAllListeners('data');
|
|
335
|
+
let approval;
|
|
336
|
+
try {
|
|
337
|
+
approval = JSON.parse(body);
|
|
338
|
+
if (approval.conversation_id !== id || approval.type !== 'approval')
|
|
339
|
+
throw new Error('Invalid approval');
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
socket.destroy();
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
void ask(approval).then(choice => socket.end(JSON.stringify({ choice }) + '\n')).catch(() => socket.end('{"choice":"deny"}\n'));
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
await withTargetLock(join(dirname(path), `${id}-lock`), async () => {
|
|
349
|
+
if (existsSync(path)) {
|
|
350
|
+
const old = lstatSync(path);
|
|
351
|
+
if (!old.isSocket() || (process.getuid && old.uid !== process.getuid()))
|
|
352
|
+
throw new Error('Device approval socket belongs to other work.');
|
|
353
|
+
const active = await new Promise((resolveActive, reject) => {
|
|
354
|
+
const probe = createConnection(path);
|
|
355
|
+
probe.setTimeout(1000, () => { probe.destroy(); reject(new Error('Device approval socket owner is unverified.')); });
|
|
356
|
+
probe.once('connect', () => { probe.destroy(); resolveActive(true); });
|
|
357
|
+
probe.once('error', error => { if (error.code === 'ECONNREFUSED')
|
|
358
|
+
resolveActive(false);
|
|
359
|
+
else
|
|
360
|
+
reject(error); });
|
|
361
|
+
});
|
|
362
|
+
if (active)
|
|
363
|
+
throw new Error('This ACP conversation already has a laptop bridge.');
|
|
364
|
+
if (lstatSync(path).ino !== old.ino)
|
|
365
|
+
throw new Error('Device approval socket owner changed.');
|
|
366
|
+
rmSync(path);
|
|
367
|
+
}
|
|
368
|
+
await new Promise((resolveListen, reject) => { server.once('error', reject); server.listen(path, resolveListen); });
|
|
369
|
+
chmodSync(path, 0o600);
|
|
370
|
+
});
|
|
371
|
+
const inode = lstatSync(path).ino;
|
|
372
|
+
let stopping;
|
|
373
|
+
return { stop() { return stopping ??= (async () => { for (const peer of peers)
|
|
374
|
+
peer.destroy(); await new Promise(resolveClose => server.close(() => resolveClose())); if (existsSync(path) && lstatSync(path).ino === inode)
|
|
375
|
+
rmSync(path); })(); } };
|
|
376
|
+
}
|
|
377
|
+
export async function askConnectedAcp(dir, approval, signal) {
|
|
378
|
+
const path = approvalSocket(dir, approval.conversation_id);
|
|
379
|
+
if (!existsSync(path))
|
|
380
|
+
return null;
|
|
381
|
+
return new Promise((resolveChoice, reject) => {
|
|
382
|
+
const socket = createConnection(path);
|
|
383
|
+
let text = '';
|
|
384
|
+
const abort = () => { socket.destroy(); reject(signal.reason instanceof Error ? signal.reason : new Error('Device approval interrupted')); };
|
|
385
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
386
|
+
socket.on('connect', () => socket.write(JSON.stringify(approval) + '\n'));
|
|
387
|
+
socket.on('data', bytes => { text += String(bytes); if (text.length > 1024)
|
|
388
|
+
socket.destroy(); });
|
|
389
|
+
socket.once('error', () => { signal.removeEventListener('abort', abort); resolveChoice(null); });
|
|
390
|
+
socket.once('end', () => { signal.removeEventListener('abort', abort); try {
|
|
391
|
+
const choice = JSON.parse(text).choice;
|
|
392
|
+
resolveChoice(['once', 'always', 'deny'].includes(choice) ? choice : 'deny');
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
resolveChoice('deny');
|
|
396
|
+
} });
|
|
397
|
+
});
|
|
398
|
+
}
|
|
@@ -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/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 };
|
package/dist/main.js
CHANGED
|
@@ -15,6 +15,10 @@ import { statSync } from 'node:fs';
|
|
|
15
15
|
import { SDK_VERSION, Shardflux, ShardfluxAccount, isSessionToken } from '@shardflux/sdk';
|
|
16
16
|
import { CLI_VERSION, CENTER_COMMAND, PROJECT_COMMAND, commandHelp, parseCommandLine, resolveApiUrl, resolveWake, resolveWakeTimeout } from "./args.js";
|
|
17
17
|
import { commandCenter } from "./command-center.js";
|
|
18
|
+
import { launchConversationProject } from "./conversation-terminal.js";
|
|
19
|
+
import { runAcp } from "./acp.js";
|
|
20
|
+
import { registerInstalledT3 } from "./t3-provider.js";
|
|
21
|
+
import { runDeviceHelper } from "./device-helper.js";
|
|
18
22
|
import { housekeeping, startupHousekeeping } from "./agent-git-commands.js";
|
|
19
23
|
import { CredentialStore, configDir } from "./credentials.js";
|
|
20
24
|
import { recordInstall } from "./update.js";
|
|
@@ -365,6 +369,7 @@ function feedbackHint(info, credentialHint) {
|
|
|
365
369
|
}
|
|
366
370
|
export async function run(argv, io, opts = {}) {
|
|
367
371
|
const signal = opts.signal ?? new AbortController().signal;
|
|
372
|
+
await registerInstalledT3(io.env, configDir(io.env), signal);
|
|
368
373
|
const distribution = opts.distribution ?? CLI_DISTRIBUTION;
|
|
369
374
|
const rawKey = io.env.SHARDFLUX_API_KEY;
|
|
370
375
|
// Every credential this command loaded or received: redacted from anything printed on failure.
|
|
@@ -626,7 +631,7 @@ export async function run(argv, io, opts = {}) {
|
|
|
626
631
|
},
|
|
627
632
|
};
|
|
628
633
|
// The update check runs next to the command (not for `version`, which reports it itself).
|
|
629
|
-
if (!updateCheckDisabled(io.env) && !['version', 'update', 'ssh-proxy'].includes(parsed.command.path[0])) {
|
|
634
|
+
if (!updateCheckDisabled(io.env) && !['version', 'update', 'ssh-proxy', 'acp', 'device-helper'].includes(parsed.command.path[0])) {
|
|
630
635
|
try {
|
|
631
636
|
updates = new UpdateCheck({ dir: store.dir, apiUrl, distribution, fetch: base, userAgent });
|
|
632
637
|
updates.start();
|
|
@@ -639,10 +644,11 @@ export async function run(argv, io, opts = {}) {
|
|
|
639
644
|
catch (err) {
|
|
640
645
|
return report(err);
|
|
641
646
|
}
|
|
642
|
-
const handler = parsed.command === PROJECT_COMMAND
|
|
647
|
+
const handler = parsed.command.path[0] === 'acp' ? (ctx) => runAcp(ctx) : parsed.command.path[0] === 'device-helper' ? runDeviceHelper : parsed.command === PROJECT_COMMAND ? launchConversationProject : parsed.command === CENTER_COMMAND ? commandCenter : HANDLERS[parsed.command.path.join(' ')];
|
|
643
648
|
if (!handler)
|
|
644
649
|
return report(new UsageError(`unknown command "${parsed.command.path.join(' ')}"`));
|
|
645
|
-
|
|
650
|
+
if (!['acp', 'device-helper'].includes(parsed.command.path[0]))
|
|
651
|
+
await housekeeping(ctx);
|
|
646
652
|
const running = handler(ctx, parsed.values, parsed.positionals).then((code) => ({ code }), (err) => ({ err }));
|
|
647
653
|
// On Ctrl-C give the command a moment to clean up (exec sends a cancel), then stop regardless.
|
|
648
654
|
const interrupted = new Promise((resolve) => {
|
|
@@ -693,6 +699,12 @@ export async function main(opts = {}) {
|
|
|
693
699
|
process.exit(130);
|
|
694
700
|
controller.abort(new Error('interrupted'));
|
|
695
701
|
});
|
|
702
|
+
for (const name of ['SIGTERM', 'SIGHUP'])
|
|
703
|
+
process.once(name, () => controller.abort(new Error(name)));
|
|
704
|
+
// A reader that closed its end (ssh after a ProxyCommand session, `| head`) has nothing left to receive.
|
|
705
|
+
for (const stream of [process.stdout, process.stderr])
|
|
706
|
+
stream.on('error', (e) => { if (e.code !== 'EPIPE')
|
|
707
|
+
throw e; });
|
|
696
708
|
const flush = (stream) => new Promise((resolve) => stream.write('', () => resolve()));
|
|
697
709
|
const runtime = { node: process.execPath, script: process.argv[1] ?? '' };
|
|
698
710
|
// The menu bar's update button runs this shard; it finds it here (macOS only, written when it changes).
|
package/dist/menubar.d.ts
CHANGED
|
@@ -10,6 +10,11 @@ export interface MenubarStatus {
|
|
|
10
10
|
type NativeStatus = Omit<MenubarStatus, 'installed' | 'version'>;
|
|
11
11
|
export declare function compareBundleVersions(left: string, right: string): number;
|
|
12
12
|
export declare function parseNativeStatus(text: string): NativeStatus;
|
|
13
|
+
/**
|
|
14
|
+
* The app's executables keep their execute permission (0.13.6+). @shardflux/menubar-darwin 0.1.1 and 0.1.2 reached npm
|
|
15
|
+
* without it (pnpm's packer drops it), so an app copied from them could not run; this also repairs such an install.
|
|
16
|
+
*/
|
|
17
|
+
export declare function makeExecutable(app: string): void;
|
|
13
18
|
/** Native tools use argument arrays, bounded deadlines, and the installed app's own process/login projection. */
|
|
14
19
|
export declare class MenubarManager {
|
|
15
20
|
#private;
|