@shardflux/cli 0.14.0 → 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 +14 -0
- package/README.md +9 -5
- package/dist/acp-catalog.d.ts +7 -152
- package/dist/acp-catalog.js +39 -286
- package/dist/acp-models.d.ts +36 -0
- package/dist/acp-models.js +161 -0
- package/dist/acp.d.ts +5 -3
- package/dist/acp.js +216 -42
- package/dist/agent-repository.d.ts +2 -0
- package/dist/agent-repository.js +41 -30
- package/dist/agent-target-commands.d.ts +1 -1
- package/dist/agent-target-commands.js +51 -23
- package/dist/args.d.ts +1 -1
- package/dist/args.js +2 -2
- package/dist/conversation-router.js +3 -0
- package/dist/conversation-stash.js +1 -1
- package/dist/conversations.d.ts +2 -2
- package/dist/conversations.js +3 -2
- package/dist/device-bridge.d.ts +1 -1
- package/dist/device-bridge.js +27 -16
- package/dist/git-remote-bin.js +3 -1
- package/dist/git-remote.d.ts +3 -1
- package/dist/git-remote.js +2 -1
- package/package.json +2 -2
package/dist/agent-repository.js
CHANGED
|
@@ -32,7 +32,13 @@ export async function seedAgentRepository(ctx, ws, folder, options = {}) {
|
|
|
32
32
|
state.update(doc => { const seeds = (doc.git_seeding ?? {}); seeds[ws.id] = { api_url: ctx.apiUrl, folder: project.folder }; doc.git_seeding = seeds; });
|
|
33
33
|
const cell = ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs });
|
|
34
34
|
await cell.files.mkdir(root, { parents: true });
|
|
35
|
-
|
|
35
|
+
let initialReady = false;
|
|
36
|
+
const initial = options.onInitialFiles ? { paths: new Set(options.initialFiles ?? []), ready: () => { if (!initialReady) {
|
|
37
|
+
initialReady = true;
|
|
38
|
+
options.onInitialFiles();
|
|
39
|
+
} } } : undefined;
|
|
40
|
+
await initializeProjectGit(cell, project.folder, root, ctx.signal, options.populate, retry, message => { ctx.io.stderr.write(`${message}\n`); }, initial);
|
|
41
|
+
initial?.ready();
|
|
36
42
|
state.update(doc => { const seeds = (doc.git_seeding ?? {}); delete seeds[ws.id]; doc.git_seeding = seeds; });
|
|
37
43
|
}
|
|
38
44
|
/** History above this goes through the project's origin instead of the bounded files path. */
|
|
@@ -116,7 +122,7 @@ export async function importProjectHistory(cell, folder, root, head, branch, ori
|
|
|
116
122
|
await cell.files.remove(remote).catch(() => undefined);
|
|
117
123
|
}
|
|
118
124
|
}
|
|
119
|
-
async function initializeProjectGit(cell, folder, root, signal, populate = false, retry = false, report = () => undefined) {
|
|
125
|
+
async function initializeProjectGit(cell, folder, root, signal, populate = false, retry = false, report = () => undefined, initial) {
|
|
120
126
|
let top;
|
|
121
127
|
try {
|
|
122
128
|
top = (await projectGit(folder, ['rev-parse', '--show-toplevel'], signal)).trim();
|
|
@@ -125,7 +131,7 @@ async function initializeProjectGit(cell, folder, root, signal, populate = false
|
|
|
125
131
|
if (signal?.aborted || error.code !== 128)
|
|
126
132
|
throw error;
|
|
127
133
|
if (populate)
|
|
128
|
-
await seedProjectFiles(cell, folder, root, '', new Set(), true, signal, report);
|
|
134
|
+
await seedProjectFiles(cell, folder, root, '', new Set(), true, signal, report, initial);
|
|
129
135
|
return;
|
|
130
136
|
}
|
|
131
137
|
if (top !== folder)
|
|
@@ -192,7 +198,7 @@ async function initializeProjectGit(cell, folder, root, signal, populate = false
|
|
|
192
198
|
// template's dependency outputs would look like agent-created source.
|
|
193
199
|
// For unborn repos, intent-to-add preserves tracked ignored paths too.
|
|
194
200
|
const tracked = new Set((await projectGit(folder, ['ls-files', '-z', '--cached'], signal)).split('\0'));
|
|
195
|
-
await seedProjectFiles(cell, folder, root, head, tracked, populate, signal, report);
|
|
201
|
+
await seedProjectFiles(cell, folder, root, head, tracked, populate, signal, report, initial);
|
|
196
202
|
}
|
|
197
203
|
// Git's info/exclude and laptop-wide excludes are selection metadata, copied
|
|
198
204
|
// into a dedicated Git exclude file without syncing credentials or .git data.
|
|
@@ -221,38 +227,43 @@ async function initializeProjectGit(cell, folder, root, signal, populate = false
|
|
|
221
227
|
if (result.exitCode !== 0)
|
|
222
228
|
throw new Error('could not apply project Git exclude rules');
|
|
223
229
|
}
|
|
224
|
-
async function seedProjectFiles(cell, folder, root, head, tracked, populate, signal, report) {
|
|
230
|
+
async function seedProjectFiles(cell, folder, root, head, tracked, populate, signal, report, initial) {
|
|
225
231
|
const raw = await localManifest(folder, signal);
|
|
226
232
|
for (const skip of raw.skipped)
|
|
227
233
|
report(`Project seed skipped ${skip.path}: ${skip.reason}`);
|
|
228
234
|
const seed = raw.entries.filter((entry) => populate || entry.path.split('/').at(-1) === '.gitignore' || (!head && tracked.has(entry.path)));
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
235
|
+
const groups = initial ? [seed.filter(e => initial.paths.has(e.path)), seed.filter(e => !initial.paths.has(e.path))] : [seed];
|
|
236
|
+
for (let group = 0; group < groups.length; group++) {
|
|
237
|
+
for (let offset = 0; offset < groups[group].length; offset += 32) {
|
|
238
|
+
const entries = groups[group].slice(offset, offset + 32);
|
|
239
|
+
const probe = await cell.exec.run(['python3', '-c', 'import os,sys,json;root=sys.argv[1];print(json.dumps([p for p in json.loads(sys.argv[2]) if not os.path.lexists(root+"/"+p)]))', root, JSON.stringify(entries.map((entry) => entry.path))], { timeoutMs: 5000, signal });
|
|
240
|
+
if (probe.exitCode !== 0)
|
|
241
|
+
throw new Error('could not inspect initial project ignore files');
|
|
242
|
+
const missing = new Set(JSON.parse(probe.stdout));
|
|
243
|
+
let seeds = new Map(), bytes = 0;
|
|
244
|
+
for (const entry of entries)
|
|
245
|
+
if (missing.has(entry.path)) {
|
|
246
|
+
if (bytes + entry.size > 8 << 20 && seeds.size) {
|
|
247
|
+
await applyRemoteSyncBatch(cell, root, seeds, {}, signal);
|
|
248
|
+
seeds = new Map();
|
|
249
|
+
bytes = 0;
|
|
250
|
+
}
|
|
251
|
+
const file = await readLocalSyncFile(folder, entry);
|
|
252
|
+
seeds.set(entry.path, file);
|
|
253
|
+
bytes += file.data.length;
|
|
254
|
+
}
|
|
255
|
+
if (seeds.size)
|
|
256
|
+
await applyRemoteSyncBatch(cell, root, seeds, {}, signal);
|
|
257
|
+
if (!head) {
|
|
258
|
+
const add = entries.filter((entry) => tracked.has(entry.path)).map((entry) => entry.path);
|
|
259
|
+
if (add.length) {
|
|
260
|
+
const result = await cell.exec.run(['git', '-C', root, '-c', 'core.hooksPath=/dev/null', 'add', '-N', '-f', '--', ...add], { timeoutMs: 5000, signal });
|
|
261
|
+
if (result.exitCode !== 0)
|
|
262
|
+
throw new Error(`initial tracked project files failed: ${result.stderr.trim()}`);
|
|
242
263
|
}
|
|
243
|
-
const file = await readLocalSyncFile(folder, entry);
|
|
244
|
-
seeds.set(entry.path, file);
|
|
245
|
-
bytes += file.data.length;
|
|
246
|
-
}
|
|
247
|
-
if (seeds.size)
|
|
248
|
-
await applyRemoteSyncBatch(cell, root, seeds, {}, signal);
|
|
249
|
-
if (!head) {
|
|
250
|
-
const add = entries.filter((entry) => tracked.has(entry.path)).map((entry) => entry.path);
|
|
251
|
-
if (add.length) {
|
|
252
|
-
const result = await cell.exec.run(['git', '-C', root, '-c', 'core.hooksPath=/dev/null', 'add', '-N', '-f', '--', ...add], { timeoutMs: 5000, signal });
|
|
253
|
-
if (result.exitCode !== 0)
|
|
254
|
-
throw new Error(`initial tracked project files failed: ${result.stderr.trim()}`);
|
|
255
264
|
}
|
|
256
265
|
}
|
|
266
|
+
if (group === 0)
|
|
267
|
+
initial?.ready();
|
|
257
268
|
}
|
|
258
269
|
}
|
|
@@ -7,7 +7,7 @@ import type { DesktopTarget } from './agent-targets.js';
|
|
|
7
7
|
export declare function installDeviceKey(cell: CellClient, key: string, signal?: AbortSignal): Promise<void>;
|
|
8
8
|
/** Host trust is established over the authenticated files API, before sending any SSH protocol bytes. */
|
|
9
9
|
export declare function prepareSsh(ws: Workspace, ctx: Ctx, targets: AgentTargets, host: string): Promise<CellClient>;
|
|
10
|
-
export declare function proxyStream(io: Io, ended?: () => void): Duplex;
|
|
10
|
+
export declare function proxyStream(io: Io, ended?: () => void, outputGate?: Promise<unknown>): Duplex;
|
|
11
11
|
export declare function openDesktopWorkspace(ctx: Ctx, ws: Workspace, target: DesktopTarget, t3Version?: string, prewarmed?: boolean): Promise<number>;
|
|
12
12
|
export declare function queueT3Prewarm(ctx: Ctx, ws: Workspace, version: string): Promise<void>;
|
|
13
13
|
export declare const TARGET_HANDLERS: Record<string, Handler>;
|
|
@@ -19,7 +19,6 @@ const keyOf = (ref) => ref.includes('/') ? ref : `${projectIdentity('./').slug}/
|
|
|
19
19
|
/** Optimistic file replacement preserves keys installed by other devices connecting at the same time. */
|
|
20
20
|
export async function installDeviceKey(cell, key, signal) {
|
|
21
21
|
const path = '/home/user/.ssh/authorized_keys';
|
|
22
|
-
await cell.files.mkdir('/home/user/.ssh', { mode: '0700', parents: true });
|
|
23
22
|
for (let attempt = 0; attempt < 4; attempt++) {
|
|
24
23
|
let text = '', revision = 'absent';
|
|
25
24
|
try {
|
|
@@ -34,8 +33,11 @@ export async function installDeviceKey(cell, key, signal) {
|
|
|
34
33
|
throw e;
|
|
35
34
|
}
|
|
36
35
|
const publicPart = key.split(' ').slice(0, 2).join(' ');
|
|
36
|
+
// A reconnect finds its key with one read; only a first key creates the directory.
|
|
37
37
|
if (text.split('\n').some(line => line.trim().split(/\s+/).slice(0, 2).join(' ') === publicPart))
|
|
38
38
|
return;
|
|
39
|
+
if (revision === 'absent')
|
|
40
|
+
await cell.files.mkdir('/home/user/.ssh', { mode: '0700', parents: true });
|
|
39
41
|
try {
|
|
40
42
|
await cell.files.patch({ path, content: text + (text && !text.endsWith('\n') ? '\n' : '') + key + '\n', expectedRevision: revision, mode: '0600' }, { signal });
|
|
41
43
|
return;
|
|
@@ -47,16 +49,22 @@ export async function installDeviceKey(cell, key, signal) {
|
|
|
47
49
|
}
|
|
48
50
|
throw new Error('authorized_keys kept changing; retry the SSH connection');
|
|
49
51
|
}
|
|
50
|
-
|
|
51
|
-
export async function prepareSsh(ws, ctx, targets, host) {
|
|
52
|
+
async function sshCell(ws, ctx) {
|
|
52
53
|
if (!ws.ready)
|
|
53
54
|
await ws.wake({ timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
|
|
54
55
|
if (ws.labels?.t3_environment_id)
|
|
55
56
|
await prepareAgentMachine(ctx, ws);
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
return ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs });
|
|
58
|
+
}
|
|
59
|
+
/** Host trust over the authenticated files API: the host key pin and this device's key, read together. */
|
|
60
|
+
async function trustSsh(cell, targets, host, signal) {
|
|
61
|
+
const [hostKey] = await Promise.all([cell.files.readText('/etc/ssh/ssh_host_ed25519_key.pub', { length: 4096 }), installDeviceKey(cell, targets.deviceKey(), signal)]);
|
|
62
|
+
await targets.pin(host, hostKey, signal);
|
|
63
|
+
}
|
|
64
|
+
/** Host trust is established over the authenticated files API, before sending any SSH protocol bytes. */
|
|
65
|
+
export async function prepareSsh(ws, ctx, targets, host) {
|
|
66
|
+
const cell = await sshCell(ws, ctx);
|
|
67
|
+
await trustSsh(cell, targets, host, ctx.signal);
|
|
60
68
|
return cell;
|
|
61
69
|
}
|
|
62
70
|
async function openApp(target, env) {
|
|
@@ -70,10 +78,10 @@ async function openApp(target, env) {
|
|
|
70
78
|
reject(new Error(`app launcher exited ${code}`)); });
|
|
71
79
|
});
|
|
72
80
|
}
|
|
73
|
-
export function proxyStream(io, ended) {
|
|
81
|
+
export function proxyStream(io, ended, outputGate) {
|
|
74
82
|
const stdout = io.stdout;
|
|
75
83
|
const source = Readable.from(io.stdin, { objectMode: false });
|
|
76
|
-
let blocked = false;
|
|
84
|
+
let blocked = false, gate = outputGate;
|
|
77
85
|
const pump = () => {
|
|
78
86
|
while (!blocked && !stream.destroyed) {
|
|
79
87
|
const bytes = source.read();
|
|
@@ -86,17 +94,24 @@ export function proxyStream(io, ended) {
|
|
|
86
94
|
allowHalfOpen: true,
|
|
87
95
|
read() { blocked = false; pump(); },
|
|
88
96
|
write(bytes, _encoding, done) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
stdout.once
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
97
|
+
const send = () => {
|
|
98
|
+
try {
|
|
99
|
+
if (stdout.write(bytes))
|
|
100
|
+
done();
|
|
101
|
+
else if (stdout.once)
|
|
102
|
+
stdout.once('drain', done);
|
|
103
|
+
else
|
|
104
|
+
done(new Error('stdout cannot signal backpressure'));
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
done(e);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
// No guest byte reaches the SSH client before the gate opens (its host key pinned).
|
|
111
|
+
if (gate)
|
|
112
|
+
void gate.then(() => { gate = undefined; send(); }, (e) => done(e instanceof Error ? e : new Error(String(e))));
|
|
113
|
+
else
|
|
114
|
+
send();
|
|
100
115
|
},
|
|
101
116
|
// A guest can close before SSH authenticates (e.g. sshd MaxStartups).
|
|
102
117
|
// Once output drains, end the proxy so SSH sees EOF instead of waiting
|
|
@@ -220,13 +235,26 @@ export const TARGET_HANDLERS = {
|
|
|
220
235
|
const controller = new AbortController();
|
|
221
236
|
const signal = AbortSignal.any([ctx.signal, controller.signal]);
|
|
222
237
|
const proxyCtx = { ...ctx, signal };
|
|
223
|
-
|
|
238
|
+
let trusted, distrusted;
|
|
239
|
+
const trust = new Promise((resolve, reject) => { trusted = resolve; distrusted = reject; });
|
|
240
|
+
trust.catch(() => { });
|
|
241
|
+
const stream = proxyStream(ctx.io, () => controller.abort(new Error('SSH proxy stream ended')), trust);
|
|
224
242
|
stream.on('error', error => controller.abort(error));
|
|
225
243
|
try {
|
|
226
244
|
const ws = await resolveWorkspace(proxyCtx, entry.workspace);
|
|
227
245
|
void ws.hint({ signal }).catch(() => undefined);
|
|
228
|
-
const cell = await
|
|
229
|
-
|
|
246
|
+
const cell = await sshCell(ws, proxyCtx);
|
|
247
|
+
// Trust and the tunnel start run together; the stream holds guest bytes until the host key is pinned.
|
|
248
|
+
const trusting = trustSsh(cell, targets, host, signal).then(trusted, (e) => { distrusted(e); controller.abort(e); throw e; });
|
|
249
|
+
const [opened, checked] = await Promise.allSettled([new WorkspaceTunnels(() => cell, () => ws.grantedTools).forward({ remotePort: 22, stream, signal, passive: true }), trusting]);
|
|
250
|
+
if (checked.status === 'rejected') {
|
|
251
|
+
if (opened.status === 'fulfilled')
|
|
252
|
+
await opened.value.close().catch(() => { });
|
|
253
|
+
throw checked.reason;
|
|
254
|
+
}
|
|
255
|
+
if (opened.status === 'rejected')
|
|
256
|
+
throw opened.reason;
|
|
257
|
+
const tunnel = opened.value;
|
|
230
258
|
try {
|
|
231
259
|
await tunnel.closed;
|
|
232
260
|
}
|
package/dist/args.d.ts
CHANGED
package/dist/args.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { parseArgs } from 'node:util';
|
|
13
13
|
import { ACCOUNT_COMMANDS } from "./account-args.js";
|
|
14
14
|
import { UsageError } from "./errors.js";
|
|
15
|
-
export const CLI_VERSION = '0.14.
|
|
15
|
+
export const CLI_VERSION = '0.14.1';
|
|
16
16
|
export const GLOBAL_OPTIONS = {
|
|
17
17
|
'api-url': { type: 'string', value: '<url>', description: 'API base URL (default $SHARDFLUX_API_URL, else https://api.shardflux.dev).' },
|
|
18
18
|
json: { type: 'boolean', description: 'Print machine-readable JSON on stdout (errors as JSON on stderr).' },
|
|
@@ -1614,7 +1614,7 @@ export function topHelp() {
|
|
|
1614
1614
|
' Codex and the agent runtime. "shard templates" is for your own app’s workspaces, not for agents: a repository',
|
|
1615
1615
|
' bound to such a template is refused (template_not_agent_capable).',
|
|
1616
1616
|
' In the VM, Claude Code and Codex have Shardflux computer use next to their own tools: the shardflux-computer MCP',
|
|
1617
|
-
' server’s computer and computer_batch drive the VM’s desktop (start programs with DISPLAY=:1).',
|
|
1617
|
+
' server’s computer and computer_batch drive the VM’s desktop (start programs with DISPLAY=:1 setsid -f).',
|
|
1618
1618
|
...sections,
|
|
1619
1619
|
'',
|
|
1620
1620
|
'Aliases: ws = workspaces, ops = operations, org = orgs, keys = api-keys; "shard workspaces files|operations|executions ..." also works.',
|
|
@@ -73,6 +73,9 @@ export async function prepareDesktopRouter(ctx, source) {
|
|
|
73
73
|
if (imported.exitCode !== 0)
|
|
74
74
|
throw new Error(`Project router repository could not be prepared: ${imported.stderr.trim()}`);
|
|
75
75
|
}
|
|
76
|
+
const seed = await cell.exec.run(['git', '-C', workspaceRepositoryPath(source), '-c', 'core.hooksPath=/dev/null', 'push', '/shardflux/project/git/hub.git', `${commit}:refs/shard/router/seeds/${commit}`], { timeoutMs: 120_000, signal: ctx.signal });
|
|
77
|
+
if (seed.exitCode !== 0)
|
|
78
|
+
throw new Error(`Project router hub seed failed: ${seed.stderr.trim()}`);
|
|
76
79
|
await destination.files.mkdir('/home/user/.config/shardflux', { parents: true, mode: '0700' });
|
|
77
80
|
await destination.files.write(ROUTER_CONFIG, JSON.stringify({ ...config, cwd: root }) + '\n', { mode: '0600' });
|
|
78
81
|
return router;
|
|
@@ -7,7 +7,7 @@ import { gitRunner } from "./agent-git.js";
|
|
|
7
7
|
import { shardRemote } from "./git-remote.js";
|
|
8
8
|
import { writePrivateFile } from "./credentials.js";
|
|
9
9
|
import { conversationClient } from "./conversation-commands.js";
|
|
10
|
-
const MAX =
|
|
10
|
+
const MAX = 64 << 20; // Guest FileManifest uses the project-sync 64 MiB per-file bound.
|
|
11
11
|
export function rewriteTranscript(bytes, remoteCwd, localCwd) {
|
|
12
12
|
const text = new TextDecoder('utf8', { fatal: true }).decode(bytes);
|
|
13
13
|
const rewrite = (value) => {
|
package/dist/conversations.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Workspace } from '@shardflux/sdk';
|
|
1
|
+
import type { Workspace, WorkspaceView } from '@shardflux/sdk';
|
|
2
2
|
import type { Ctx } from './commands.js';
|
|
3
3
|
export type Harness = 'claude' | 'codex';
|
|
4
4
|
export interface Conversation {
|
|
@@ -45,7 +45,7 @@ export declare function resolveConversation(ctx: Ctx, ref: string, folder?: stri
|
|
|
45
45
|
/** Re-resolve the binding on every attach: a move changes the VM, never the conversation identity. */
|
|
46
46
|
export declare function conversationWorkspace(ctx: Ctx, c: Conversation): Promise<Workspace>;
|
|
47
47
|
export declare function restoreConversationBinding(ctx: Ctx, c: Conversation, ws: Workspace): Promise<void>;
|
|
48
|
-
export declare function createConversationRecord(ctx: Ctx, folder: string, harness: Harness, route: Conversation['route'], name?: string): Promise<Conversation>;
|
|
48
|
+
export declare function createConversationRecord(ctx: Ctx, folder: string, harness: Harness, route: Conversation['route'], name?: string, allocated?: (workspace: WorkspaceView) => void, idempotencyKey?: string): Promise<Conversation>;
|
|
49
49
|
export declare function createConversation(ctx: Ctx, folder: string, harness: Harness, route: Conversation['route'], name?: string): Promise<{
|
|
50
50
|
conversation: Conversation;
|
|
51
51
|
workspace: Workspace;
|
package/dist/conversations.js
CHANGED
|
@@ -85,7 +85,7 @@ export async function restoreConversationBinding(ctx, c, ws) {
|
|
|
85
85
|
if (result.exitCode !== 0)
|
|
86
86
|
throw new Error(`Conversation restore failed: ${result.stderr.trim()}`);
|
|
87
87
|
}
|
|
88
|
-
export async function createConversationRecord(ctx, folder, harness, route, name) {
|
|
88
|
+
export async function createConversationRecord(ctx, folder, harness, route, name, allocated, idempotencyKey = randomUUID()) {
|
|
89
89
|
const project = projectIdentity(folder);
|
|
90
90
|
// Detect the optional control-plane feature before applying the new Git contract to old APIs.
|
|
91
91
|
await conversationClient(ctx).conversations.list({ limit: 1 });
|
|
@@ -93,8 +93,9 @@ export async function createConversationRecord(ctx, folder, harness, route, name
|
|
|
93
93
|
const commit = await git(['rev-parse', 'HEAD']);
|
|
94
94
|
const branch = await git(['symbolic-ref', '--quiet', '--short', 'HEAD']).catch(() => undefined);
|
|
95
95
|
const response = await conversationClient(ctx).conversations.create({ repoRemote: project.remote ?? `file://${project.folder}`, harness, route, mode: 'new',
|
|
96
|
-
base: { commit, ...(branch ? { branch } : {}) }, ...(name ? { name } : {}), projectId: ctx.store.profile(ctx.apiUrl).project_id }, { idempotencyKey
|
|
96
|
+
base: { commit, ...(branch ? { branch } : {}) }, ...(name ? { name } : {}), projectId: ctx.store.profile(ctx.apiUrl).project_id }, { idempotencyKey, signal: ctx.signal });
|
|
97
97
|
const c = validateConversation({ ...response.conversation, restore: response.restore });
|
|
98
|
+
allocated?.(response.workspace);
|
|
98
99
|
rememberConversation(ctx, c, project.folder);
|
|
99
100
|
return c;
|
|
100
101
|
}
|
package/dist/device-bridge.d.ts
CHANGED
|
@@ -39,7 +39,7 @@ type Result = {
|
|
|
39
39
|
stderr_b64?: string;
|
|
40
40
|
files_artifact?: string;
|
|
41
41
|
};
|
|
42
|
-
/** Resolve
|
|
42
|
+
/** Resolve existing ancestors even before the conversation worktree is created. */
|
|
43
43
|
export declare function devicePath(root: string, requested?: string): {
|
|
44
44
|
path: string;
|
|
45
45
|
outside: boolean;
|
package/dist/device-bridge.js
CHANGED
|
@@ -18,11 +18,7 @@ const LIMIT = 256 << 20, OUTPUT = (192 << 10) - 128;
|
|
|
18
18
|
const inside = (root, target) => target === root || target.startsWith(root + sep);
|
|
19
19
|
const safeId = (value) => { if (!/^[a-zA-Z0-9_-]{1,96}$/.test(value))
|
|
20
20
|
throw new Error('Invalid device request identity'); return value; };
|
|
21
|
-
|
|
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);
|
|
21
|
+
function canonicalDevicePath(target) {
|
|
26
22
|
let ancestor = target;
|
|
27
23
|
while (!existsSync(ancestor)) {
|
|
28
24
|
const parent = dirname(ancestor);
|
|
@@ -30,7 +26,14 @@ export function devicePath(root, requested = '.') {
|
|
|
30
26
|
throw new UsageError('Device path is unavailable.');
|
|
31
27
|
ancestor = parent;
|
|
32
28
|
}
|
|
33
|
-
|
|
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);
|
|
34
37
|
return { path: canonical, outside: !inside(base, canonical) };
|
|
35
38
|
}
|
|
36
39
|
export class DeviceGrants {
|
|
@@ -162,12 +165,13 @@ export class DeviceExecutor {
|
|
|
162
165
|
const record = records[r.conversation_id] ?? (folders.size === 1 ? matching[0] : undefined);
|
|
163
166
|
if (!record || record.project_id !== r.project_id)
|
|
164
167
|
return; // No inferred local path for unknown projects.
|
|
165
|
-
let c = await getConversation(ctx, r.conversation_id)
|
|
168
|
+
let c = await getConversation(ctx, r.conversation_id);
|
|
166
169
|
if (c.project_id !== r.project_id)
|
|
167
170
|
throw new Error('Device request project changed.');
|
|
168
|
-
const
|
|
171
|
+
const planned = devicePath(record.folder, join('.shard', 'worktrees', conversationName(c)));
|
|
169
172
|
const requested = r.exec?.cwd_relative ?? r.files?.local_path_relative ?? '.';
|
|
170
|
-
const target = devicePath(
|
|
173
|
+
const target = devicePath(planned.path, requested), grants = new DeviceGrants(ctx.store.dir);
|
|
174
|
+
target.outside ||= planned.outside;
|
|
171
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 });
|
|
172
176
|
if (choice === 'deny') {
|
|
173
177
|
await this.result(r, { state: 'denied' });
|
|
@@ -180,20 +184,27 @@ export class DeviceExecutor {
|
|
|
180
184
|
if (choice === 'always')
|
|
181
185
|
await grants.save(ctx.apiUrl, r.project_id, record.folder, r.kind);
|
|
182
186
|
// Re-read after the approval, before claiming or touching data.
|
|
183
|
-
if (devicePath(
|
|
187
|
+
if (devicePath(record.folder, join('.shard', 'worktrees', conversationName(c))).path !== planned.path || devicePath(planned.path, requested).path !== target.path)
|
|
184
188
|
throw new Error('Device path changed during approval.');
|
|
185
189
|
const current = await getConversation(ctx, r.conversation_id);
|
|
186
190
|
if (current.project_id !== c.project_id || current.branch !== c.branch || current.state === 'done' || current.state === 'archived')
|
|
187
191
|
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
192
|
c = current;
|
|
193
193
|
await this.result(r, { state: 'approved' });
|
|
194
194
|
await this.result(r, { state: 'running' });
|
|
195
|
-
|
|
195
|
+
let ws;
|
|
196
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();
|
|
197
208
|
let result;
|
|
198
209
|
if (r.kind === 'try_locally' || tryLocallyAction(r)) {
|
|
199
210
|
const action = tryLocallyAction(r);
|
|
@@ -260,7 +271,7 @@ export class DeviceExecutor {
|
|
|
260
271
|
}
|
|
261
272
|
finally {
|
|
262
273
|
if (!this.#handles.has(r.id))
|
|
263
|
-
cell.close();
|
|
274
|
+
ws?.cell().close();
|
|
264
275
|
}
|
|
265
276
|
}
|
|
266
277
|
async reap() {
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shardflux/cli",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "shard: the Shardflux command line. Create an account, sign in, set up keys and billing, then open, run commands in, move files to, suspend, resume and fork serverless agent workspaces.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"smol-toml": "1.9.0",
|
|
48
48
|
"snappyjs": "0.7.0",
|
|
49
49
|
"yaml": "2.9.1",
|
|
50
|
-
"@shardflux/sdk": "^0.19.
|
|
50
|
+
"@shardflux/sdk": "^0.19.1"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@eslint/js": "10.0.1",
|