@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
|
@@ -9,13 +9,16 @@ import { join, relative } from 'node:path';
|
|
|
9
9
|
import { projectIdentity } from "./project-identity.js";
|
|
10
10
|
import { projectWorkspacePath } from "./agent-repository.js";
|
|
11
11
|
import { UsageError } from "./errors.js";
|
|
12
|
+
import { prepareDesktopRouter } from "./conversation-router.js";
|
|
12
13
|
import { openUrl } from "./browser.js";
|
|
14
|
+
import { agentMachineName, prepareAgentMachine } from "./agent-machine.js";
|
|
15
|
+
import { prewarmT3 } from "./t3-runtime.js";
|
|
16
|
+
import { AgentStateStore } from "./agent-state.js";
|
|
13
17
|
const missing = (e) => e instanceof ShardfluxApiError && e.status === 404;
|
|
14
18
|
const keyOf = (ref) => ref.includes('/') ? ref : `${projectIdentity('./').slug}/${ref}`;
|
|
15
19
|
/** Optimistic file replacement preserves keys installed by other devices connecting at the same time. */
|
|
16
20
|
export async function installDeviceKey(cell, key, signal) {
|
|
17
21
|
const path = '/home/user/.ssh/authorized_keys';
|
|
18
|
-
await cell.files.mkdir('/home/user/.ssh', { mode: '0700', parents: true });
|
|
19
22
|
for (let attempt = 0; attempt < 4; attempt++) {
|
|
20
23
|
let text = '', revision = 'absent';
|
|
21
24
|
try {
|
|
@@ -30,8 +33,11 @@ export async function installDeviceKey(cell, key, signal) {
|
|
|
30
33
|
throw e;
|
|
31
34
|
}
|
|
32
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.
|
|
33
37
|
if (text.split('\n').some(line => line.trim().split(/\s+/).slice(0, 2).join(' ') === publicPart))
|
|
34
38
|
return;
|
|
39
|
+
if (revision === 'absent')
|
|
40
|
+
await cell.files.mkdir('/home/user/.ssh', { mode: '0700', parents: true });
|
|
35
41
|
try {
|
|
36
42
|
await cell.files.patch({ path, content: text + (text && !text.endsWith('\n') ? '\n' : '') + key + '\n', expectedRevision: revision, mode: '0600' }, { signal });
|
|
37
43
|
return;
|
|
@@ -43,18 +49,26 @@ export async function installDeviceKey(cell, key, signal) {
|
|
|
43
49
|
}
|
|
44
50
|
throw new Error('authorized_keys kept changing; retry the SSH connection');
|
|
45
51
|
}
|
|
46
|
-
|
|
47
|
-
export async function prepareSsh(ws, ctx, targets, host) {
|
|
52
|
+
async function sshCell(ws, ctx) {
|
|
48
53
|
if (!ws.ready)
|
|
49
54
|
await ws.wake({ timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
55
|
+
if (ws.labels?.t3_environment_id)
|
|
56
|
+
await prepareAgentMachine(ctx, ws);
|
|
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);
|
|
54
68
|
return cell;
|
|
55
69
|
}
|
|
56
70
|
async function openApp(target, env) {
|
|
57
|
-
const child = spawn('open', ['-b',
|
|
71
|
+
const child = spawn('open', ['-b', { 'claude-desktop': 'com.anthropic.claudefordesktop', 'codex-app': 'com.openai.codex', t3: 'com.t3tools.t3code' }[target]], { env, stdio: 'ignore' });
|
|
58
72
|
await new Promise((resolve, reject) => {
|
|
59
73
|
const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('app launcher timed out')); }, 10_000);
|
|
60
74
|
child.once('error', e => { clearTimeout(timer); reject(e); });
|
|
@@ -64,10 +78,10 @@ async function openApp(target, env) {
|
|
|
64
78
|
reject(new Error(`app launcher exited ${code}`)); });
|
|
65
79
|
});
|
|
66
80
|
}
|
|
67
|
-
export function proxyStream(io) {
|
|
81
|
+
export function proxyStream(io, ended, outputGate) {
|
|
68
82
|
const stdout = io.stdout;
|
|
69
83
|
const source = Readable.from(io.stdin, { objectMode: false });
|
|
70
|
-
let blocked = false;
|
|
84
|
+
let blocked = false, gate = outputGate;
|
|
71
85
|
const pump = () => {
|
|
72
86
|
while (!blocked && !stream.destroyed) {
|
|
73
87
|
const bytes = source.read();
|
|
@@ -80,27 +94,43 @@ export function proxyStream(io) {
|
|
|
80
94
|
allowHalfOpen: true,
|
|
81
95
|
read() { blocked = false; pump(); },
|
|
82
96
|
write(bytes, _encoding, done) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
stdout.once
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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();
|
|
94
115
|
},
|
|
95
|
-
|
|
116
|
+
// A guest can close before SSH authenticates (e.g. sshd MaxStartups).
|
|
117
|
+
// Once output drains, end the proxy so SSH sees EOF instead of waiting
|
|
118
|
+
// for input EOF from the client that is itself waiting for us.
|
|
119
|
+
final(done) { done(); ended?.(); },
|
|
120
|
+
destroy(error, done) { source.destroy(); stdout.off?.('error', outputError); stdout.off?.('close', outputClosed); done(error); },
|
|
96
121
|
});
|
|
122
|
+
const outputError = (error) => stream.destroy(error);
|
|
123
|
+
const outputClosed = () => stream.destroy(new Error('SSH client output closed'));
|
|
124
|
+
stdout.once?.('error', outputError);
|
|
125
|
+
stdout.once?.('close', outputClosed);
|
|
97
126
|
source.on('readable', pump);
|
|
98
|
-
source.once('end', () => stream.push(null));
|
|
127
|
+
source.once('end', () => { stream.push(null); ended?.(); });
|
|
99
128
|
source.once('error', (error) => stream.destroy(error));
|
|
100
129
|
return stream;
|
|
101
130
|
}
|
|
102
131
|
const openTarget = (target) => async (ctx, values, [ref = 'main']) => {
|
|
103
|
-
|
|
132
|
+
const detected = installedTargets(ctx.io.env);
|
|
133
|
+
if (target !== 'web' && !detected.targets.includes(target))
|
|
104
134
|
throw new UsageError(`${target} is not installed on this machine`);
|
|
105
135
|
let ws = await resolveWorkspace(ctx, keyOf(ref));
|
|
106
136
|
if (values.reachable !== undefined)
|
|
@@ -125,37 +155,57 @@ const openTarget = (target) => async (ctx, values, [ref = 'main']) => {
|
|
|
125
155
|
out(ctx, `${url}\n`, { target, workspace: ws.data, url });
|
|
126
156
|
return 0;
|
|
127
157
|
}
|
|
158
|
+
return openDesktopWorkspace(ctx, ws, target, detected.versions.t3);
|
|
159
|
+
};
|
|
160
|
+
export async function openDesktopWorkspace(ctx, ws, target, t3Version, prewarmed = false) {
|
|
161
|
+
if (target !== 't3')
|
|
162
|
+
ws = await prepareDesktopRouter(ctx, ws);
|
|
163
|
+
await prepareAgentMachine(ctx, ws);
|
|
164
|
+
if (target === 't3' && t3Version && !prewarmed)
|
|
165
|
+
await queueT3Prewarm(ctx, ws, t3Version);
|
|
128
166
|
const targets = new AgentTargets(ctx.io.env, ctx.store.dir);
|
|
129
|
-
const entry = targets.configure(ws.key, ctx.apiUrl, [target]).find(e => e.workspace === ws.key);
|
|
130
|
-
const instructions = target === '
|
|
131
|
-
? `
|
|
132
|
-
:
|
|
167
|
+
const entry = (await (ws.labels.shard_router === 'project' ? targets.configureProject(ws.key, ctx.apiUrl, [target], ctx.signal) : targets.configure(ws.key, ctx.apiUrl, [target], false, ctx.signal))).find(e => e.workspace === ws.key);
|
|
168
|
+
const instructions = target === 't3'
|
|
169
|
+
? `Add environment → SSH → ${entry.host} (${agentMachineName(ws.key)}), then open ${projectWorkspacePath({ slug: entry.project })}.`
|
|
170
|
+
: target === 'claude-desktop'
|
|
171
|
+
? `Restart Claude Desktop to load the connection, then choose ${targetLabel(entry)} in Code's environment dropdown and open ${projectWorkspacePath({ slug: entry.project })}.`
|
|
172
|
+
: `Restart Codex to import ${entry.host} and its remote project. If it has already been imported, enable ${entry.host} in Settings → Connections → SSH.`;
|
|
133
173
|
if (!ctx.json)
|
|
134
174
|
await openApp(target, ctx.io.env);
|
|
175
|
+
new AgentStateStore(ctx.store.dir).saveTarget(ws.labels.repo_remote || ws.labels.shard_project || entry.project, target);
|
|
135
176
|
out(ctx, `${instructions}\n`, { target, workspace: ws.data, host: entry.host, instructions });
|
|
136
177
|
return 0;
|
|
137
|
-
}
|
|
178
|
+
}
|
|
179
|
+
export async function queueT3Prewarm(ctx, ws, version) {
|
|
180
|
+
try {
|
|
181
|
+
await prewarmT3(ctx, ws, version);
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
ctx.io.stderr.write(`T3 runtime will install on connect: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
138
187
|
const tilde = (targets, file) => file.startsWith(`${targets.home}/`) ? `~/${relative(targets.home, file)}` : file;
|
|
139
|
-
const targetLabel = (e) => `[SF] ${e.project} · ${e.agent}`;
|
|
188
|
+
const targetLabel = (e) => e.agent === 'router' ? `Shardflux · ${e.project}` : `[SF] ${e.project} · ${e.agent}`;
|
|
140
189
|
/** Where each managed connection of an entry lives: the SSH host block (and its Include), then each desktop app's file. */
|
|
141
|
-
function configuredTargets(targets, e) {
|
|
190
|
+
function configuredTargets(targets, e, t3 = false) {
|
|
142
191
|
return [
|
|
143
192
|
{ target: 'ssh', label: 'SSH', file: targets.sshFile, name: e.host, detail: `(included from ${tilde(targets, join(targets.home, '.ssh', 'config'))}): ssh ${e.host}` },
|
|
144
193
|
...(e.targets.includes('claude-desktop') ? [{ target: 'claude-desktop', label: 'Claude Desktop', file: targets.claudeFile, name: targetLabel(e), detail: `(sshConfigs: ${targetLabel(e)}); restart Claude Desktop, then pick it in Code's environment list` }] : []),
|
|
145
194
|
...(e.targets.includes('codex-app') ? [{ target: 'codex-app', label: 'Codex app', file: targets.codexFile, name: targetLabel(e), detail: `(remoteConnections: ${targetLabel(e)}); restart Codex to import it` }] : []),
|
|
195
|
+
...(t3 ? [{ target: 't3', label: 'T3 Code', file: targets.sshFile, name: agentMachineName(e.workspace), detail: `Add environment → SSH → ${e.host}; project ${projectWorkspacePath({ slug: e.project })}` }] : []),
|
|
146
196
|
];
|
|
147
197
|
}
|
|
148
198
|
function targetFiles(targets, entries) {
|
|
149
199
|
return [...new Set(entries.flatMap(e => configuredTargets(targets, e).map(c => c.file)))];
|
|
150
200
|
}
|
|
151
201
|
export const TARGET_HANDLERS = {
|
|
152
|
-
'agents targets'(ctx, values, [ref]) {
|
|
202
|
+
async 'agents targets'(ctx, values, [ref]) {
|
|
153
203
|
const detected = installedTargets(ctx.io.env);
|
|
154
204
|
const targets = new AgentTargets(ctx.io.env, ctx.store.dir);
|
|
155
205
|
const key = ref ? keyOf(ref) : undefined;
|
|
156
|
-
const before = values.remove === true ? targets.configure(undefined, ctx.apiUrl, []) : [];
|
|
206
|
+
const before = values.remove === true ? await targets.configure(undefined, ctx.apiUrl, [], false, ctx.signal) : [];
|
|
157
207
|
const entries = ref || values.remove === true
|
|
158
|
-
? targets.configure(key, ctx.apiUrl, detected.targets.filter((t) => t === 'claude-desktop' || t === 'codex-app'), values.remove === true)
|
|
208
|
+
? await targets.configure(key, ctx.apiUrl, detected.targets.filter((t) => t === 'claude-desktop' || t === 'codex-app' || t === 't3'), values.remove === true, ctx.signal)
|
|
159
209
|
: [];
|
|
160
210
|
const detection = `${detected.clients.join(', ') || 'No installed agent clients'}\nOpen targets: ${detected.targets.join(', ')}\n`;
|
|
161
211
|
if (values.remove === true) {
|
|
@@ -168,23 +218,43 @@ export const TARGET_HANDLERS = {
|
|
|
168
218
|
out(ctx, detection, { ...detected, entries });
|
|
169
219
|
return Promise.resolve(0);
|
|
170
220
|
}
|
|
171
|
-
const
|
|
221
|
+
const t3 = detected.targets.includes('t3');
|
|
222
|
+
const configured = configuredTargets(targets, entry, t3);
|
|
172
223
|
out(ctx, `Configured ${entry.workspace} as SSH host ${entry.host}:\n${configured.map(c => ` ${c.label.padEnd(15)} ${tilde(targets, c.file)}${c.detail ? ` ${c.detail}` : ''}`).join('\n')}\n` +
|
|
173
|
-
['claude-desktop', 'codex-app'].filter(t => !entry.targets.includes(t)).map(t => ` ${
|
|
224
|
+
['claude-desktop', 'codex-app', 't3'].filter(t => t === 't3' ? !t3 : !entry.targets.includes(t)).map(t => ` ${{ 'claude-desktop': 'Claude Desktop', 'codex-app': 'Codex app', t3: 'T3 Code' }[t].padEnd(15)} not installed on this computer\n`).join(''), { ...detected, entries, configured: configured.map(({ target, file, name }) => ({ target, file, name })) });
|
|
174
225
|
return Promise.resolve(0);
|
|
175
226
|
},
|
|
176
227
|
'agents open claude-desktop': openTarget('claude-desktop'),
|
|
177
228
|
'agents open codex-app': openTarget('codex-app'),
|
|
229
|
+
'agents open t3': openTarget('t3'),
|
|
178
230
|
'agents open web': openTarget('web'),
|
|
179
231
|
async 'ssh-proxy'(ctx, _values, [host]) {
|
|
180
232
|
if (ctx.json || !host)
|
|
181
233
|
throw new UsageError('ssh-proxy requires a managed host and binary standard IO');
|
|
182
234
|
const targets = new AgentTargets(ctx.io.env, ctx.store.dir), entry = targets.entry(host, ctx.apiUrl);
|
|
183
|
-
const
|
|
184
|
-
const
|
|
185
|
-
const
|
|
235
|
+
const controller = new AbortController();
|
|
236
|
+
const signal = AbortSignal.any([ctx.signal, controller.signal]);
|
|
237
|
+
const proxyCtx = { ...ctx, signal };
|
|
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);
|
|
242
|
+
stream.on('error', error => controller.abort(error));
|
|
186
243
|
try {
|
|
187
|
-
const
|
|
244
|
+
const ws = await resolveWorkspace(proxyCtx, entry.workspace);
|
|
245
|
+
void ws.hint({ signal }).catch(() => undefined);
|
|
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;
|
|
188
258
|
try {
|
|
189
259
|
await tunnel.closed;
|
|
190
260
|
}
|
package/dist/agent-targets.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type DesktopTarget = 'claude-desktop' | 'codex-app';
|
|
1
|
+
export type DesktopTarget = 'claude-desktop' | 'codex-app' | 't3';
|
|
2
2
|
export interface TargetEntry {
|
|
3
3
|
host: string;
|
|
4
4
|
workspace: string;
|
|
@@ -10,9 +10,10 @@ export interface TargetEntry {
|
|
|
10
10
|
export declare function targetIdentity(key: string): Pick<TargetEntry, 'host' | 'workspace' | 'project' | 'agent'>;
|
|
11
11
|
/** Replace only a marked block, retaining every byte outside it. Put Includes before user Host blocks. */
|
|
12
12
|
export declare function managedBlock(text: string, body: string): string;
|
|
13
|
-
export declare function installedTargets(env: Record<string, string | undefined>, platform?: NodeJS.Platform): {
|
|
13
|
+
export declare function installedTargets(env: Record<string, string | undefined>, platform?: NodeJS.Platform, appDirs?: string[]): {
|
|
14
14
|
targets: string[];
|
|
15
15
|
clients: string[];
|
|
16
|
+
versions: Partial<Record<DesktopTarget, string>>;
|
|
16
17
|
};
|
|
17
18
|
/** All paths are supplied by the invocation; tests use a temporary HOME, never the owner's app stores. */
|
|
18
19
|
export declare class AgentTargets {
|
|
@@ -28,6 +29,7 @@ export declare class AgentTargets {
|
|
|
28
29
|
constructor(env: Record<string, string | undefined>, dir: string);
|
|
29
30
|
entry(host: string, apiUrl: string): TargetEntry;
|
|
30
31
|
deviceKey(): string;
|
|
31
|
-
pin(host: string, publicKey: string): void
|
|
32
|
-
configure(key: string | undefined, apiUrl: string, targets: DesktopTarget[], remove?: boolean): TargetEntry[]
|
|
32
|
+
pin(host: string, publicKey: string, signal?: AbortSignal): Promise<void>;
|
|
33
|
+
configure(key: string | undefined, apiUrl: string, targets: DesktopTarget[], remove?: boolean, signal?: AbortSignal): Promise<TargetEntry[]>;
|
|
34
|
+
configureProject(key: string, apiUrl: string, targets: DesktopTarget[], signal?: AbortSignal): Promise<TargetEntry[]>;
|
|
33
35
|
}
|
package/dist/agent-targets.js
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import { accessSync, constants, chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { accessSync, constants, chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { dirname, join, resolve } from 'node:path';
|
|
6
6
|
import { writePrivateFile } from "./credentials.js";
|
|
7
7
|
import { UsageError } from "./errors.js";
|
|
8
8
|
import { projectWorkspacePath } from "./agent-repository.js";
|
|
9
|
+
import { withTargetLock } from "./target-lock.js";
|
|
10
|
+
const PERSISTED_TARGETS = ['claude-desktop', 'codex-app'];
|
|
9
11
|
const BEGIN = '# BEGIN Shardflux managed targets';
|
|
10
12
|
const END = '# END Shardflux managed targets';
|
|
11
13
|
const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
12
14
|
const shellQuote = (s) => "'" + s.replaceAll("'", "'\\''").replaceAll('%', '%%') + "'";
|
|
13
15
|
const quote = (s) => '"' + s.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%') + '"';
|
|
16
|
+
// Some SSH config readers (T3 Code's host discovery) split Include on whitespace and keep quotes; quote only when needed.
|
|
17
|
+
const includePath = (s) => /^[^\s"'\\#%]+$/.test(s) ? s : quote(s);
|
|
14
18
|
export function targetIdentity(key) {
|
|
15
19
|
const parts = key.split('/');
|
|
16
20
|
if (parts.length !== 2 || parts.some(p => !/^[a-z0-9][a-z0-9._-]{0,95}$/.test(p)))
|
|
@@ -29,15 +33,48 @@ export function managedBlock(text, body) {
|
|
|
29
33
|
const stop = end + END.length + (text[end + END.length] === '\n' ? 1 : 0);
|
|
30
34
|
return text.slice(0, start) + block + text.slice(stop);
|
|
31
35
|
}
|
|
32
|
-
export function installedTargets(env, platform = process.platform) {
|
|
36
|
+
export function installedTargets(env, platform = process.platform, appDirs) {
|
|
33
37
|
const home = env.HOME || homedir();
|
|
34
|
-
|
|
38
|
+
// SHARDFLUX_APPLICATIONS_DIRS (colon-separated) replaces the scanned app folders, like SHARDFLUX_CONFIG_DIR does for config.
|
|
39
|
+
const dirs = appDirs ?? (env.SHARDFLUX_APPLICATIONS_DIRS !== undefined ? env.SHARDFLUX_APPLICATIONS_DIRS.split(':').filter(Boolean) : ['/Applications', join(home, 'Applications')]);
|
|
40
|
+
const hasApp = (name, bundle) => platform === 'darwin' && dirs.map(dir => join(dir, name)).some(path => {
|
|
35
41
|
const info = join(path, 'Contents', 'Info.plist');
|
|
36
42
|
if (!existsSync(info))
|
|
37
43
|
return false;
|
|
38
44
|
const result = spawnSync('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', info], { input: '', encoding: 'utf8', timeout: 1000 });
|
|
39
45
|
return result.status === 0 && result.stdout.trim() === bundle;
|
|
40
46
|
});
|
|
47
|
+
const versions = {};
|
|
48
|
+
let t3 = false;
|
|
49
|
+
if (platform === 'darwin')
|
|
50
|
+
for (const dir of dirs) {
|
|
51
|
+
let names;
|
|
52
|
+
try {
|
|
53
|
+
names = readdirSync(dir);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
for (const name of names.filter(name => name.endsWith('.app'))) {
|
|
59
|
+
const info = join(dir, name, 'Contents', 'Info.plist');
|
|
60
|
+
// XML and binary plists store this ASCII bundle id verbatim. Avoid a
|
|
61
|
+
// subprocess for every unrelated app on the normal no-T3 path.
|
|
62
|
+
try {
|
|
63
|
+
if (!readFileSync(info).includes('com.t3tools.t3code'))
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const result = spawnSync('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', '-c', 'Print :CFBundleShortVersionString', info], { input: '', encoding: 'utf8', timeout: 1000 });
|
|
70
|
+
const [bundle, version] = result.stdout?.trim().split('\n') ?? [];
|
|
71
|
+
if (bundle !== 'com.t3tools.t3code')
|
|
72
|
+
continue;
|
|
73
|
+
t3 = true;
|
|
74
|
+
if (result.status === 0 && version && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version))
|
|
75
|
+
versions.t3 ??= version;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
41
78
|
const executable = (name) => (env.PATH ?? '').split(':').filter(Boolean).some(p => { try {
|
|
42
79
|
accessSync(join(p, name), constants.X_OK);
|
|
43
80
|
return true;
|
|
@@ -46,7 +83,9 @@ export function installedTargets(env, platform = process.platform) {
|
|
|
46
83
|
return false;
|
|
47
84
|
} });
|
|
48
85
|
const clients = [hasApp('Claude.app', 'com.anthropic.claudefordesktop') && 'claude-desktop', (hasApp('Codex.app', 'com.openai.codex') || hasApp('ChatGPT.app', 'com.openai.codex')) && 'codex-app', executable('claude') && 'claude', executable('codex') && 'codex'].filter((v) => !!v);
|
|
49
|
-
|
|
86
|
+
if (t3)
|
|
87
|
+
clients.push('t3');
|
|
88
|
+
return { targets: [...(platform === 'darwin' ? clients.filter(c => c.endsWith('-app') || c === 'claude-desktop' || c === 't3') : []), 'web'], clients, versions };
|
|
50
89
|
}
|
|
51
90
|
/** All paths are supplied by the invocation; tests use a temporary HOME, never the owner's app stores. */
|
|
52
91
|
export class AgentTargets {
|
|
@@ -107,11 +146,16 @@ export class AgentTargets {
|
|
|
107
146
|
if (Object.values(doc.created).some(v => !Array.isArray(v) || v.some(k => typeof k !== 'string')))
|
|
108
147
|
throw new UsageError('invalid target ownership metadata');
|
|
109
148
|
for (const e of doc.entries) {
|
|
110
|
-
if (!object(e) || typeof e.workspace !== 'string' || typeof e.api_url !== 'string' || /[\r\n\0]/.test(e.api_url) || !Array.isArray(e.targets) || e.targets.some(t =>
|
|
149
|
+
if (!object(e) || typeof e.workspace !== 'string' || typeof e.api_url !== 'string' || /[\r\n\0]/.test(e.api_url) || !Array.isArray(e.targets) || e.targets.some(t => typeof t !== 'string'))
|
|
111
150
|
throw new UsageError('invalid Shardflux target entry');
|
|
151
|
+
// Forward compatible: a newer CLI may record targets this one does not know; they write no files here.
|
|
152
|
+
e.targets = e.targets.filter(t => PERSISTED_TARGETS.includes(t));
|
|
112
153
|
const identity = targetIdentity(e.workspace);
|
|
113
154
|
if (identity.host !== e.host || identity.project !== e.project || identity.agent !== e.agent)
|
|
114
155
|
throw new UsageError('invalid Shardflux target identity');
|
|
156
|
+
// Earlier CVE entries used this optional flag. Identity now comes solely
|
|
157
|
+
// from the router workspace key; remove the obsolete flag on rewrite.
|
|
158
|
+
delete e.project_router;
|
|
115
159
|
}
|
|
116
160
|
return doc;
|
|
117
161
|
}
|
|
@@ -139,8 +183,8 @@ export class AgentTargets {
|
|
|
139
183
|
const key = r.stdout.trim().split(/\s+/).slice(0, 2).join(' ');
|
|
140
184
|
return `${key} shardflux-device-${createHash('sha256').update(key).digest('hex').slice(0, 16)}`;
|
|
141
185
|
}
|
|
142
|
-
pin(host, publicKey) {
|
|
143
|
-
this
|
|
186
|
+
pin(host, publicKey, signal) {
|
|
187
|
+
return withTargetLock(this.dir, () => this.#pin(host, publicKey), signal);
|
|
144
188
|
}
|
|
145
189
|
#pin(host, publicKey) {
|
|
146
190
|
if (!/^shardflux-[a-z0-9._-]+$/.test(host) || !/^ssh-ed25519 [A-Za-z0-9+/=]+(?: [^\r\n]*)?$/.test(publicKey.trim()))
|
|
@@ -156,28 +200,19 @@ export class AgentTargets {
|
|
|
156
200
|
if (!previous.length)
|
|
157
201
|
writePrivateFile(this.dir, this.knownHosts, text + (text && !text.endsWith('\n') ? '\n' : '') + `${host} ${key}\n`);
|
|
158
202
|
}
|
|
159
|
-
configure(key, apiUrl, targets, remove = false) {
|
|
160
|
-
return this
|
|
203
|
+
configure(key, apiUrl, targets, remove = false, signal) {
|
|
204
|
+
return withTargetLock(this.dir, () => this.#configure(key, apiUrl, targets, remove), signal);
|
|
161
205
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
throw new UsageError('another target update holds targets.lock; retry after it finishes');
|
|
171
|
-
throw e;
|
|
172
|
-
}
|
|
173
|
-
try {
|
|
174
|
-
return work();
|
|
175
|
-
}
|
|
176
|
-
finally {
|
|
177
|
-
rmSync(lock, { recursive: true });
|
|
178
|
-
}
|
|
206
|
+
configureProject(key, apiUrl, targets, signal) {
|
|
207
|
+
return withTargetLock(this.dir, () => {
|
|
208
|
+
const identity = targetIdentity(key);
|
|
209
|
+
if (identity.agent !== 'router')
|
|
210
|
+
throw new UsageError('Project targets require the managed router workspace.');
|
|
211
|
+
const previous = this.#manifest().entries.filter(e => e.project === identity.project && e.api_url === apiUrl).flatMap(e => e.targets);
|
|
212
|
+
return this.#configure(key, apiUrl, [...new Set([...previous, ...targets])], false, true);
|
|
213
|
+
}, signal);
|
|
179
214
|
}
|
|
180
|
-
#configure(key, apiUrl, targets, remove) {
|
|
215
|
+
#configure(key, apiUrl, targets, remove, projectTarget = false) {
|
|
181
216
|
const url = new URL(apiUrl);
|
|
182
217
|
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || /[\r\n\0]/.test(apiUrl))
|
|
183
218
|
throw new UsageError('invalid target API URL');
|
|
@@ -188,8 +223,11 @@ export class AgentTargets {
|
|
|
188
223
|
const existing = old.find(e => e.host === identity?.host);
|
|
189
224
|
if (existing && (existing.workspace !== key || existing.api_url !== apiUrl))
|
|
190
225
|
throw new UsageError('SSH alias already belongs to another workspace or API URL');
|
|
191
|
-
|
|
192
|
-
const
|
|
226
|
+
// Only targets that own a config file are recorded: published CLIs reject any other value in this manifest.
|
|
227
|
+
const added = identity ? { ...identity, api_url: apiUrl, targets: [...new Set([...(existing?.targets ?? []), ...targets.filter((t) => PERSISTED_TARGETS.includes(t))])] } : undefined;
|
|
228
|
+
let entries = remove ? old.filter(e => identity ? e.host !== identity.host : e.api_url !== apiUrl) : existing ? old.map(e => e === existing ? added : e) : [...old, added];
|
|
229
|
+
if (projectTarget)
|
|
230
|
+
entries = entries.map(e => e.host !== added.host && e.project === added.project && e.api_url === apiUrl ? { ...e, targets: [] } : e);
|
|
193
231
|
const sshConfig = join(this.home, '.ssh', 'config');
|
|
194
232
|
const writes = new Map();
|
|
195
233
|
const rememberFile = (file) => {
|
|
@@ -208,7 +246,7 @@ export class AgentTargets {
|
|
|
208
246
|
const text = this.#text(this.knownHosts);
|
|
209
247
|
writes.set(this.knownHosts, text.split(/(?<=\n)/).filter(line => !removed.some(e => line.split(/\s+/)[0] === e.host)).join(''));
|
|
210
248
|
}
|
|
211
|
-
writes.set(sshConfig, managedBlock(this.#text(sshConfig), entries.length ? `Include ${
|
|
249
|
+
writes.set(sshConfig, managedBlock(this.#text(sshConfig), entries.length ? `Include ${includePath(this.sshFile)}` : ''));
|
|
212
250
|
const blocks = entries.map(e => `Host ${e.host}\n HostName ${e.host}\n ProxyCommand ${this.dir === join(this.home, '.config', 'shardflux') ? '' : `env ${shellQuote(`SHARDFLUX_CONFIG_DIR=${this.dir}`)} `}shard ssh-proxy %n --api-url ${shellQuote(e.api_url)}\n User user\n UserKnownHostsFile ${quote(this.knownHosts)}\n StrictHostKeyChecking yes\n IdentityFile ${quote(this.identityFile)}\n IdentitiesOnly yes`);
|
|
213
251
|
writes.set(this.sshFile, managedBlock(this.#text(this.sshFile), blocks.join('\n\n')));
|
|
214
252
|
for (const [file, prop, target] of [[this.claudeFile, 'sshConfigs', 'claude-desktop'], [this.codexFile, 'remoteConnections', 'codex-app']]) {
|
|
@@ -229,7 +267,10 @@ export class AgentTargets {
|
|
|
229
267
|
manifest.created[file] ??= [];
|
|
230
268
|
if (!(prop in doc) && !manifest.created[file].includes(prop))
|
|
231
269
|
manifest.created[file].push(prop);
|
|
232
|
-
doc[prop] = [...kept, ...entries.filter(e => e.targets.includes(target)).map(e =>
|
|
270
|
+
doc[prop] = [...kept, ...entries.filter(e => e.targets.includes(target)).map(e => {
|
|
271
|
+
const label = e.agent === 'router' ? `Shardflux · ${e.project}` : `[SF] ${e.project} · ${e.agent}`;
|
|
272
|
+
return target === 'claude-desktop' ? { id: e.host, name: label, sshHost: e.host, startDirectory: projectWorkspacePath({ slug: e.project }) } : { sshAlias: e.host, projects: [{ remotePath: projectWorkspacePath({ slug: e.project }), label }] };
|
|
273
|
+
})];
|
|
233
274
|
if (!doc[prop].length && manifest.created[file].includes(prop))
|
|
234
275
|
delete doc[prop];
|
|
235
276
|
if (file === this.codexFile) {
|
package/dist/args.d.ts
CHANGED