@shardflux/cli 0.28.2 → 0.28.3
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 +20 -0
- package/README.md +34 -3
- package/dist/args.d.ts +1 -1
- package/dist/args.js +12 -1
- package/dist/board-args.js +2 -1
- package/dist/board-commands.js +5 -2
- package/dist/commands.d.ts +2 -0
- package/dist/commands.js +5 -5
- package/dist/credential-device-capability.d.ts +8 -0
- package/dist/credential-device-capability.js +21 -0
- package/dist/credential-setup.d.ts +13 -0
- package/dist/credential-setup.js +170 -0
- package/dist/credential-sources.d.ts +34 -0
- package/dist/credential-sources.js +400 -0
- package/dist/device-bridge.d.ts +8 -1
- package/dist/device-bridge.js +45 -2
- package/dist/device-helper.js +15 -7
- package/dist/device-mode.d.ts +7 -0
- package/dist/device-mode.js +13 -0
- package/dist/device-outcomes.d.ts +3 -0
- package/dist/device-outcomes.js +19 -0
- package/dist/handler-loader.js +2 -1
- package/dist/handlers.js +2 -1
- package/dist/land.d.ts +4 -1
- package/dist/land.js +32 -3
- package/dist/machine-setup.d.ts +1 -0
- package/dist/machine-setup.js +5 -2
- package/dist/main.js +30 -2
- package/dist/orchestrator-args.js +11 -1
- package/dist/orchestrator-commands.js +44 -3
- package/dist/task-auto-land.d.ts +22 -0
- package/dist/task-auto-land.js +162 -0
- package/dist/task-land-hub.py +6 -0
- package/dist/vault.js +3 -8
- package/package.json +6 -6
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/** Source discovery exposes descriptors only. Values stay in this process and go directly to the write-only API. */
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { readFile, readdir, access } from 'node:fs/promises';
|
|
4
|
+
import { constants } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { parseEnv } from 'node:util';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { parse as parseYaml } from 'yaml';
|
|
9
|
+
import { vaultValue } from "./vault.js";
|
|
10
|
+
export class CredentialSourceError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
constructor(code) { super('The credential source could not be read.'); this.code = code; }
|
|
13
|
+
}
|
|
14
|
+
const SYSTEM_NAMES = new Set(['PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'LANG', 'LANGUAGE', 'LC_ALL', 'TERM', 'COLORTERM', 'PWD', 'OLDPWD', 'SHLVL', '_', 'TMPDIR', 'TMP', 'TEMP', 'XDG_RUNTIME_DIR', 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME', 'XDG_DATA_HOME', 'SSH_AUTH_SOCK', 'SSH_AGENT_PID', 'DISPLAY', 'EDITOR', 'VISUAL', 'PAGER', 'MANPAGER', 'LESS', 'LSCOLORS', 'LS_COLORS', 'TERM_PROGRAM', 'TERM_PROGRAM_VERSION', 'TERM_SESSION_ID', 'VIRTUAL_ENV', 'CONDA_PREFIX', 'CONDA_DEFAULT_ENV', 'CONDA_SHLVL', 'JAVA_HOME', 'GOPATH', 'GOROOT', 'GOCACHE', 'GOMODCACHE', 'RUSTUP_HOME', 'CARGO_HOME', 'NODE_PATH', 'NODE_OPTIONS', 'BASH_ENV', 'ENV', 'SHELLOPTS', 'BASHOPTS', 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'SHARD_DEVICE_HELPER']);
|
|
15
|
+
for (const name of ['BW_SESSION', 'BW_PASSWORD', 'BW_CLIENTSECRET', 'OP_SERVICE_ACCOUNT_TOKEN', 'OP_CONNECT_TOKEN', 'NODE_ENV', 'SF_CONVERSATION_ID', 'SF_PROJECT_ID', 'SF_WORKSPACE_ID', 'SF_ORGANIZATION_ID', 'SF_AGENT_LABEL', 'SF_HARNESS_SESSION_ID', 'SF_ACTOR', 'SF_ENV'])
|
|
16
|
+
SYSTEM_NAMES.add(name);
|
|
17
|
+
// Same owned launcher controls as API agent-names and guest agentenv.ValidName.
|
|
18
|
+
for (const name of ['SF_PROJECT_NAME', 'SF_REPO_NAME', 'SF_ACP_MODEL_DISCOVERY', 'SF_DESKTOP_SOCKET', 'SF_PTY_SESSION_ID', 'SF_SESSION_PLACE', 'SF_SESSION_TITLE', 'SF_SESSION_TITLE_PREFIX', 'SF_SESSION_TITLE_FROM_PROMPT', 'SF_TASK_TITLE', 'SF_LOGIN_ENV', 'CODEX_HOME', 'CLAUDE_CONFIG_DIR', 'CLAUDE_CODE_ENABLE_TELEMETRY', 'DISABLE_TELEMETRY', 'DISABLE_ERROR_REPORTING'])
|
|
19
|
+
SYSTEM_NAMES.add(name);
|
|
20
|
+
export function credentialVariableName(name) { return /^[A-Z_][A-Z0-9_]{0,127}$/.test(name) && !name.startsWith('SF_AGENT_') && !name.startsWith('SHARDFLUX_') && !name.startsWith('OP_SESSION_') && !name.startsWith('LC_') && !SYSTEM_NAMES.has(name); }
|
|
21
|
+
function checked(value) {
|
|
22
|
+
if (value === undefined)
|
|
23
|
+
throw new CredentialSourceError('source_changed');
|
|
24
|
+
if (!value.length)
|
|
25
|
+
throw new CredentialSourceError('source_empty');
|
|
26
|
+
if (value.includes('\0'))
|
|
27
|
+
throw new CredentialSourceError('source_has_nul');
|
|
28
|
+
if (Buffer.byteLength(value) > 65536)
|
|
29
|
+
throw new CredentialSourceError('source_too_large');
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
async function text(path) {
|
|
33
|
+
let bytes;
|
|
34
|
+
try {
|
|
35
|
+
bytes = await readFile(path);
|
|
36
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
bytes?.fill(0);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function shellAssignments(source) {
|
|
46
|
+
const result = new Map();
|
|
47
|
+
// Parse assignment syntax; never source/evaluate a shell file or hand it to a model.
|
|
48
|
+
let through = 0;
|
|
49
|
+
for (const m of source.matchAll(/^[ \t]*(?:export[ \t]+)?([A-Z_][A-Z0-9_]*)=/gm)) {
|
|
50
|
+
if (m.index < through)
|
|
51
|
+
continue;
|
|
52
|
+
const start = m.index + m[0].length;
|
|
53
|
+
let i = start, quote = '';
|
|
54
|
+
for (; i < source.length; i++) {
|
|
55
|
+
const c = source[i];
|
|
56
|
+
if (c === '\\' && quote !== "'") {
|
|
57
|
+
i++;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (c === quote) {
|
|
61
|
+
quote = '';
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!quote && (c === "'" || c === '"')) {
|
|
65
|
+
quote = c;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (!quote && (c === '\n' || c === '#' && i > start && /\s/.test(source[i - 1])))
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
result.set(m[1], source.slice(start, i));
|
|
72
|
+
through = i;
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
function shellLiteral(expression, env) {
|
|
77
|
+
let value = '', quote = '';
|
|
78
|
+
for (let i = 0; i < expression.length; i++) {
|
|
79
|
+
const c = expression[i];
|
|
80
|
+
if (c === '\\' && quote !== "'") {
|
|
81
|
+
const next = expression[++i];
|
|
82
|
+
if (next === undefined)
|
|
83
|
+
throw new CredentialSourceError('source_unavailable');
|
|
84
|
+
if (quote === '"' && !['$', '`', '"', '\\', '\n'].includes(next))
|
|
85
|
+
value += '\\';
|
|
86
|
+
if (next !== '\n')
|
|
87
|
+
value += next;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (c === quote) {
|
|
91
|
+
quote = '';
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if ((c === "'" || c === '"') && !quote) {
|
|
95
|
+
quote = c;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (!quote && /\s/.test(c)) {
|
|
99
|
+
const tail = expression.slice(i).trim();
|
|
100
|
+
if (!tail || tail.startsWith('#'))
|
|
101
|
+
return checked(value);
|
|
102
|
+
throw new CredentialSourceError('source_unavailable');
|
|
103
|
+
}
|
|
104
|
+
if (quote !== "'" && c === '`' || !quote && [';', '|', '&', '<', '>', '(', ')'].includes(c))
|
|
105
|
+
throw new CredentialSourceError('source_unavailable');
|
|
106
|
+
if (quote !== "'" && c === '$') {
|
|
107
|
+
const m = /^\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/.exec(expression.slice(i));
|
|
108
|
+
if (!m)
|
|
109
|
+
throw new CredentialSourceError('source_unavailable');
|
|
110
|
+
const name = m[1] ?? m[2];
|
|
111
|
+
if (env[name] === undefined)
|
|
112
|
+
throw new CredentialSourceError('source_unavailable');
|
|
113
|
+
value += env[name];
|
|
114
|
+
i += m[0].length - 1;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
value += c;
|
|
118
|
+
}
|
|
119
|
+
if (quote)
|
|
120
|
+
throw new CredentialSourceError('source_unavailable');
|
|
121
|
+
return checked(value);
|
|
122
|
+
}
|
|
123
|
+
function vaultExpression(expression) {
|
|
124
|
+
const raw = expression.trim().replace(/^"(.*)"$/, '$1');
|
|
125
|
+
let m = /^\$\(hsec get ([A-Za-z0-9_./:@+-]+)\)$/.exec(raw);
|
|
126
|
+
if (m)
|
|
127
|
+
return { vault: 'hsec', reference: m[1] };
|
|
128
|
+
m = /^\$\(op read (op:\/\/[^\s;|&`$]+)\)$/.exec(raw);
|
|
129
|
+
if (m)
|
|
130
|
+
return { vault: '1password', reference: m[1] };
|
|
131
|
+
m = /^\$\(bw get password ([A-Za-z0-9_./:@+-]+)\)$/.exec(raw);
|
|
132
|
+
if (m)
|
|
133
|
+
return { vault: 'bitwarden', reference: m[1] };
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
function ini(source) {
|
|
137
|
+
const sections = new Map();
|
|
138
|
+
let section = 'default';
|
|
139
|
+
for (const line of source.split(/\r?\n/)) {
|
|
140
|
+
const group = /^\s*\[([^\]]+)\]\s*$/.exec(line);
|
|
141
|
+
if (group) {
|
|
142
|
+
section = group[1].replace(/^profile /, '');
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const pair = /^\s*([^=;#\s]+)\s*=\s*(.*?)\s*$/.exec(line);
|
|
146
|
+
if (!pair)
|
|
147
|
+
continue;
|
|
148
|
+
if (!sections.has(section))
|
|
149
|
+
sections.set(section, {});
|
|
150
|
+
sections.get(section)[pair[1]] = pair[2];
|
|
151
|
+
}
|
|
152
|
+
return sections;
|
|
153
|
+
}
|
|
154
|
+
async function privateRun(argv, env, signal) {
|
|
155
|
+
return new Promise((resolve, reject) => {
|
|
156
|
+
const chunks = [];
|
|
157
|
+
const child = spawn(argv[0], argv.slice(1), { env, signal, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
158
|
+
child.stdout.on('data', (b) => chunks.push(b));
|
|
159
|
+
const wipe = () => { for (const b of chunks)
|
|
160
|
+
b.fill(0); };
|
|
161
|
+
child.once('error', () => { wipe(); reject(new CredentialSourceError('source_unavailable')); });
|
|
162
|
+
child.once('close', code => { if (code !== 0) {
|
|
163
|
+
wipe();
|
|
164
|
+
reject(new CredentialSourceError('login_expired'));
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
const b = Buffer.concat(chunks);
|
|
168
|
+
wipe();
|
|
169
|
+
resolve(b);
|
|
170
|
+
} });
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
async function hasCommand(command, options) {
|
|
174
|
+
if (options.availableCommands)
|
|
175
|
+
return options.availableCommands.includes(command);
|
|
176
|
+
for (const folder of (options.env.PATH ?? '').split(':').filter(Boolean))
|
|
177
|
+
try {
|
|
178
|
+
await access(join(folder, command), constants.X_OK);
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
catch { /* next PATH entry */ }
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
/** Vault output stays private. Only item names/IDs become candidate descriptors. */
|
|
185
|
+
async function vaultCandidates(options) {
|
|
186
|
+
const result = [], run = options.run ?? privateRun;
|
|
187
|
+
for (const command of ['hsec', 'op', 'bw', 'security']) {
|
|
188
|
+
if (!await hasCommand(command, options))
|
|
189
|
+
continue;
|
|
190
|
+
const argv = command === 'hsec' ? ['hsec', 'ls'] : command === 'op' ? ['op', 'item', 'list', '--format', 'json'] : command === 'bw' ? ['bw', 'list', 'items'] : ['security', 'dump-keychain'];
|
|
191
|
+
let bytes;
|
|
192
|
+
try {
|
|
193
|
+
bytes = await run(argv, options.env, options.signal);
|
|
194
|
+
const source = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
195
|
+
if (command === 'hsec')
|
|
196
|
+
for (const name of source.split(/\r?\n/).map(n => n.trim())) {
|
|
197
|
+
if (credentialVariableName(name))
|
|
198
|
+
result.push({ name, vault: 'hsec', item: name, label: 'hsec' });
|
|
199
|
+
}
|
|
200
|
+
else if (command === 'security')
|
|
201
|
+
for (const match of source.matchAll(/"svce"<blob>="([A-Z_][A-Z0-9_]*)"/g)) {
|
|
202
|
+
const name = match[1];
|
|
203
|
+
if (credentialVariableName(name))
|
|
204
|
+
result.push({ name, vault: 'keychain', item: name, label: 'Keychain' });
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
const items = JSON.parse(source);
|
|
208
|
+
if (!Array.isArray(items))
|
|
209
|
+
continue;
|
|
210
|
+
for (const raw of items) {
|
|
211
|
+
if (!raw || typeof raw !== 'object')
|
|
212
|
+
continue;
|
|
213
|
+
const item = raw;
|
|
214
|
+
const name = command === 'op' ? item.title : item.name;
|
|
215
|
+
if (typeof name !== 'string' || !credentialVariableName(name) || typeof item.id !== 'string' || !item.id || item.id.startsWith('-') || item.id.includes('\0') || item.deletedDate)
|
|
216
|
+
continue;
|
|
217
|
+
result.push({ name, vault: command === 'op' ? '1password' : 'bitwarden', item: item.id, label: command === 'op' ? '1Password' : 'Bitwarden' });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
catch { /* A locked or unavailable vault does not block the other existing sources. */ }
|
|
222
|
+
finally {
|
|
223
|
+
bytes?.fill(0);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return result;
|
|
227
|
+
}
|
|
228
|
+
/** Normal local login startup resolves exported vault expressions privately, without a model reading shell files. */
|
|
229
|
+
export async function captureLocalLoginEnvironment(options) {
|
|
230
|
+
const shell = options.env.SHELL;
|
|
231
|
+
if (!shell?.startsWith('/'))
|
|
232
|
+
return { ...options.env };
|
|
233
|
+
const marker = '\0SHARDFLUX_CREDENTIAL_ENV_START\0';
|
|
234
|
+
let bytes;
|
|
235
|
+
try {
|
|
236
|
+
bytes = await (options.run ?? privateRun)([shell, '-ilc', "printf '\\0SHARDFLUX_CREDENTIAL_ENV_START\\0'; /usr/bin/env -0"], { ...options.env, HOME: options.home }, options.signal);
|
|
237
|
+
const start = bytes.indexOf(Buffer.from(marker));
|
|
238
|
+
if (start < 0)
|
|
239
|
+
return { ...options.env };
|
|
240
|
+
const env = { ...options.env };
|
|
241
|
+
for (const entry of new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(start + Buffer.byteLength(marker))).split('\0')) {
|
|
242
|
+
const equal = entry.indexOf('=');
|
|
243
|
+
if (equal > 0)
|
|
244
|
+
env[entry.slice(0, equal)] = entry.slice(equal + 1);
|
|
245
|
+
}
|
|
246
|
+
return env;
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return { ...options.env };
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
bytes?.fill(0);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/** Reconstruct a source from metadata only, so a review can finish in a later CLI process. */
|
|
256
|
+
export async function readCredentialSource(candidate, options) {
|
|
257
|
+
const { source, name } = candidate;
|
|
258
|
+
try {
|
|
259
|
+
if (source.kind === 'environment')
|
|
260
|
+
return checked(options.env[source.reference]);
|
|
261
|
+
if (source.kind === 'dotenv') {
|
|
262
|
+
const file = await text(source.reference);
|
|
263
|
+
if (file === undefined)
|
|
264
|
+
throw new CredentialSourceError('source_unavailable');
|
|
265
|
+
return checked(parseEnv(file)[name]);
|
|
266
|
+
}
|
|
267
|
+
if (source.kind === 'shell') {
|
|
268
|
+
const file = await text(source.reference);
|
|
269
|
+
if (file === undefined)
|
|
270
|
+
throw new CredentialSourceError('source_unavailable');
|
|
271
|
+
const expression = shellAssignments(file).get(name);
|
|
272
|
+
if (expression === undefined)
|
|
273
|
+
throw new CredentialSourceError('source_changed');
|
|
274
|
+
return shellLiteral(expression, options.env);
|
|
275
|
+
}
|
|
276
|
+
const ref = JSON.parse(source.reference);
|
|
277
|
+
if (source.kind === 'vault') {
|
|
278
|
+
if (!ref.vault || !ref.item)
|
|
279
|
+
throw new CredentialSourceError('source_unavailable');
|
|
280
|
+
try {
|
|
281
|
+
return checked(await vaultValue(ref.vault, ref.item, { env: options.env, signal: options.signal }));
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
throw new CredentialSourceError('vault_locked');
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (ref.kind === 'aws-file') {
|
|
288
|
+
if (!ref.path || !ref.profile || !ref.field)
|
|
289
|
+
throw new CredentialSourceError('source_unavailable');
|
|
290
|
+
const file = await text(ref.path);
|
|
291
|
+
if (file === undefined)
|
|
292
|
+
throw new CredentialSourceError('source_unavailable');
|
|
293
|
+
return checked(ini(file).get(ref.profile)?.[ref.field]);
|
|
294
|
+
}
|
|
295
|
+
const run = options.run ?? privateRun;
|
|
296
|
+
if (ref.kind === 'aws' && ref.profile && ref.field) {
|
|
297
|
+
const bytes = await run(['aws', 'configure', 'export-credentials', '--profile', ref.profile, '--format', 'process'], options.env, options.signal);
|
|
298
|
+
try {
|
|
299
|
+
const data = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
300
|
+
return checked(data[ref.field]);
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
bytes.fill(0);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (ref.kind === 'gh' && ref.host) {
|
|
307
|
+
const bytes = await run(['gh', 'auth', 'token', '--hostname', ref.host], options.env, options.signal);
|
|
308
|
+
try {
|
|
309
|
+
return checked(new TextDecoder('utf-8', { fatal: true }).decode(bytes).replace(/\r?\n$/, ''));
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
bytes.fill(0);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
throw new CredentialSourceError('source_unavailable');
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
if (err instanceof CredentialSourceError)
|
|
319
|
+
throw err;
|
|
320
|
+
throw new CredentialSourceError('source_not_text');
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
export async function discoverCredentialSources(options) {
|
|
324
|
+
const found = [];
|
|
325
|
+
const add = (name, kind, label, reference, scope, preferred = true) => {
|
|
326
|
+
if (!credentialVariableName(name))
|
|
327
|
+
return;
|
|
328
|
+
found.push({ id: randomUUID(), name, source: { kind, label, reference }, suggested_scope: scope, ...(scope === 'project' && options.projectId ? { project_id: options.projectId } : {}), preferred });
|
|
329
|
+
};
|
|
330
|
+
for (const name of Object.keys(options.env).sort())
|
|
331
|
+
if (options.env[name] !== undefined)
|
|
332
|
+
add(name, 'environment', 'Current shell', name, 'agents');
|
|
333
|
+
for (const filename of ['.zshenv', '.zprofile', '.zshrc', '.bash_profile', '.bash_login', '.profile', '.bashrc']) {
|
|
334
|
+
const path = join(options.home, filename), source = await text(path);
|
|
335
|
+
if (source === undefined)
|
|
336
|
+
continue;
|
|
337
|
+
for (const [name, expression] of shellAssignments(source)) {
|
|
338
|
+
const vault = vaultExpression(expression);
|
|
339
|
+
if (vault)
|
|
340
|
+
add(name, 'vault', '~/' + filename + ' · ' + vault.vault, JSON.stringify({ vault: vault.vault, item: vault.reference }), 'agents', options.env[name] === undefined);
|
|
341
|
+
else
|
|
342
|
+
add(name, 'shell', '~/' + filename, path, 'agents', options.env[name] === undefined);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (options.projectPath) {
|
|
346
|
+
let names = [];
|
|
347
|
+
try {
|
|
348
|
+
names = await readdir(options.projectPath);
|
|
349
|
+
}
|
|
350
|
+
catch { /* a missing folder has no files */ }
|
|
351
|
+
const examples = new Set(['.env.example', '.env.sample', '.env.template', '.env.defaults', '.env.dist']);
|
|
352
|
+
const mode = options.env.NODE_ENV, priority = mode ? [`.env.${mode}.local`, '.env.local', `.env.${mode}`, '.env'] : ['.env.local', '.env'];
|
|
353
|
+
const rank = (name) => priority.includes(name) ? priority.indexOf(name) : priority.length;
|
|
354
|
+
for (const filename of names.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b)))
|
|
355
|
+
if (filename.startsWith('.env') && !examples.has(filename)) {
|
|
356
|
+
const path = join(options.projectPath, filename), source = await text(path);
|
|
357
|
+
if (source === undefined)
|
|
358
|
+
continue;
|
|
359
|
+
for (const name of Object.keys(parseEnv(source)))
|
|
360
|
+
add(name, 'dotenv', filename, path, 'project');
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
const awsFile = join(options.home, '.aws/credentials'), credentials = await text(awsFile), config = await text(join(options.home, '.aws/config'));
|
|
364
|
+
const profiles = new Map();
|
|
365
|
+
for (const [name, fields] of ini(credentials ?? ''))
|
|
366
|
+
profiles.set(name, fields);
|
|
367
|
+
for (const [name] of ini(config ?? ''))
|
|
368
|
+
if (!name.startsWith('sso-session ') && !profiles.has(name))
|
|
369
|
+
profiles.set(name, {});
|
|
370
|
+
const active = options.env.AWS_PROFILE ?? options.env.AWS_DEFAULT_PROFILE ?? 'default';
|
|
371
|
+
const fields = [['AWS_ACCESS_KEY_ID', 'aws_access_key_id', 'AccessKeyId'], ['AWS_SECRET_ACCESS_KEY', 'aws_secret_access_key', 'SecretAccessKey'], ['AWS_SESSION_TOKEN', 'aws_session_token', 'SessionToken']];
|
|
372
|
+
for (const [profile, values] of profiles)
|
|
373
|
+
for (const [name, field, apiField] of fields) {
|
|
374
|
+
if (Object.keys(values).length && !values[field])
|
|
375
|
+
continue;
|
|
376
|
+
add(name, 'cli', 'AWS profile ' + profile, JSON.stringify(values[field] ? { kind: 'aws-file', path: awsFile, profile, field } : { kind: 'aws', profile, field: apiField }), 'agents', profile === active && options.env[name] === undefined);
|
|
377
|
+
}
|
|
378
|
+
const hosts = await text(join(options.home, '.config/gh/hosts.yml'));
|
|
379
|
+
if (hosts) {
|
|
380
|
+
try {
|
|
381
|
+
const data = parseYaml(hosts);
|
|
382
|
+
if (data && typeof data === 'object')
|
|
383
|
+
for (const host of Object.keys(data))
|
|
384
|
+
add('GH_TOKEN', 'cli', 'GitHub login · ' + host, JSON.stringify({ kind: 'gh', host }), 'agents', host === (options.env.GH_HOST ?? 'github.com') && options.env.GH_TOKEN === undefined);
|
|
385
|
+
}
|
|
386
|
+
catch { /* unreadable login metadata is not sent to a model or report */ }
|
|
387
|
+
}
|
|
388
|
+
for (const item of await vaultCandidates(options))
|
|
389
|
+
add(item.name, 'vault', item.label, JSON.stringify({ vault: item.vault, item: item.item }), 'agents', options.env[item.name] === undefined);
|
|
390
|
+
// Ordinary defaults use the first preferred source per logical name/scope. Every alternative remains reviewable.
|
|
391
|
+
const chosen = new Set();
|
|
392
|
+
for (const candidate of found) {
|
|
393
|
+
const key = JSON.stringify([candidate.suggested_scope, candidate.project_id, candidate.name]);
|
|
394
|
+
if (candidate.preferred && chosen.has(key))
|
|
395
|
+
candidate.preferred = false;
|
|
396
|
+
if (candidate.preferred)
|
|
397
|
+
chosen.add(key);
|
|
398
|
+
}
|
|
399
|
+
return found;
|
|
400
|
+
}
|
package/dist/device-bridge.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export interface DeviceRequest {
|
|
|
10
10
|
conversation_id: string;
|
|
11
11
|
state: string;
|
|
12
12
|
device_id: string | null;
|
|
13
|
-
kind: 'exec' | 'files_pull' | 'files_push' | 'port_forward' | 'port_reverse' | 'try_locally' | 'dir_list' | 'project_link';
|
|
13
|
+
kind: 'exec' | 'files_pull' | 'files_push' | 'port_forward' | 'port_reverse' | 'try_locally' | 'dir_list' | 'project_link' | 'credentials_setup';
|
|
14
14
|
exec?: {
|
|
15
15
|
argv: string[];
|
|
16
16
|
cwd_relative?: string;
|
|
@@ -36,6 +36,10 @@ export interface DeviceRequest {
|
|
|
36
36
|
brief?: string;
|
|
37
37
|
harness?: 'claude' | 'codex';
|
|
38
38
|
};
|
|
39
|
+
credentials?: {
|
|
40
|
+
path?: string;
|
|
41
|
+
names?: string[];
|
|
42
|
+
};
|
|
39
43
|
}
|
|
40
44
|
export interface DeviceApproval {
|
|
41
45
|
type: 'approval';
|
|
@@ -72,6 +76,9 @@ export interface DeviceApproval {
|
|
|
72
76
|
choose_mode?: true;
|
|
73
77
|
}
|
|
74
78
|
export type Result = {
|
|
79
|
+
credential_setup?: {
|
|
80
|
+
plan_id: string;
|
|
81
|
+
};
|
|
75
82
|
state: 'approved' | 'denied' | 'running' | 'done' | 'failed';
|
|
76
83
|
exit_code?: number;
|
|
77
84
|
stdout_b64?: string;
|
package/dist/device-bridge.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { captureLocalLoginEnvironment, discoverCredentialSources } from "./credential-sources.js";
|
|
2
|
+
import { transferCredentialSetup } from "./credential-setup.js";
|
|
1
3
|
import { createHash } from 'node:crypto';
|
|
2
4
|
import { spawn } from 'node:child_process';
|
|
3
5
|
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from 'node:fs';
|
|
@@ -17,7 +19,7 @@ import { checkConversationWorktree, rememberConversationWorktree } from "./conve
|
|
|
17
19
|
import { yourComputer } from "./this-computer.js";
|
|
18
20
|
import { canonicalDevicePath, deviceHome, displayPath, expandDevicePath, FOLDER_KINDS, isDevicePath, linkLocalProject, listDeviceDir } from "./device-folders.js";
|
|
19
21
|
import { ownConversation } from "./agent-config-return.js";
|
|
20
|
-
import { deviceApprovalMode, recordDeviceActivity, runsWithoutAsking } from "./device-mode.js";
|
|
22
|
+
import { deviceApprovalMode, recordDeviceActivity, recordDeviceResult, runsWithoutAsking } from "./device-mode.js";
|
|
21
23
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
22
24
|
/**
|
|
23
25
|
* Posts a request's answer. An API before a linked folder's start point (train 47) refuses a link's commit and branch
|
|
@@ -247,7 +249,7 @@ export class DeviceExecutor {
|
|
|
247
249
|
const auto = this.#auto.get(r.id);
|
|
248
250
|
if (auto && result.state !== 'approved' && result.state !== 'running') {
|
|
249
251
|
this.#auto.delete(r.id);
|
|
250
|
-
|
|
252
|
+
recordDeviceResult(this.#ctx.store.dir, auto, { ...result, state: result.state });
|
|
251
253
|
}
|
|
252
254
|
await conversationClient(this.#ctx).deviceRequests.result(safeId(r.id), result, { idempotencyKey: `${this.#deviceId}:${r.id}:${result.state}${again}`, signal: this.#ctx.signal });
|
|
253
255
|
}
|
|
@@ -310,11 +312,52 @@ export class DeviceExecutor {
|
|
|
310
312
|
await watch;
|
|
311
313
|
}
|
|
312
314
|
}
|
|
315
|
+
async #credentials(r) {
|
|
316
|
+
const ctx = this.#ctx, c = await getConversation(ctx, r.conversation_id), records = localConversations(ctx);
|
|
317
|
+
if (c.project_id !== r.project_id || !ownConversation(c, records, await this.#me()) || archived(c)) {
|
|
318
|
+
await this.result(r, { state: 'denied' });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
await this.result(r, { state: 'approved' });
|
|
322
|
+
try {
|
|
323
|
+
const matching = Object.values(records).filter(record => record.project_id === r.project_id), folders = new Set(matching.map(record => record.folder));
|
|
324
|
+
const folder = r.credentials?.path ? expandDevicePath(r.credentials.path, deviceHome(ctx)) : records[r.conversation_id]?.folder ?? (folders.size === 1 ? matching[0]?.folder : undefined);
|
|
325
|
+
const base = { home: deviceHome(ctx), env: ctx.io.env, projectId: r.project_id, ...(folder ? { projectPath: folder } : {}), signal: ctx.signal };
|
|
326
|
+
const env = await captureLocalLoginEnvironment(base), options = { ...base, env };
|
|
327
|
+
const requested = r.credentials?.names ? new Set(r.credentials.names) : null;
|
|
328
|
+
let sources = (await discoverCredentialSources(options)).filter(source => !requested || requested.has(source.name));
|
|
329
|
+
const cloud = ctx.sessionSource() ? ctx.account() : conversationClient(ctx);
|
|
330
|
+
if (!ctx.sessionSource())
|
|
331
|
+
sources = [...sources].sort((a, b) => Number(b.suggested_scope === 'project') - Number(a.suggested_scope === 'project')).map(source => ({ ...source, suggested_scope: 'project', project_id: r.project_id }));
|
|
332
|
+
const selected = new Set(), ids = [];
|
|
333
|
+
for (const source of sources)
|
|
334
|
+
if (source.preferred) {
|
|
335
|
+
const target = JSON.stringify([source.suggested_scope, source.project_id, source.name]);
|
|
336
|
+
if (!selected.has(target)) {
|
|
337
|
+
selected.add(target);
|
|
338
|
+
ids.push(source.id);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const plan = await cloud.credentialSetups.create({ projectId: r.project_id, deviceId: this.#deviceId, deviceRequestId: r.id, selectedItems: ids, items: sources.map(({ id, name, source, suggested_scope, project_id }) => ({ id, name, source, suggested_scope, ...(project_id ? { project_id } : {}) })) });
|
|
342
|
+
await this.result(r, { state: 'running', credential_setup: { plan_id: plan.id } });
|
|
343
|
+
let final = await transferCredentialSetup({ ...ctx, json: false }, cloud, plan, options);
|
|
344
|
+
while (final.state === 'approved' && !ctx.signal.aborted)
|
|
345
|
+
final = await cloud.credentialSetups.get(plan.id, { waitSeconds: 30, signal: ctx.signal });
|
|
346
|
+
const failed = final.state !== 'completed' || final.items.some(i => i.state === 'failed');
|
|
347
|
+
await this.result(r, { state: failed ? 'failed' : 'done', ...(final.state === 'completed' ? { credential_setup: { plan_id: plan.id } } : {}), ...(failed ? { exit_code: 1 } : {}) });
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
// No raw source/HTTP/CLI error is allowed into a device result or its conversation.
|
|
351
|
+
await this.result(r, { state: 'failed', exit_code: 1 });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
313
354
|
async execute(r) {
|
|
314
355
|
const ctx = this.#ctx;
|
|
315
356
|
safeId(r.id);
|
|
316
357
|
if (r.state !== 'pending' || r.device_id !== this.#deviceId)
|
|
317
358
|
return;
|
|
359
|
+
if (r.kind === 'credentials_setup')
|
|
360
|
+
return this.#credentials(r);
|
|
318
361
|
if (folderRequest(r))
|
|
319
362
|
return this.#folder(r);
|
|
320
363
|
const records = localConversations(ctx);
|
package/dist/device-helper.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
2
|
import { DeviceExecutor, askConnectedAcp, askDeviceApprover } from "./device-bridge.js";
|
|
3
|
+
import { credentialHeartbeat } from "./credential-device-capability.js";
|
|
3
4
|
import { ShardfluxApiError } from '@shardflux/sdk';
|
|
4
5
|
import { allOrgs, allProjects } from "./account-scope.js";
|
|
5
6
|
import { ConversationMirrorState, mirrorProjectConversations } from "./conversation-stash.js";
|
|
@@ -17,13 +18,16 @@ import { AgentConfigReturnState, runAgentConfigLoop } from "./agent-config-retur
|
|
|
17
18
|
import { askLocalDeviceApproval } from "./device-approvals.js";
|
|
18
19
|
import { resumeCloudStartup, startCarried } from "./codex-cloud.js";
|
|
19
20
|
import { runProjectPreparation } from "./project-preparation.js";
|
|
21
|
+
import { runTaskAutoLand } from "./task-auto-land.js";
|
|
20
22
|
/** The menu bar owns this helper and its stdin; VM data can request actions but cannot answer approvals. */
|
|
21
23
|
export async function runDeviceHelper(ctx, options = {}) {
|
|
22
24
|
const abort = new AbortController(), signal = AbortSignal.any([ctx.signal, abort.signal]);
|
|
23
25
|
ctx = { ...ctx, signal };
|
|
24
26
|
const pending = new Map();
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
// A service never reads approvals from stdin. In particular the Linux fallback may start after its command has
|
|
28
|
+
// already finished: creating an unconsumed stream with that aborted signal emits an uncaught AbortError.
|
|
29
|
+
const inputSource = options.service ? undefined : Readable.from(ctx.io.stdin, { objectMode: false, signal });
|
|
30
|
+
const input = inputSource ? (async () => {
|
|
27
31
|
let tail = '';
|
|
28
32
|
for await (const bytes of inputSource) {
|
|
29
33
|
tail += Buffer.from(bytes).toString();
|
|
@@ -40,7 +44,7 @@ export async function runDeviceHelper(ctx, options = {}) {
|
|
|
40
44
|
}
|
|
41
45
|
}
|
|
42
46
|
}
|
|
43
|
-
})().finally(() => abort.abort());
|
|
47
|
+
})().finally(() => abort.abort()) : Promise.resolve();
|
|
44
48
|
void input.catch(() => abort.abort());
|
|
45
49
|
// `until` ends one question early: the person chose a mode that lets the request run by itself (device-mode.ts).
|
|
46
50
|
const ask = async (approval, until) => {
|
|
@@ -81,11 +85,14 @@ export async function runDeviceHelper(ctx, options = {}) {
|
|
|
81
85
|
// sign-in change later renews again and a refusal right after a renewal stops (never a loop).
|
|
82
86
|
let renewedAt = 0;
|
|
83
87
|
const report = (e) => { ctx.io.stderr.write(`Device bridge: ${e instanceof Error ? e.message : String(e)}\n`); };
|
|
84
|
-
let mirroring, twin, returning, preparing, projects;
|
|
88
|
+
let mirroring, twin, returning, preparing, projects, taskLanding;
|
|
85
89
|
try {
|
|
86
90
|
return await withTargetLock(join(ctx.store.dir, 'device-helper-lock'), async () => {
|
|
87
91
|
// Finished conversations land in their local branches (worktree-twin-engine.ts); its own loop and backoff.
|
|
88
92
|
twin = runWorktreeTwin(ctx, deviceId, signal).catch(report);
|
|
93
|
+
// Board tasks (including those delegated from the phone) have no locally started conversation. Consume their
|
|
94
|
+
// finished results independently, through the exact same merge/check/receipt path as `shard task land`.
|
|
95
|
+
taskLanding = runTaskAutoLand(ctx).catch(report);
|
|
89
96
|
// Skills, memories and CLAUDE.md lines saved in the cloud come back on their own cadence (agent-config-return.ts):
|
|
90
97
|
// never behind a files-mirror pass, which takes minutes for an account with hundreds of conversations. Every 2 s
|
|
91
98
|
// while one of this user's conversations is active, backing off to 60 s when none is.
|
|
@@ -161,13 +168,13 @@ export async function runDeviceHelper(ctx, options = {}) {
|
|
|
161
168
|
// This device's local conversations of a project (started here or opened here): their requests come here first.
|
|
162
169
|
const linked = (projectId) => Object.values(localConversations(ctx)).filter(r => !r.handed_off && (!projectId || r.project_id === projectId))
|
|
163
170
|
.sort((a, b) => (b.conversation.created_at ?? '').localeCompare(a.conversation.created_at ?? '')).slice(0, 500).map(r => r.conversation.id);
|
|
164
|
-
const beat = (projectId) =>
|
|
171
|
+
const beat = (projectId) => credentialHeartbeat(ctx, { ...(projectId ? { projectId } : {}), deviceId, leaseSeconds: 30, ...(links ? { conversationIds: linked(projectId) } : {}) })
|
|
165
172
|
.catch((e) => {
|
|
166
173
|
// An API before device links (0.25.0+ clients) refuses the list: register without it from then on.
|
|
167
174
|
if (!links || !(e instanceof ShardfluxApiError && [400, 422].includes(e.status)))
|
|
168
175
|
throw e;
|
|
169
176
|
links = false;
|
|
170
|
-
return
|
|
177
|
+
return credentialHeartbeat(ctx, { ...(projectId ? { projectId } : {}), deviceId, leaseSeconds: 30 });
|
|
171
178
|
});
|
|
172
179
|
const take = (rows) => {
|
|
173
180
|
for (const r of rows) {
|
|
@@ -261,7 +268,7 @@ export async function runDeviceHelper(ctx, options = {}) {
|
|
|
261
268
|
}
|
|
262
269
|
finally {
|
|
263
270
|
abort.abort();
|
|
264
|
-
inputSource
|
|
271
|
+
inputSource?.destroy();
|
|
265
272
|
for (const resolve of pending.values())
|
|
266
273
|
resolve('deny');
|
|
267
274
|
pending.clear();
|
|
@@ -273,6 +280,7 @@ export async function runDeviceHelper(ctx, options = {}) {
|
|
|
273
280
|
await twin;
|
|
274
281
|
await preparing;
|
|
275
282
|
await projects;
|
|
283
|
+
await taskLanding;
|
|
276
284
|
await input.catch(() => undefined);
|
|
277
285
|
}
|
|
278
286
|
}
|
package/dist/device-mode.d.ts
CHANGED
|
@@ -38,7 +38,14 @@ export interface DeviceActivity {
|
|
|
38
38
|
local_path?: string;
|
|
39
39
|
exit_code?: number;
|
|
40
40
|
finished_at?: string;
|
|
41
|
+
error?: string;
|
|
41
42
|
}
|
|
42
43
|
export declare function recordDeviceActivity(dir: string, entry: DeviceActivity): void;
|
|
44
|
+
/** Persist the result before posting it: a stopped helper or failed API call must not erase the actual device error. */
|
|
45
|
+
export declare function recordDeviceResult(dir: string, entry: DeviceActivity, result: {
|
|
46
|
+
state: DeviceActivity['state'];
|
|
47
|
+
exit_code?: number;
|
|
48
|
+
stderr_b64?: string;
|
|
49
|
+
}): void;
|
|
43
50
|
/** What ran without asking, newest first: one entry per request with its latest state. */
|
|
44
51
|
export declare function deviceActivity(dir: string, limit?: number): DeviceActivity[];
|
package/dist/device-mode.js
CHANGED
|
@@ -87,6 +87,19 @@ export function recordDeviceActivity(dir, entry) {
|
|
|
87
87
|
}
|
|
88
88
|
catch { /* the request's own result still reaches its conversation */ }
|
|
89
89
|
}
|
|
90
|
+
/** Persist the result before posting it: a stopped helper or failed API call must not erase the actual device error. */
|
|
91
|
+
export function recordDeviceResult(dir, entry, result) {
|
|
92
|
+
const bytes = result.stderr_b64 ? Buffer.from(result.stderr_b64, 'base64') : null;
|
|
93
|
+
let error;
|
|
94
|
+
if (bytes && bytes.toString('base64').replace(/=+$/, '') === result.stderr_b64.replace(/=+$/, '')) {
|
|
95
|
+
try {
|
|
96
|
+
error = new TextDecoder('utf-8', { fatal: true }).decode(bytes) || undefined;
|
|
97
|
+
}
|
|
98
|
+
catch { /* no readable error text */ }
|
|
99
|
+
}
|
|
100
|
+
recordDeviceActivity(dir, { ...entry, at: new Date().toISOString(), state: result.state,
|
|
101
|
+
...(result.exit_code !== undefined ? { exit_code: result.exit_code } : {}), ...(error !== undefined ? { error } : {}) });
|
|
102
|
+
}
|
|
90
103
|
/** What ran without asking, newest first: one entry per request with its latest state. */
|
|
91
104
|
export function deviceActivity(dir, limit = 20) {
|
|
92
105
|
let text;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** The actual request and its outcome, never shortened to a count or a generic failure. */
|
|
2
|
+
export function deviceOutcomeLines(requests) {
|
|
3
|
+
if (!requests.length)
|
|
4
|
+
return [];
|
|
5
|
+
const ordered = [...requests].sort((a, b) => Number(b.failed) - Number(a.failed) || Date.parse(b.created_at) - Date.parse(a.created_at) || b.id.localeCompare(a.id));
|
|
6
|
+
return ['', 'Device requests', ...ordered.flatMap(r => {
|
|
7
|
+
const state = r.failed ? 'failed' : r.state;
|
|
8
|
+
const lines = [`${r.id}\t${state}${typeof r.exit_code === 'number' ? ` (exit ${r.exit_code})` : ''}\t${r.what}`];
|
|
9
|
+
if (r.path)
|
|
10
|
+
lines.push(` Local: ${r.path}`);
|
|
11
|
+
if (r.remote_path)
|
|
12
|
+
lines.push(` Remote: ${r.remote_path}`);
|
|
13
|
+
if (r.error)
|
|
14
|
+
lines.push(` ${r.error}`);
|
|
15
|
+
else if (r.failed)
|
|
16
|
+
lines.push(' Error text unavailable');
|
|
17
|
+
return lines;
|
|
18
|
+
})];
|
|
19
|
+
}
|