@shardflux/cli 0.28.1 → 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 +33 -0
- package/README.md +49 -4
- package/dist/args.d.ts +1 -1
- package/dist/args.js +16 -1
- package/dist/board-args.js +9 -1
- package/dist/board-commands.d.ts +1 -1
- package/dist/board-commands.js +50 -7
- package/dist/commands.d.ts +14 -0
- package/dist/commands.js +123 -6
- package/dist/conversations.d.ts +2 -0
- package/dist/conversations.js +1 -1
- 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 +46 -3
- package/dist/device-folders.d.ts +2 -1
- package/dist/device-folders.js +3 -3
- 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,21 @@
|
|
|
1
|
+
/** Old APIs must keep accepting ordinary helper heartbeats during a rolling upgrade. */
|
|
2
|
+
import { ShardfluxApiError } from '@shardflux/sdk';
|
|
3
|
+
import { conversationClient } from "./conversation-commands.js";
|
|
4
|
+
const probes = new WeakMap();
|
|
5
|
+
export async function credentialHeartbeat(ctx, input) {
|
|
6
|
+
let probe = probes.get(ctx);
|
|
7
|
+
if (!probe || Date.now() - probe.at >= 30_000) {
|
|
8
|
+
probe = { at: Date.now(), pending: conversationClient(ctx).credentialSetups.available().catch(() => false) };
|
|
9
|
+
probes.set(ctx, probe);
|
|
10
|
+
}
|
|
11
|
+
const available = await probe.pending, cloud = conversationClient(ctx);
|
|
12
|
+
try {
|
|
13
|
+
return await cloud.deviceRequests.heartbeat({ ...input, ...(available ? { capabilities: ['credentials_setup.v1'] } : {}) });
|
|
14
|
+
}
|
|
15
|
+
catch (e) {
|
|
16
|
+
if (!available || !(e instanceof ShardfluxApiError && [400, 422].includes(e.status)))
|
|
17
|
+
throw e;
|
|
18
|
+
probes.set(ctx, { at: Date.now(), pending: Promise.resolve(false) });
|
|
19
|
+
return cloud.deviceRequests.heartbeat(input);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CredentialSetup, CredentialSetupsApi, SecretsApi, NeedsYouApi } from '@shardflux/sdk';
|
|
2
|
+
import type { Ctx, Handler } from './commands.js';
|
|
3
|
+
import type { CredentialDiscoveryOptions } from './credential-sources.js';
|
|
4
|
+
interface SetupClient {
|
|
5
|
+
credentialSetups: CredentialSetupsApi;
|
|
6
|
+
secrets: SecretsApi;
|
|
7
|
+
needsYou: NeedsYouApi;
|
|
8
|
+
}
|
|
9
|
+
/** Onboarding opts in explicitly. The restricted machine agent never reads the source values. */
|
|
10
|
+
export declare function credentialSetupAfterMachine(ctx: Ctx): Promise<void>;
|
|
11
|
+
export declare function transferCredentialSetup(ctx: Ctx, cloud: SetupClient, plan: CredentialSetup, options: CredentialDiscoveryOptions): Promise<CredentialSetup>;
|
|
12
|
+
export declare const CREDENTIAL_SETUP_HANDLERS: Record<string, Handler>;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/** Trusted local credential transfer. Models and stdout receive names, sources and status only. */
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { UsageError } from "./errors.js";
|
|
5
|
+
import { captureLocalLoginEnvironment, discoverCredentialSources, readCredentialSource, CredentialSourceError } from "./credential-sources.js";
|
|
6
|
+
const str = (value) => typeof value === 'string' ? value : undefined;
|
|
7
|
+
function client(ctx) { return ctx.sessionSource() ? ctx.account() : ctx.cloud(); }
|
|
8
|
+
/** Onboarding opts in explicitly. The restricted machine agent never reads the source values. */
|
|
9
|
+
export async function credentialSetupAfterMachine(ctx) {
|
|
10
|
+
try {
|
|
11
|
+
const cloud = client(ctx);
|
|
12
|
+
if (!cloud.credentialSetups || !await cloud.credentialSetups.available())
|
|
13
|
+
return;
|
|
14
|
+
const quiet = { ...ctx, json: true, io: { ...ctx.io, stdout: { ...ctx.io.stdout, write: () => true } } };
|
|
15
|
+
await CREDENTIAL_SETUP_HANDLERS['credentials setup'](quiet, { 'from-this-computer': true, 'if-needed': true }, []);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
ctx.io.stderr.write('Credential setup is pending. Continue with shard credentials setup --from-this-computer --if-needed.\n');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const itemResult = (item) => ({ id: item.id, name: item.name, state: item.state, scope: item.selected_scope, project_id: item.project_id, ...(item.delivery ? { delivery: item.delivery } : {}), ...(item.error_code ? { error_code: item.error_code } : {}) });
|
|
22
|
+
function output(ctx, value) { if (ctx.json)
|
|
23
|
+
ctx.io.stdout.write(JSON.stringify(value) + '\n'); }
|
|
24
|
+
export async function transferCredentialSetup(ctx, cloud, plan, options) {
|
|
25
|
+
if (plan.state === 'awaiting_approval')
|
|
26
|
+
return plan;
|
|
27
|
+
if (plan.state === 'denied' || plan.state === 'completed')
|
|
28
|
+
return plan;
|
|
29
|
+
const jobs = plan.items.filter(i => i.state === 'pending' && i.selected_scope !== null).map(async (item) => {
|
|
30
|
+
try {
|
|
31
|
+
const { project_id: sourceProject, ...descriptor } = item;
|
|
32
|
+
const candidate = { ...descriptor, ...(sourceProject ? { project_id: sourceProject } : {}), preferred: true };
|
|
33
|
+
const value = await readCredentialSource(candidate, options);
|
|
34
|
+
ctx.addWriteOnlyValue(value);
|
|
35
|
+
const stored = await cloud.credentialSetups.storeValue(plan.id, item.id, value);
|
|
36
|
+
const result = stored.items.find(i => i.id === item.id);
|
|
37
|
+
if (result)
|
|
38
|
+
output(ctx, { plan_id: plan.id, result: itemResult(result) });
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
const code = err instanceof CredentialSourceError ? err.code : 'import_failed';
|
|
42
|
+
try {
|
|
43
|
+
const failed = await cloud.credentialSetups.sourceResult(plan.id, item.id, code);
|
|
44
|
+
const result = failed.items.find(i => i.id === item.id);
|
|
45
|
+
if (result)
|
|
46
|
+
output(ctx, { plan_id: plan.id, result: itemResult(result) });
|
|
47
|
+
}
|
|
48
|
+
catch { /* Read authoritative status below; a lost write response may already have stored it. */ }
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
await Promise.allSettled(jobs);
|
|
52
|
+
return cloud.credentialSetups.get(plan.id, { signal: ctx.signal });
|
|
53
|
+
}
|
|
54
|
+
async function picks(ctx, path) {
|
|
55
|
+
let value;
|
|
56
|
+
if (path === '-') {
|
|
57
|
+
const chunks = [];
|
|
58
|
+
for await (const chunk of ctx.io.stdin)
|
|
59
|
+
chunks.push(Buffer.from(chunk));
|
|
60
|
+
value = Buffer.concat(chunks).toString('utf8');
|
|
61
|
+
}
|
|
62
|
+
else
|
|
63
|
+
value = await readFile(path, 'utf8');
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(value);
|
|
66
|
+
if (!Array.isArray(parsed) || !parsed.every((item) => {
|
|
67
|
+
if (!item || typeof item !== 'object' || Array.isArray(item))
|
|
68
|
+
return false;
|
|
69
|
+
const i = item;
|
|
70
|
+
return typeof i.id === 'string' && (i.scope === 'agents' || i.scope === 'project') && (i.project_id === undefined || typeof i.project_id === 'string') && Object.keys(i).every(k => ['id', 'scope', 'project_id'].includes(k));
|
|
71
|
+
}))
|
|
72
|
+
throw new Error();
|
|
73
|
+
return parsed;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
throw new UsageError('--picks takes a JSON list of discovered source IDs and scopes.');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export const CREDENTIAL_SETUP_HANDLERS = {
|
|
80
|
+
async 'credentials setup'(ctx, values) {
|
|
81
|
+
const cloud = client(ctx), apply = str(values.apply), deny = str(values.deny), path = str(values.path);
|
|
82
|
+
if (!await cloud.credentialSetups.available())
|
|
83
|
+
throw new UsageError('Credential setup is being prepared. Update Shardflux and try again.');
|
|
84
|
+
if (apply && deny)
|
|
85
|
+
throw new UsageError('Choose --apply or --deny.');
|
|
86
|
+
if (deny) {
|
|
87
|
+
await cloud.needsYou.answer('csetup_' + deny, { decision: 'deny' });
|
|
88
|
+
output(ctx, { plan_id: deny, state: 'denied' });
|
|
89
|
+
if (!ctx.json)
|
|
90
|
+
ctx.io.stdout.write('Credential setup declined.\n');
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
if (!apply && values['from-this-computer'] !== true)
|
|
94
|
+
throw new UsageError('Use --from-this-computer to bring your existing credentials along.');
|
|
95
|
+
let projectId = str(values.project) ?? ctx.store.profile(ctx.apiUrl).project_id, organizationId = ctx.store.profile(ctx.apiUrl).organization_id;
|
|
96
|
+
if (!projectId && !apply) {
|
|
97
|
+
try {
|
|
98
|
+
const me = await ctx.cloud().me();
|
|
99
|
+
if (!me.api_key)
|
|
100
|
+
throw new UsageError('Select a project with --project.');
|
|
101
|
+
projectId = me.api_key.project_id;
|
|
102
|
+
organizationId ??= me.api_key.organization_id;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
if (!ctx.sessionSource())
|
|
106
|
+
throw new UsageError('Select a project with --project, or sign in first.');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const home = ctx.io.env.HOME ?? homedir();
|
|
110
|
+
const base = { home, env: ctx.io.env, ...(projectId ? { projectId, projectPath: path ?? process.cwd() } : {}), signal: ctx.signal };
|
|
111
|
+
const env = await captureLocalLoginEnvironment(base), options = { ...base, env };
|
|
112
|
+
let plan, prior = new Map();
|
|
113
|
+
if (apply) {
|
|
114
|
+
const selection = str(values.picks);
|
|
115
|
+
if (selection)
|
|
116
|
+
await cloud.needsYou.answer('csetup_' + apply, { credential_setup: { items: await picks(ctx, selection) } });
|
|
117
|
+
plan = await cloud.credentialSetups.get(apply, { signal: ctx.signal });
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
const candidates = await discoverCredentialSources(options), localOnly = !ctx.sessionSource();
|
|
121
|
+
const normalized = (localOnly ? [...candidates].sort((a, b) => Number(b.suggested_scope === 'project') - Number(a.suggested_scope === 'project')) : candidates).map(c => ({ ...c, ...(localOnly ? { suggested_scope: 'project', project_id: projectId } : {}) }));
|
|
122
|
+
if (localOnly && !projectId)
|
|
123
|
+
throw new UsageError('Select a project with --project.');
|
|
124
|
+
const selected = new Set(), selectedItems = [];
|
|
125
|
+
for (const c of normalized)
|
|
126
|
+
if (c.preferred) {
|
|
127
|
+
const key = JSON.stringify([c.suggested_scope, c.project_id, c.name]);
|
|
128
|
+
if (!selected.has(key)) {
|
|
129
|
+
selected.add(key);
|
|
130
|
+
selectedItems.push(c.id);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const items = normalized.map(({ id, name, source, suggested_scope, project_id }) => ({ id, name, source, suggested_scope, ...(project_id ? { project_id } : {}) }));
|
|
134
|
+
if (!items.length && values['if-needed'] === true) {
|
|
135
|
+
output(ctx, { needed: false, results: [] });
|
|
136
|
+
if (!ctx.json)
|
|
137
|
+
ctx.io.stdout.write('No new credential sources found.\n');
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
const metadata = await cloud.secrets.listAgents({ ...organizationId ? { organizationId } : {} });
|
|
142
|
+
prior = new Map(metadata.map(s => [s.id, s.current_version]));
|
|
143
|
+
}
|
|
144
|
+
catch { /* An unavailable metadata read does not block the direct transfer. */ }
|
|
145
|
+
plan = await cloud.credentialSetups.create({ items, ...(projectId ? { projectId } : {}), ...(organizationId ? { organizationId } : {}), review: values.plan === true, ...(values.plan === true ? {} : { selectedItems }) });
|
|
146
|
+
if (!prior.size)
|
|
147
|
+
try {
|
|
148
|
+
prior = new Map((await cloud.secrets.listAgents({ organizationId: plan.organization_id })).map(s => [s.id, s.current_version]));
|
|
149
|
+
}
|
|
150
|
+
catch { /* Transfers remain independent of this optional metadata read. */ }
|
|
151
|
+
output(ctx, { plan_id: plan.id, ...(values.plan === true ? { candidates: plan.items } : {}) });
|
|
152
|
+
if (values.plan === true) {
|
|
153
|
+
if (!ctx.json)
|
|
154
|
+
ctx.io.stdout.write('Review the discovered names in Shard, then import the selected sources.\n');
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (plan.state === 'awaiting_approval') {
|
|
159
|
+
output(ctx, { plan_id: plan.id, state: plan.state, results: plan.items.map(itemResult) });
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
const final = await transferCredentialSetup(ctx, cloud, plan, options), failed = final.items.filter(i => i.state === 'failed').length, pending = final.items.filter(i => i.state === 'pending' || i.state === 'stored' && i.delivery?.state !== 'ready').length;
|
|
163
|
+
const stored = final.items.filter(i => i.state === 'stored'), needed = stored.some(i => !i.secret_id || prior.get(i.secret_id) !== i.secret_version);
|
|
164
|
+
const result = { plan_id: final.id, state: final.state, needed, results: final.items.map(itemResult), stored: stored.length, failed, pending };
|
|
165
|
+
output(ctx, result);
|
|
166
|
+
if (!ctx.json)
|
|
167
|
+
ctx.io.stdout.write(`${stored.length} credential${stored.length === 1 ? '' : 's'} stored${failed ? `, ${failed} source${failed === 1 ? '' : 's'} need attention` : ''}${pending ? `, ${pending} delivery pending` : ''}.\n`);
|
|
168
|
+
return failed ? 1 : 0;
|
|
169
|
+
},
|
|
170
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type CredentialSourceCode = 'source_unavailable' | 'source_changed' | 'source_not_text' | 'source_empty' | 'source_too_large' | 'source_has_nul' | 'vault_locked' | 'login_expired' | 'import_failed';
|
|
2
|
+
export declare class CredentialSourceError extends Error {
|
|
3
|
+
readonly code: CredentialSourceCode;
|
|
4
|
+
constructor(code: CredentialSourceCode);
|
|
5
|
+
}
|
|
6
|
+
export interface CredentialCandidate {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
source: {
|
|
10
|
+
kind: 'environment' | 'dotenv' | 'shell' | 'vault' | 'cli';
|
|
11
|
+
label: string;
|
|
12
|
+
reference: string;
|
|
13
|
+
};
|
|
14
|
+
suggested_scope: 'agents' | 'project';
|
|
15
|
+
project_id?: string;
|
|
16
|
+
preferred: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface CredentialDiscoveryOptions {
|
|
19
|
+
home: string;
|
|
20
|
+
env: Readonly<NodeJS.ProcessEnv>;
|
|
21
|
+
projectPath?: string;
|
|
22
|
+
projectId?: string;
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
/** Fixture-injected process boundary. stdout is private; stderr is never returned. */
|
|
25
|
+
run?: (argv: readonly string[], env: Readonly<NodeJS.ProcessEnv>, signal?: AbortSignal) => Promise<Buffer>;
|
|
26
|
+
/** Tests declare the fixture executables. Production resolves only the caller's PATH. */
|
|
27
|
+
availableCommands?: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
export declare function credentialVariableName(name: string): boolean;
|
|
30
|
+
/** Normal local login startup resolves exported vault expressions privately, without a model reading shell files. */
|
|
31
|
+
export declare function captureLocalLoginEnvironment(options: CredentialDiscoveryOptions): Promise<NodeJS.ProcessEnv>;
|
|
32
|
+
/** Reconstruct a source from metadata only, so a review can finish in a later CLI process. */
|
|
33
|
+
export declare function readCredentialSource(candidate: CredentialCandidate, options: CredentialDiscoveryOptions): Promise<string>;
|
|
34
|
+
export declare function discoverCredentialSources(options: CredentialDiscoveryOptions): Promise<CredentialCandidate[]>;
|
|
@@ -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
|
+
}
|