@phnx-labs/agents-cli 1.22.52 → 1.22.54
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 +336 -0
- package/README.md +42 -9
- package/dist/bootstrap.js +55 -154
- package/dist/cli/command-registry.d.ts +5 -0
- package/dist/cli/command-registry.js +8 -1
- package/dist/commands/accounts.js +220 -174
- package/dist/commands/apply.js +6 -3
- package/dist/commands/auth-mint.d.ts +8 -0
- package/dist/commands/auth-mint.js +96 -0
- package/dist/commands/auth.js +5 -1
- package/dist/commands/browser.js +1 -1
- package/dist/commands/cost.js +8 -2
- package/dist/commands/daemon.js +2 -2
- package/dist/commands/doctor.js +6 -1
- package/dist/commands/exec.js +26 -17
- package/dist/commands/fleet-capture.js +7 -0
- package/dist/commands/focus.d.ts +1 -0
- package/dist/commands/focus.js +4 -2
- package/dist/commands/go.d.ts +5 -4
- package/dist/commands/go.js +8 -7
- package/dist/commands/insights.js +9 -0
- package/dist/commands/monitors.js +85 -30
- package/dist/commands/output.js +8 -2
- package/dist/commands/repo.js +18 -0
- package/dist/commands/secrets.js +33 -14
- package/dist/commands/sessions-inject.js +8 -3
- package/dist/commands/sessions-picker.js +2 -1
- package/dist/commands/sessions.d.ts +20 -12
- package/dist/commands/sessions.js +94 -40
- package/dist/commands/setup-accounts.d.ts +8 -0
- package/dist/commands/setup-accounts.js +47 -0
- package/dist/commands/setup.d.ts +1 -1
- package/dist/commands/setup.js +11 -2
- package/dist/commands/share.d.ts +52 -3
- package/dist/commands/share.js +262 -18
- package/dist/commands/ssh.d.ts +7 -0
- package/dist/commands/ssh.js +53 -14
- package/dist/commands/status.js +14 -0
- package/dist/commands/sync.js +44 -0
- package/dist/commands/view.d.ts +3 -1
- package/dist/commands/view.js +5 -4
- package/dist/lib/account-registry.d.ts +15 -5
- package/dist/lib/account-registry.js +165 -53
- package/dist/lib/accounting/rotate.d.ts +20 -6
- package/dist/lib/accounting/rotate.js +38 -7
- package/dist/lib/accounting/usage.d.ts +37 -1
- package/dist/lib/accounting/usage.js +71 -6
- package/dist/lib/agent-spec/agents.d.ts +5 -2
- package/dist/lib/agent-spec/agents.js +25 -7
- package/dist/lib/analytics/mix-commands.js +12 -6
- package/dist/lib/answer-router.js +2 -1
- package/dist/lib/auth-mint.d.ts +150 -0
- package/dist/lib/auth-mint.js +434 -0
- package/dist/lib/browser/profiles.d.ts +18 -0
- package/dist/lib/browser/profiles.js +26 -1
- package/dist/lib/browser/registry.d.ts +44 -14
- package/dist/lib/browser/registry.js +141 -45
- package/dist/lib/browser/remote-control.d.ts +9 -7
- package/dist/lib/browser/remote-control.js +9 -7
- package/dist/lib/claude-account-token.d.ts +10 -0
- package/dist/lib/claude-account-token.js +14 -4
- package/dist/lib/config-drift.d.ts +37 -0
- package/dist/lib/config-drift.js +72 -0
- package/dist/lib/daemon/auth-sync-service.d.ts +19 -0
- package/dist/lib/daemon/auth-sync-service.js +34 -0
- package/dist/lib/daemon/daemon.js +30 -4
- package/dist/lib/daemon/runner.js +10 -2
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/device-config.d.ts +3 -3
- package/dist/lib/device-config.js +8 -7
- package/dist/lib/devices/config-migration.js +147 -1
- package/dist/lib/devices/connect.d.ts +26 -0
- package/dist/lib/devices/connect.js +48 -1
- package/dist/lib/devices/device-docs.d.ts +35 -0
- package/dist/lib/devices/device-docs.js +163 -0
- package/dist/lib/devices/discovery-policy.d.ts +14 -2
- package/dist/lib/devices/discovery-policy.js +31 -21
- package/dist/lib/devices/doctor-findings.d.ts +5 -1
- package/dist/lib/devices/doctor-findings.js +19 -1
- package/dist/lib/devices/registry.d.ts +11 -5
- package/dist/lib/devices/registry.js +46 -18
- package/dist/lib/exec.d.ts +88 -30
- package/dist/lib/exec.js +138 -34
- package/dist/lib/feed/feed.d.ts +10 -2
- package/dist/lib/feed/feed.js +35 -2
- package/dist/lib/feed-broadcast.js +1 -1
- package/dist/lib/fleet/apply.d.ts +11 -0
- package/dist/lib/fleet/apply.js +23 -3
- package/dist/lib/fleet/auth-sync.js +5 -3
- package/dist/lib/help.d.ts +9 -0
- package/dist/lib/help.js +29 -1
- package/dist/lib/hosts/dispatch.d.ts +4 -3
- package/dist/lib/hosts/dispatch.js +12 -8
- package/dist/lib/hosts/passthrough.d.ts +1 -10
- package/dist/lib/hosts/passthrough.js +1 -13
- package/dist/lib/hosts/providers/local.d.ts +9 -3
- package/dist/lib/hosts/providers/local.js +23 -12
- package/dist/lib/hosts/reconnect.d.ts +7 -4
- package/dist/lib/hosts/reconnect.js +29 -25
- package/dist/lib/hosts/registry.js +4 -1
- package/dist/lib/hosts/remote-os.js +3 -1
- package/dist/lib/installations/versions.js +9 -1
- package/dist/lib/linux-userns.d.ts +58 -0
- package/dist/lib/linux-userns.js +116 -0
- package/dist/lib/memory.d.ts +26 -0
- package/dist/lib/memory.js +80 -1
- package/dist/lib/monitors/config.d.ts +11 -0
- package/dist/lib/monitors/config.js +8 -0
- package/dist/lib/monitors/engine.js +8 -1
- package/dist/lib/monitors/state.d.ts +37 -1
- package/dist/lib/monitors/state.js +79 -4
- package/dist/lib/permissions-registry.d.ts +2 -0
- package/dist/lib/permissions-registry.js +116 -14
- package/dist/lib/permissions.d.ts +5 -3
- package/dist/lib/permissions.js +25 -27
- package/dist/lib/profiles.d.ts +8 -7
- package/dist/lib/profiles.js +12 -0
- package/dist/lib/project-key.d.ts +9 -0
- package/dist/lib/project-key.js +11 -0
- package/dist/lib/secrets/bundles.d.ts +35 -0
- package/dist/lib/secrets/bundles.js +78 -1
- package/dist/lib/secrets/push.d.ts +3 -8
- package/dist/lib/secrets/push.js +18 -14
- package/dist/lib/secrets/remote.d.ts +9 -18
- package/dist/lib/secrets/remote.js +11 -26
- package/dist/lib/secrets/reserved-sync.d.ts +65 -0
- package/dist/lib/secrets/reserved-sync.js +129 -0
- package/dist/lib/self-heal/checks/hook-manifest.d.ts +2 -0
- package/dist/lib/self-heal/checks/hook-manifest.js +56 -0
- package/dist/lib/self-heal/registry.js +4 -0
- package/dist/lib/self-heal/types.d.ts +1 -1
- package/dist/lib/session/active.d.ts +10 -1
- package/dist/lib/session/active.js +8 -5
- package/dist/lib/session/actor-sidecar.d.ts +7 -0
- package/dist/lib/session/actor-sidecar.js +2 -0
- package/dist/lib/session/db.d.ts +39 -4
- package/dist/lib/session/db.js +168 -31
- package/dist/lib/session/discover.d.ts +32 -4
- package/dist/lib/session/discover.js +126 -37
- package/dist/lib/session/insights.d.ts +14 -0
- package/dist/lib/session/insights.js +25 -2
- package/dist/lib/session/linear.js +1 -1
- package/dist/lib/session/live-metadata.js +1 -0
- package/dist/lib/session/pid-registry.d.ts +7 -0
- package/dist/lib/session/prompt.d.ts +15 -0
- package/dist/lib/session/prompt.js +21 -0
- package/dist/lib/session/shell-programs.d.ts +17 -0
- package/dist/lib/session/shell-programs.js +21 -0
- package/dist/lib/session/state.js +2 -1
- package/dist/lib/session/stream-render.js +2 -1
- package/dist/lib/session/tool-calls.js +2 -5
- package/dist/lib/session/trajectory-html.js +2 -1
- package/dist/lib/session/trajectory.js +3 -12
- package/dist/lib/session/types.d.ts +25 -0
- package/dist/lib/session/types.js +10 -0
- package/dist/lib/share/publish.d.ts +53 -5
- package/dist/lib/share/publish.js +99 -17
- package/dist/lib/share/worker-template.js +594 -64
- package/dist/lib/startup/root-command.js +2 -1
- package/dist/lib/state.d.ts +24 -0
- package/dist/lib/state.js +318 -54
- package/dist/lib/sync-status.d.ts +4 -0
- package/dist/lib/sync-status.js +3 -0
- package/dist/lib/terminal/resolve.d.ts +7 -0
- package/dist/lib/terminal/resolve.js +41 -2
- package/dist/lib/traces/classify.js +24 -19
- package/dist/lib/traces/insights.d.ts +67 -0
- package/dist/lib/traces/insights.js +178 -0
- package/dist/lib/traces/phenotype.d.ts +67 -0
- package/dist/lib/traces/phenotype.js +437 -0
- package/dist/lib/traces/segments.d.ts +133 -0
- package/dist/lib/traces/segments.js +301 -0
- package/dist/lib/traces/sync.d.ts +33 -0
- package/dist/lib/traces/sync.js +11 -2
- package/dist/lib/types.d.ts +47 -1
- package/dist/lib/usage-refresh.js +2 -1
- package/dist/lib/view-types.d.ts +2 -0
- package/dist/lib/watchdog/runner.js +18 -4
- package/package.json +2 -1
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import { resolveAgentName } from './agents.js';
|
|
2
|
+
import { AUTH_BUNDLE, claudeAccountTokenKey, isValidClaudeSetupToken, readClaudeAccountEmail, } from './claude-account-token.js';
|
|
3
|
+
import { addAccount, findAccount, readAccountRegistry, setAccountSecret } from './account-registry.js';
|
|
4
|
+
import { bundleBackend, bundleExists, keychainRef, readBundle, rotateBundleSecret, writeBundleWithItems, } from './secrets/bundles.js';
|
|
5
|
+
import { secretsKeychainItem } from './secrets/index.js';
|
|
6
|
+
import { getBinaryPath, getGlobalDefault, getVersionHomePath, listInstalledVersions } from './installations/versions.js';
|
|
7
|
+
import { shellQuote } from './ssh-exec.js';
|
|
8
|
+
import { defaultPtyDriver, } from './fleet/remote-login.js';
|
|
9
|
+
import { showUrl } from './open-url.js';
|
|
10
|
+
import { loadDevices } from './devices/registry.js';
|
|
11
|
+
import { isSelfHost } from './devices/self-host.js';
|
|
12
|
+
import { pushBundleToHost } from './secrets/push.js';
|
|
13
|
+
import { assertCredentialTransportHostPinned, resolveHostSshTarget } from './secrets/remote.js';
|
|
14
|
+
import { resolveRemoteOsSync } from './hosts/remote-os.js';
|
|
15
|
+
/** Well-formed Claude setup-token as it appears inside a TTY blob. */
|
|
16
|
+
export const CLAUDE_SETUP_TOKEN_CAPTURE_RE = /sk-ant-oat01-[A-Za-z0-9_-]+/;
|
|
17
|
+
const ACCOUNT_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
18
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
19
|
+
/**
|
|
20
|
+
* Harnesses that expose an interactive setup-token mint. Native device-code
|
|
21
|
+
* login (codex/droid/kimi/grok) stays on `agents fleet login`; API keys stay
|
|
22
|
+
* on `agents accounts add`. Adding a harness here without a real mint command
|
|
23
|
+
* is a lying table — do not.
|
|
24
|
+
*/
|
|
25
|
+
export const MINT_FLOWS = {
|
|
26
|
+
claude: {
|
|
27
|
+
harness: 'claude',
|
|
28
|
+
provider: 'anthropic',
|
|
29
|
+
auth: 'setup-token',
|
|
30
|
+
mintArgs: ['setup-token'],
|
|
31
|
+
// Authorize URL printed by `claude setup-token` before it waits for a code.
|
|
32
|
+
verificationUrlRegex: /(https:\/\/[^\s"'<>]+)/i,
|
|
33
|
+
tokenCapture: CLAUDE_SETUP_TOKEN_CAPTURE_RE,
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
export function listMintableHarnesses() {
|
|
37
|
+
return Object.keys(MINT_FLOWS);
|
|
38
|
+
}
|
|
39
|
+
export function getMintFlow(harnessRaw) {
|
|
40
|
+
const harness = resolveAgentName(harnessRaw);
|
|
41
|
+
if (!harness) {
|
|
42
|
+
throw new Error(`Unknown harness '${harnessRaw}'. Interactive setup-token mint is implemented for: ${listMintableHarnesses().join(', ')}.`);
|
|
43
|
+
}
|
|
44
|
+
const flow = MINT_FLOWS[harness];
|
|
45
|
+
if (flow)
|
|
46
|
+
return flow;
|
|
47
|
+
throw new Error(unmintableMessage(harness));
|
|
48
|
+
}
|
|
49
|
+
export function unmintableMessage(harness) {
|
|
50
|
+
return [
|
|
51
|
+
`Cannot mint a setup-token for '${harness}'.`,
|
|
52
|
+
`Interactive setup-token mint is implemented for: ${listMintableHarnesses().join(', ')}.`,
|
|
53
|
+
`Device-code native login is \`agents fleet login --agent ${harness}\`.`,
|
|
54
|
+
`API-key / pasted-token accounts use \`agents accounts add <name> --provider <provider> --auth api-key\`.`,
|
|
55
|
+
].join(' ');
|
|
56
|
+
}
|
|
57
|
+
/** Strip CSI / Fe ANSI so a #1767 TTY blob can be scanned for a real token. */
|
|
58
|
+
export function stripAnsi(text) {
|
|
59
|
+
return text.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '');
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Pull a single well-formed Claude setup-token out of a (possibly ANSI-wrapped)
|
|
63
|
+
* screen. Returns null when none is present. Two distinct tokens fail loud —
|
|
64
|
+
* guessing which one to seed is how a banner fragment becomes an auth header.
|
|
65
|
+
*/
|
|
66
|
+
export function extractClaudeSetupToken(screen) {
|
|
67
|
+
const text = stripAnsi(screen);
|
|
68
|
+
const matches = text.match(new RegExp(CLAUDE_SETUP_TOKEN_CAPTURE_RE.source, 'g')) ?? [];
|
|
69
|
+
const unique = [...new Set(matches.filter(isValidClaudeSetupToken))];
|
|
70
|
+
if (unique.length === 0)
|
|
71
|
+
return null;
|
|
72
|
+
if (unique.length > 1) {
|
|
73
|
+
throw new Error(`Screen contained ${unique.length} distinct setup-tokens; refusing to guess. Re-run mint and capture a single sk-ant-oat01- token.`);
|
|
74
|
+
}
|
|
75
|
+
return unique[0];
|
|
76
|
+
}
|
|
77
|
+
/** First https URL on the screen, trailing punctuation stripped. */
|
|
78
|
+
export function extractMintUrl(screen, flow) {
|
|
79
|
+
const text = stripAnsi(screen);
|
|
80
|
+
const m = text.match(flow.verificationUrlRegex);
|
|
81
|
+
if (!m)
|
|
82
|
+
return undefined;
|
|
83
|
+
const raw = (m[1] ?? m[0]).trim();
|
|
84
|
+
return raw.replace(/[.,;:]+$/, '') || undefined;
|
|
85
|
+
}
|
|
86
|
+
export function isEmail(value) {
|
|
87
|
+
return EMAIL_RE.test(value.trim());
|
|
88
|
+
}
|
|
89
|
+
/** Account-name slug of an email (`ada@example.com` → `ada-at-example.com`). */
|
|
90
|
+
export function accountNameFromEmail(email) {
|
|
91
|
+
const slug = email
|
|
92
|
+
.trim()
|
|
93
|
+
.toLowerCase()
|
|
94
|
+
.replace(/@/g, '-at-')
|
|
95
|
+
.replace(/[^a-z0-9._-]/g, '-');
|
|
96
|
+
if (!ACCOUNT_NAME_RE.test(slug)) {
|
|
97
|
+
throw new Error(`Cannot derive an account name from email '${email}'. Pass --account <name>.`);
|
|
98
|
+
}
|
|
99
|
+
return slug;
|
|
100
|
+
}
|
|
101
|
+
export function assertValidSetupToken(token) {
|
|
102
|
+
const cleaned = token.trim();
|
|
103
|
+
if (!isValidClaudeSetupToken(cleaned)) {
|
|
104
|
+
throw new Error('Not a Claude setup-token (expected a single-line sk-ant-oat01-… value). The #1767 capture stored the TTY banner as the token — that is not a token. Re-run `claude setup-token` and paste only the sk-ant-oat01- line.');
|
|
105
|
+
}
|
|
106
|
+
return cleaned;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Resolve the named account + the email that keys the reserved `auth` bundle.
|
|
110
|
+
* `--account` that looks like an email is the email; a name needs `--email` or
|
|
111
|
+
* a locally signed-in `.claude.json`. Missing email fails loud — we must not
|
|
112
|
+
* fall back to a bare shared key (that is the multi-account mix-up).
|
|
113
|
+
*/
|
|
114
|
+
export function resolveMintIdentity(input) {
|
|
115
|
+
const accountRaw = input.account?.trim();
|
|
116
|
+
const emailRaw = input.email?.trim();
|
|
117
|
+
const fromHome = readClaudeAccountEmail(input.home);
|
|
118
|
+
let email;
|
|
119
|
+
let accountName;
|
|
120
|
+
if (emailRaw) {
|
|
121
|
+
if (!isEmail(emailRaw))
|
|
122
|
+
throw new Error(`--email '${emailRaw}' is not an email address.`);
|
|
123
|
+
email = emailRaw;
|
|
124
|
+
}
|
|
125
|
+
if (accountRaw) {
|
|
126
|
+
if (isEmail(accountRaw)) {
|
|
127
|
+
if (email && email.toLowerCase() !== accountRaw.toLowerCase()) {
|
|
128
|
+
throw new Error(`--account '${accountRaw}' and --email '${email}' name different emails.`);
|
|
129
|
+
}
|
|
130
|
+
email = accountRaw;
|
|
131
|
+
accountName = accountNameFromEmail(accountRaw);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
if (!ACCOUNT_NAME_RE.test(accountRaw)) {
|
|
135
|
+
throw new Error(`Account name '${accountRaw}' must start with a letter or number and contain only letters, numbers, dot, underscore, or dash.`);
|
|
136
|
+
}
|
|
137
|
+
accountName = accountRaw;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (!email)
|
|
141
|
+
email = fromHome ?? undefined;
|
|
142
|
+
if (!email) {
|
|
143
|
+
throw new Error("Cannot key a per-account setup-token without an email. Pass --account <email>, or --email, or sign in locally first so this version home's .claude.json has oauthAccount.emailAddress.");
|
|
144
|
+
}
|
|
145
|
+
if (!accountName)
|
|
146
|
+
accountName = accountNameFromEmail(email);
|
|
147
|
+
return { accountName, email };
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Write (or rotate) the reserved FILE-BASED `auth` bundle's per-account key.
|
|
151
|
+
* Usage/probe ignores a keychain- or vault-backed bundle of this name, so a
|
|
152
|
+
* wrong backend fails loud instead of looking like a successful mint.
|
|
153
|
+
*/
|
|
154
|
+
export function seedReservedAuthToken(email, token) {
|
|
155
|
+
const cleaned = assertValidSetupToken(token);
|
|
156
|
+
const key = claudeAccountTokenKey(email);
|
|
157
|
+
if (bundleExists(AUTH_BUNDLE)) {
|
|
158
|
+
const backend = bundleBackend(AUTH_BUNDLE);
|
|
159
|
+
if (backend !== 'file') {
|
|
160
|
+
throw new Error(`Reserved bundle '${AUTH_BUNDLE}' exists with backend '${backend}', but usage/probe only reads a FILE-backed auth bundle. Recreate it with: agents secrets create ${AUTH_BUNDLE} --backend file --policy never --i-understand --force`);
|
|
161
|
+
}
|
|
162
|
+
const bundle = readBundle(AUTH_BUNDLE);
|
|
163
|
+
if (key in bundle.vars) {
|
|
164
|
+
rotateBundleSecret(bundle, key, { newValue: cleaned, meta: { type: 'token' } });
|
|
165
|
+
return { key };
|
|
166
|
+
}
|
|
167
|
+
const item = secretsKeychainItem(AUTH_BUNDLE, key);
|
|
168
|
+
bundle.vars[key] = keychainRef(key);
|
|
169
|
+
if (!bundle.meta)
|
|
170
|
+
bundle.meta = {};
|
|
171
|
+
bundle.meta[key] = { type: 'token' };
|
|
172
|
+
writeBundleWithItems(bundle, new Map([[item, cleaned]]));
|
|
173
|
+
return { key };
|
|
174
|
+
}
|
|
175
|
+
const item = secretsKeychainItem(AUTH_BUNDLE, key);
|
|
176
|
+
const bundle = {
|
|
177
|
+
name: AUTH_BUNDLE,
|
|
178
|
+
backend: 'file',
|
|
179
|
+
policy: 'never',
|
|
180
|
+
description: 'Reserved per-account Claude setup-tokens for unattended usage/probe (never copy native OAuth).',
|
|
181
|
+
vars: { [key]: keychainRef(key) },
|
|
182
|
+
meta: { [key]: { type: 'token' } },
|
|
183
|
+
};
|
|
184
|
+
writeBundleWithItems(bundle, new Map([[item, cleaned]]));
|
|
185
|
+
return { key };
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Create or rotate the named provider account that `agents run --account` and
|
|
189
|
+
* `agents accounts sync` consume. Existing account of a different kind fails
|
|
190
|
+
* loud rather than silently overwriting an API key with a setup-token.
|
|
191
|
+
*/
|
|
192
|
+
export function seedNamedAccount(name, token, flow) {
|
|
193
|
+
const cleaned = assertValidSetupToken(token);
|
|
194
|
+
const existing = findAccount(name);
|
|
195
|
+
if (!existing)
|
|
196
|
+
return addAccount(name, flow.provider, flow.auth, cleaned);
|
|
197
|
+
if (existing.provider !== flow.provider || existing.auth !== flow.auth) {
|
|
198
|
+
throw new Error(`Account '${name}' already exists as ${existing.provider} ${existing.auth}; not overwriting it with a ${flow.provider} ${flow.auth}. Choose a different --account name.`);
|
|
199
|
+
}
|
|
200
|
+
setAccountSecret(name, cleaned);
|
|
201
|
+
return existing;
|
|
202
|
+
}
|
|
203
|
+
/** Stdout progress for the mint path. `--json` / quiet: drop it (stderr errors stay). */
|
|
204
|
+
function emitMintProgress(line, json) {
|
|
205
|
+
if (!json)
|
|
206
|
+
console.log(line);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Drive `claude setup-token` in a PTY: scrape the authorize URL, open it,
|
|
210
|
+
* optionally paste `--code`, then capture the token with the #1767 guard.
|
|
211
|
+
* Tears the session down on the way out (success, timeout, or throw).
|
|
212
|
+
*/
|
|
213
|
+
export async function driveSetupTokenMint(command, flow, opts = {}) {
|
|
214
|
+
const driver = opts.driver ?? defaultPtyDriver();
|
|
215
|
+
const initialDelayMs = opts.drive?.initialDelayMs ?? 1500;
|
|
216
|
+
const pollMs = opts.drive?.pollMs ?? 500;
|
|
217
|
+
const timeoutMs = opts.drive?.timeoutMs ?? 180_000;
|
|
218
|
+
const json = opts.json === true;
|
|
219
|
+
const openUrl = opts.openUrl ?? (async (url) => {
|
|
220
|
+
const shown = await showUrl(url);
|
|
221
|
+
if (shown.via === 'none') {
|
|
222
|
+
// stderr: `--json` stdout stays parseable; the operator still gets the URL.
|
|
223
|
+
console.error(`Could not open a browser — open this yourself:\n ${url}`);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
const id = await driver.start();
|
|
227
|
+
let opened;
|
|
228
|
+
let wroteCode = false;
|
|
229
|
+
let askedCode = false;
|
|
230
|
+
try {
|
|
231
|
+
await driver.exec(id, command);
|
|
232
|
+
await sleep(initialDelayMs);
|
|
233
|
+
const deadline = Date.now() + timeoutMs;
|
|
234
|
+
for (;;) {
|
|
235
|
+
const { screen, exited } = await driver.screen(id);
|
|
236
|
+
const url = extractMintUrl(screen, flow);
|
|
237
|
+
const token = extractClaudeSetupToken(screen);
|
|
238
|
+
if (token) {
|
|
239
|
+
const capturedUrl = url ?? opened;
|
|
240
|
+
if (capturedUrl)
|
|
241
|
+
emitMintProgress(`Authorize URL: ${capturedUrl}`, json);
|
|
242
|
+
return { token, url: capturedUrl, sessionId: id };
|
|
243
|
+
}
|
|
244
|
+
if (url && url !== opened) {
|
|
245
|
+
opened = url;
|
|
246
|
+
await openUrl(url);
|
|
247
|
+
}
|
|
248
|
+
if (url && !wroteCode && !askedCode) {
|
|
249
|
+
const code = opts.code ?? await opts.readCode?.();
|
|
250
|
+
askedCode = true;
|
|
251
|
+
if (code?.trim()) {
|
|
252
|
+
await driver.write(id, `${code.trim()}\r`);
|
|
253
|
+
wroteCode = true;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (exited) {
|
|
257
|
+
throw new Error(opened
|
|
258
|
+
? `Mint process exited before printing a setup-token. Authorize URL was ${opened}. Re-run and complete the browser step, or seed with --token-stdin.`
|
|
259
|
+
: 'Mint process exited before printing an authorize URL or a setup-token.');
|
|
260
|
+
}
|
|
261
|
+
if (Date.now() >= deadline) {
|
|
262
|
+
throw new Error(opened
|
|
263
|
+
? `Timed out waiting for a setup-token after opening ${opened}. Paste the code with --code, or seed an already-minted token with --token-stdin.`
|
|
264
|
+
: 'Timed out waiting for `claude setup-token` to print an authorize URL.');
|
|
265
|
+
}
|
|
266
|
+
await sleep(pollMs);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
catch (e) {
|
|
270
|
+
await driver.stop(id).catch(() => { });
|
|
271
|
+
throw e;
|
|
272
|
+
}
|
|
273
|
+
finally {
|
|
274
|
+
await driver.stop(id).catch(() => { });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
export function buildMintCommand(flow, bin, home) {
|
|
278
|
+
if (!flow.mintArgs) {
|
|
279
|
+
throw new Error(`Harness '${flow.harness}' has no interactive mint command.`);
|
|
280
|
+
}
|
|
281
|
+
const args = flow.mintArgs.map(shellQuote).join(' ');
|
|
282
|
+
return `HOME=${shellQuote(home)} ${shellQuote(bin)} ${args}`;
|
|
283
|
+
}
|
|
284
|
+
export function resolveMintInstallation(harness) {
|
|
285
|
+
const installed = listInstalledVersions(harness);
|
|
286
|
+
const version = getGlobalDefault(harness) ?? installed[installed.length - 1];
|
|
287
|
+
if (!version) {
|
|
288
|
+
throw new Error(`No installed version of ${harness}. Install one with: agents add ${harness}`);
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
version,
|
|
292
|
+
bin: getBinaryPath(harness, version),
|
|
293
|
+
home: getVersionHomePath(harness, version),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* End-to-end mint: resolve identity, obtain a token (stdin or PTY drive),
|
|
298
|
+
* seed the named account + reserved auth bundle, optionally sync the fleet.
|
|
299
|
+
* Never returns or logs the token.
|
|
300
|
+
*/
|
|
301
|
+
export async function mintAndSeed(input) {
|
|
302
|
+
const flow = getMintFlow(input.harness);
|
|
303
|
+
const json = input.json === true;
|
|
304
|
+
// An injected PTY driver is a complete substitute for exec'ing the local
|
|
305
|
+
// binary, so the install lookup is only required on the real drive path.
|
|
306
|
+
const install = input.token
|
|
307
|
+
? null
|
|
308
|
+
: input.hooks?.driver
|
|
309
|
+
? { bin: flow.harness, home: process.env.HOME ?? '/tmp' }
|
|
310
|
+
: resolveMintInstallation(flow.harness);
|
|
311
|
+
const identity = resolveMintIdentity({
|
|
312
|
+
account: input.account,
|
|
313
|
+
email: input.email,
|
|
314
|
+
home: install?.home,
|
|
315
|
+
});
|
|
316
|
+
let token;
|
|
317
|
+
if (input.token !== undefined) {
|
|
318
|
+
token = assertValidSetupToken(input.token);
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
const command = buildMintCommand(flow, install.bin, install.home);
|
|
322
|
+
const openUrl = input.open === false
|
|
323
|
+
? async (url) => {
|
|
324
|
+
emitMintProgress(`Authorize: ${url}`, json);
|
|
325
|
+
}
|
|
326
|
+
: input.hooks?.openUrl;
|
|
327
|
+
const driven = await driveSetupTokenMint(command, flow, {
|
|
328
|
+
driver: input.hooks?.driver,
|
|
329
|
+
openUrl,
|
|
330
|
+
readCode: input.hooks?.readCode,
|
|
331
|
+
drive: input.hooks?.drive,
|
|
332
|
+
code: input.code,
|
|
333
|
+
json,
|
|
334
|
+
});
|
|
335
|
+
token = driven.token;
|
|
336
|
+
}
|
|
337
|
+
const existing = findAccount(identity.accountName);
|
|
338
|
+
const account = seedNamedAccount(identity.accountName, token, flow);
|
|
339
|
+
const { key } = seedReservedAuthToken(identity.email, token);
|
|
340
|
+
const targets = await resolveSyncTargets(input.fleet === true, input.devices ?? []);
|
|
341
|
+
const fleet = [];
|
|
342
|
+
for (const device of targets) {
|
|
343
|
+
fleet.push(await syncMintedBundles(account.name, device));
|
|
344
|
+
}
|
|
345
|
+
const failed = fleet.filter((row) => !row.ok);
|
|
346
|
+
if (failed.length) {
|
|
347
|
+
throw new Error(`Minted locally but fleet sync failed for: ${failed.map((row) => `${row.device} (${row.message})`).join('; ')}. Retry: agents accounts sync ${account.name} <device>`);
|
|
348
|
+
}
|
|
349
|
+
return {
|
|
350
|
+
harness: flow.harness,
|
|
351
|
+
account: account.name,
|
|
352
|
+
email: identity.email,
|
|
353
|
+
authBundleKey: key,
|
|
354
|
+
rotated: Boolean(existing),
|
|
355
|
+
fleet,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
export async function resolveSyncTargets(fleet, devices) {
|
|
359
|
+
const named = [...new Set(devices.map((d) => d.trim()).filter(Boolean))];
|
|
360
|
+
if (!fleet && named.length === 0)
|
|
361
|
+
return [];
|
|
362
|
+
const registry = await loadDevices();
|
|
363
|
+
const known = Object.keys(registry);
|
|
364
|
+
if (named.length) {
|
|
365
|
+
// A self host in --device is skipped, not an error; an unknown name is.
|
|
366
|
+
const unknown = named.filter((d) => !known.includes(d) && !isSelfHost(d));
|
|
367
|
+
if (unknown.length) {
|
|
368
|
+
throw new Error(`Unknown device${unknown.length === 1 ? '' : 's'} '${unknown.join("', '")}'. Register with \`agents devices\` or omit --device.`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const fromFleet = fleet
|
|
372
|
+
? known.filter((name) => !isSelfHost(name))
|
|
373
|
+
: [];
|
|
374
|
+
const union = [...new Set([...fromFleet, ...named.filter((d) => !isSelfHost(d))])];
|
|
375
|
+
return union;
|
|
376
|
+
}
|
|
377
|
+
async function syncMintedBundles(accountName, device) {
|
|
378
|
+
try {
|
|
379
|
+
const sshTarget = await resolveHostSshTarget(device);
|
|
380
|
+
assertCredentialTransportHostPinned(sshTarget);
|
|
381
|
+
const remoteBackend = resolveRemoteOsSync(device) === 'win32' ? 'keychain' : 'file';
|
|
382
|
+
const account = findAccount(accountName);
|
|
383
|
+
if (!account)
|
|
384
|
+
throw new Error(`Unknown provider account '${accountName}'.`);
|
|
385
|
+
const accountPush = pushBundleToHost(accountName, device, {
|
|
386
|
+
remoteBackend,
|
|
387
|
+
force: true,
|
|
388
|
+
operation: 'accounts mint --fleet',
|
|
389
|
+
policyNever: true,
|
|
390
|
+
agentOnly: false,
|
|
391
|
+
literalValues: {
|
|
392
|
+
ACCOUNT_ID: account.id,
|
|
393
|
+
PROVIDER: account.provider,
|
|
394
|
+
AUTH_TYPE: account.auth,
|
|
395
|
+
...(account.baseUrl ? { BASE_URL: account.baseUrl } : {}),
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
if (!accountPush.ok)
|
|
399
|
+
throw new Error(accountPush.message);
|
|
400
|
+
if (bundleExists(AUTH_BUNDLE)) {
|
|
401
|
+
const authPush = pushBundleToHost(AUTH_BUNDLE, device, {
|
|
402
|
+
remoteBackend: 'file',
|
|
403
|
+
force: true,
|
|
404
|
+
operation: 'accounts mint --fleet',
|
|
405
|
+
policyNever: true,
|
|
406
|
+
agentOnly: true,
|
|
407
|
+
});
|
|
408
|
+
if (!authPush.ok)
|
|
409
|
+
throw new Error(authPush.message);
|
|
410
|
+
}
|
|
411
|
+
return { device, ok: true, message: `${accountPush.keyCount} keys` };
|
|
412
|
+
}
|
|
413
|
+
catch (err) {
|
|
414
|
+
return { device, ok: false, message: err instanceof Error ? err.message : String(err) };
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/** True when a Claude setup-token is already seeded on this box (setup status). */
|
|
418
|
+
export function hasMintedSetupToken() {
|
|
419
|
+
const records = Object.values(readAccountRegistry().accounts);
|
|
420
|
+
const setup = records.filter((a) => a.auth === 'setup-token' && a.provider === 'anthropic');
|
|
421
|
+
if (setup.length) {
|
|
422
|
+
return { ready: true, detail: `${setup.length} Claude setup-token account${setup.length === 1 ? '' : 's'}` };
|
|
423
|
+
}
|
|
424
|
+
if (bundleExists(AUTH_BUNDLE) && bundleBackend(AUTH_BUNDLE) === 'file') {
|
|
425
|
+
const bundle = readBundle(AUTH_BUNDLE);
|
|
426
|
+
const keys = Object.keys(bundle.vars).filter((k) => k.startsWith('CLAUDE_CODE_OAUTH_TOKEN_'));
|
|
427
|
+
if (keys.length)
|
|
428
|
+
return { ready: true, detail: `reserved auth bundle (${keys.length} account key${keys.length === 1 ? '' : 's'})` };
|
|
429
|
+
}
|
|
430
|
+
return { ready: false, detail: 'no Claude setup-token minted — agents accounts mint claude' };
|
|
431
|
+
}
|
|
432
|
+
function sleep(ms) {
|
|
433
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
434
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { BrowserProfileConfig } from '../types.js';
|
|
1
2
|
import type { BrowserProfile, ProfileName } from './types.js';
|
|
2
3
|
export type { BrowserProfile } from './types.js';
|
|
3
4
|
export { declaringDevices, profileKind, profileRegistry, type ProfileDeclaration, } from './registry.js';
|
|
@@ -175,6 +176,23 @@ export declare function effectiveLocalPort(profile: BrowserProfile): number | un
|
|
|
175
176
|
* skip remote profiles in this scan.
|
|
176
177
|
*/
|
|
177
178
|
export declare function findFreeProfilePort(): Promise<number>;
|
|
179
|
+
export declare function hasSshEndpoint(endpoints: BrowserProfileConfig['endpoints']): boolean;
|
|
180
|
+
/**
|
|
181
|
+
* Whether the automatic central-tombstone drain (PHNX-3315) may claim `config`
|
|
182
|
+
* into THIS device's doc during `agents sync`.
|
|
183
|
+
*
|
|
184
|
+
* Only REMOTE (`ssh://`) profiles qualify. An `ssh://` endpoint names a specific
|
|
185
|
+
* host, so the profile is fungible by design — any box resolves it to the same
|
|
186
|
+
* browser, and a concurrent double-claim across machines is harmless. A
|
|
187
|
+
* local/`cdp://` profile has NO per-machine ownership signal: "that browser is
|
|
188
|
+
* installed here" is not "I hold this profile's credentialed session", so two
|
|
189
|
+
* boxes with the same common browser installed would each auto-claim the same
|
|
190
|
+
* tombstone on their first post-merge sync and flip it identity->fungible
|
|
191
|
+
* fleet-wide (the exact logged-out-browser failure this module exists to prevent
|
|
192
|
+
* — PHNX-3315 review). Local/cdp tombstones stay central for the explicit
|
|
193
|
+
* `agents browser profiles claim`.
|
|
194
|
+
*/
|
|
195
|
+
export declare function shouldAutoClaimCentralProfile(config: BrowserProfileConfig): boolean;
|
|
178
196
|
/**
|
|
179
197
|
* Refuse a profile whose LOCAL port another profile already owns.
|
|
180
198
|
*
|
|
@@ -390,7 +390,7 @@ function validateRemoteBrowserBinary(binary) {
|
|
|
390
390
|
throw new Error(`Remote browser binary contains shell metacharacters: ${binary}`);
|
|
391
391
|
}
|
|
392
392
|
}
|
|
393
|
-
function hasSshEndpoint(endpoints) {
|
|
393
|
+
export function hasSshEndpoint(endpoints) {
|
|
394
394
|
const targets = Array.isArray(endpoints)
|
|
395
395
|
? endpoints
|
|
396
396
|
: Object.values(endpoints).map((preset) => preset.target);
|
|
@@ -403,6 +403,31 @@ function hasSshEndpoint(endpoints) {
|
|
|
403
403
|
}
|
|
404
404
|
});
|
|
405
405
|
}
|
|
406
|
+
/**
|
|
407
|
+
* Whether the automatic central-tombstone drain (PHNX-3315) may claim `config`
|
|
408
|
+
* into THIS device's doc during `agents sync`.
|
|
409
|
+
*
|
|
410
|
+
* Only REMOTE (`ssh://`) profiles qualify. An `ssh://` endpoint names a specific
|
|
411
|
+
* host, so the profile is fungible by design — any box resolves it to the same
|
|
412
|
+
* browser, and a concurrent double-claim across machines is harmless. A
|
|
413
|
+
* local/`cdp://` profile has NO per-machine ownership signal: "that browser is
|
|
414
|
+
* installed here" is not "I hold this profile's credentialed session", so two
|
|
415
|
+
* boxes with the same common browser installed would each auto-claim the same
|
|
416
|
+
* tombstone on their first post-merge sync and flip it identity->fungible
|
|
417
|
+
* fleet-wide (the exact logged-out-browser failure this module exists to prevent
|
|
418
|
+
* — PHNX-3315 review). Local/cdp tombstones stay central for the explicit
|
|
419
|
+
* `agents browser profiles claim`.
|
|
420
|
+
*/
|
|
421
|
+
export function shouldAutoClaimCentralProfile(config) {
|
|
422
|
+
if (!hasSshEndpoint(config.endpoints))
|
|
423
|
+
return false;
|
|
424
|
+
return isProfileLaunchableHere({
|
|
425
|
+
name: '_',
|
|
426
|
+
browser: config.browser,
|
|
427
|
+
binary: config.binary,
|
|
428
|
+
endpoints: config.endpoints,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
406
431
|
/**
|
|
407
432
|
* Refuse a profile whose LOCAL port another profile already owns.
|
|
408
433
|
*
|
|
@@ -3,7 +3,15 @@ export interface ProfileDeclaration {
|
|
|
3
3
|
device: string;
|
|
4
4
|
config: BrowserProfileConfig;
|
|
5
5
|
}
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* The leftover central `browser:` tombstone from before per-device declarations.
|
|
8
|
+
* Empty when none remain — and `agents sync` now drains it automatically via
|
|
9
|
+
* {@link autoEvictCentralBrowserProfiles}, so on a synced box this is empty and
|
|
10
|
+
* {@link profileRegistry} is the single source of truth. It survives only to
|
|
11
|
+
* fold in migration and to explain an as-yet-unclaimed profile in a resolve
|
|
12
|
+
* error (a profile hostable on a peer that has not synced yet), never as a
|
|
13
|
+
* parallel store callers read for resolution.
|
|
14
|
+
*/
|
|
7
15
|
export declare function centralBrowserProfiles(): Record<string, BrowserProfileConfig>;
|
|
8
16
|
export interface CentralClaimResult {
|
|
9
17
|
claimed: string[];
|
|
@@ -12,22 +20,44 @@ export interface CentralClaimResult {
|
|
|
12
20
|
/**
|
|
13
21
|
* Fold leftover central `browser:` entries into THIS device's declaration file.
|
|
14
22
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
23
|
+
* The EXPLICIT operator action (`agents browser profiles claim`), run on the
|
|
24
|
+
* machine that actually owns the browser. It is never called implicitly from a
|
|
25
|
+
* read, and that is the point: every device can read the central map, so an
|
|
26
|
+
* implicit claim races — whichever box reads first claims the name, {@link
|
|
27
|
+
* profileKind} then reports it `identity`, and the daemon tunnels to that box,
|
|
28
|
+
* which for a fleet-wide `cdp://localhost:*` profile is a logged-out headless
|
|
29
|
+
* browser wearing a credentialed browser's name. {@link autoEvictCentralBrowserProfiles}
|
|
30
|
+
* closes that race for the automatic path (host-gated + non-throwing on sync);
|
|
31
|
+
* this one is the manual, throw-on-conflict counterpart.
|
|
22
32
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* Only profiles this machine can host are claimed; the rest stay central,
|
|
28
|
-
* undeclared, and fail loudly on resolve.
|
|
33
|
+
* `canHostHere` is supplied by the command layer so this module stays a leaf —
|
|
34
|
+
* it must not import `isProfileLaunchableHere` (that would cycle through
|
|
35
|
+
* chrome.ts → profiles.ts). Only profiles this machine can host are claimed; the
|
|
36
|
+
* rest stay central, undeclared, and fail loudly on resolve.
|
|
29
37
|
*/
|
|
30
38
|
export declare function migrateCentralBrowserProfiles(canHostHere: (config: BrowserProfileConfig) => boolean, name?: string): CentralClaimResult;
|
|
39
|
+
/**
|
|
40
|
+
* Automatic, self-draining counterpart to {@link migrateCentralBrowserProfiles}:
|
|
41
|
+
* fold every lingering central `browser:` profile THIS machine can host into its
|
|
42
|
+
* device doc and clear it from central, so the tombstone drains itself on
|
|
43
|
+
* `agents sync` with no manual `agents browser profiles claim`.
|
|
44
|
+
*
|
|
45
|
+
* Safe to call implicitly, unlike a claim at registry-read time (which races —
|
|
46
|
+
* see the `profileRegistry does not claim central declarations` guard), but the
|
|
47
|
+
* CALLER owns one ordering invariant: invoke it AFTER the sync's repo pull. Two
|
|
48
|
+
* boxes can both host the same profile (`canHostHere` is launchability, not
|
|
49
|
+
* ownership), so a box acting on a PRE-pull view of central could re-claim a
|
|
50
|
+
* profile a peer already drained and pushed — both writes land in different
|
|
51
|
+
* device files with no git conflict, and the profile flips identity->fungible.
|
|
52
|
+
* Running post-pull means central already reflects peers' drains, so the tombstone
|
|
53
|
+
* is gone locally before this box looks. The selection is also computed INSIDE
|
|
54
|
+
* the meta lock, and the path never throws — an unhostable profile or one that
|
|
55
|
+
* conflicts with an existing local declaration is left central for an explicit
|
|
56
|
+
* claim rather than wedging the sync. After it runs, {@link profileRegistry} is
|
|
57
|
+
* the single source of truth: a claimed profile lives in the device doc alone,
|
|
58
|
+
* never double-counted across the two stores.
|
|
59
|
+
*/
|
|
60
|
+
export declare function autoEvictCentralBrowserProfiles(canHostHere: (config: BrowserProfileConfig) => boolean): CentralClaimResult;
|
|
31
61
|
/**
|
|
32
62
|
* Every profile any device declares, keyed by name to all declaring devices.
|
|
33
63
|
* Reads every `devices/<name>/agents.yaml`; declarations never overwrite.
|