@shardflux/cli 0.28.2 → 0.28.4

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.
@@ -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
+ }
@@ -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;
@@ -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
- recordDeviceActivity(this.#ctx.store.dir, { ...auto, at: new Date().toISOString(), state: result.state, ...(result.exit_code !== undefined ? { exit_code: result.exit_code } : {}) });
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);
@@ -46,6 +46,11 @@ type Starter = (ctx: Ctx, folder: string, harness: 'claude' | 'codex', brief: st
46
46
  * prompt. The brief goes in on standard input, never in a command line another user can read.
47
47
  */
48
48
  export declare const startFromFolder: Starter;
49
+ /** How long a start from a folder may take, and how long a child asked to stop may take before it is killed. */
50
+ export declare const startFolderLimits: {
51
+ timeoutMs: number;
52
+ killAfterMs: number;
53
+ };
49
54
  /**
50
55
  * The conversation `shard agents new --json` started, from its whole output: one pretty-printed JSON document
51
56
  * (format.ts json), never its last line (staging 2026-10-16: the last line is `}`).
@@ -149,31 +149,55 @@ export const startFromFolder = (ctx, folder, harness, brief, fromConversationId)
149
149
  env: { ...process.env, ...ctx.io.env, SHARDFLUX_CONFIG_DIR: ctx.store.dir, SHARDFLUX_API_URL: ctx.apiUrl, CI: '1', ...(fromConversationId ? { SF_CONVERSATION_ID: fromConversationId } : {}) },
150
150
  });
151
151
  const out = [], err = [];
152
- let outBytes = 0;
153
- const stop = () => child.kill('SIGTERM');
154
- const timer = setTimeout(stop, 300_000);
155
- ctx.signal.addEventListener('abort', stop, { once: true });
152
+ let outBytes = 0, settled = false, kill;
153
+ const finish = (error, started) => {
154
+ if (settled)
155
+ return;
156
+ settled = true;
157
+ clearTimeout(timer);
158
+ ctx.signal.removeEventListener('abort', abort);
159
+ if (error)
160
+ reject(error);
161
+ else
162
+ resolveStart(started);
163
+ };
164
+ // A child that does not end when asked (its own start waiting on a project setup that never finishes) is killed,
165
+ // and the start fails now with what held it: a device request never stays `running` behind it (production
166
+ // 2026-10-18: a project_link sat running for over an hour, so its orchestrator input never settled).
167
+ const stop = () => {
168
+ child.kill('SIGTERM');
169
+ kill ??= setTimeout(() => child.kill('SIGKILL'), startFolderLimits.killAfterMs);
170
+ kill.unref?.();
171
+ };
172
+ const abort = () => { stop(); finish(new Error('The conversation start was cancelled.')); };
173
+ const timer = setTimeout(() => {
174
+ stop();
175
+ const minutes = Math.max(1, Math.round(startFolderLimits.timeoutMs / 60_000)), held = lastLine(Buffer.concat(err).toString('utf8'));
176
+ finish(new Error(`The conversation did not start within ${minutes} minute${minutes === 1 ? '' : 's'}${held ? `: ${held}` : '.'}`));
177
+ }, startFolderLimits.timeoutMs);
178
+ ctx.signal.addEventListener('abort', abort, { once: true });
156
179
  child.stdout.on('data', (b) => { if ((outBytes += b.length) <= 8 << 20)
157
180
  out.push(b); });
158
181
  child.stderr.on('data', (b) => { if (err.length < 64)
159
182
  err.push(b); });
160
- child.once('error', error => { clearTimeout(timer); reject(error); });
183
+ child.once('error', error => { clearTimeout(kill); finish(error); });
161
184
  child.once('close', code => {
162
- clearTimeout(timer);
163
- ctx.signal.removeEventListener('abort', stop);
185
+ clearTimeout(kill);
164
186
  const text = Buffer.concat(out).toString('utf8');
165
187
  try {
166
188
  if (code !== 0)
167
189
  throw new Error(lastLine(Buffer.concat(err).toString('utf8')) || `The conversation did not start (exit ${code}).`);
168
- resolveStart(startedConversation(text));
190
+ finish(null, startedConversation(text));
169
191
  }
170
192
  catch (error) {
171
- reject(error instanceof Error ? error : new Error(String(error)));
193
+ finish(error instanceof Error ? error : new Error(String(error)));
172
194
  }
173
195
  });
174
196
  child.stdin.end(brief);
175
197
  });
176
198
  };
199
+ /** How long a start from a folder may take, and how long a child asked to stop may take before it is killed. */
200
+ export const startFolderLimits = { timeoutMs: 300_000, killAfterMs: 10_000 };
177
201
  /**
178
202
  * The conversation `shard agents new --json` started, from its whole output: one pretty-printed JSON document
179
203
  * (format.ts json), never its last line (staging 2026-10-16: the last line is `}`).