@phnx-labs/agents-cli 1.22.84 → 1.22.86
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 +96 -0
- package/README.md +80 -53
- package/dist/bootstrap.d.ts +7 -6
- package/dist/bootstrap.js +20 -27
- package/dist/cli/command-registry.js +5 -1
- package/dist/commands/accounts.d.ts +6 -13
- package/dist/commands/accounts.js +330 -143
- package/dist/commands/alias.js +7 -3
- package/dist/commands/apply.js +1 -1
- package/dist/commands/auth-mint.d.ts +7 -3
- package/dist/commands/auth-mint.js +21 -9
- package/dist/commands/auth.js +2 -3
- package/dist/commands/browser.js +10 -10
- package/dist/commands/daemon.d.ts +7 -3
- package/dist/commands/daemon.js +40 -25
- package/dist/commands/doctor.js +28 -2
- package/dist/commands/exec.js +87 -39
- package/dist/commands/fleet-capture.js +21 -2
- package/dist/commands/harness-wizard.js +2 -2
- package/dist/commands/lease.js +7 -12
- package/dist/commands/profiles.d.ts +2 -2
- package/dist/commands/profiles.js +20 -13
- package/dist/commands/repo.js +3 -3
- package/dist/commands/run-account-picker.d.ts +3 -3
- package/dist/commands/run-account-picker.js +29 -30
- package/dist/commands/secrets-passthrough.d.ts +20 -0
- package/dist/commands/secrets-passthrough.js +43 -0
- package/dist/commands/setup-accounts.d.ts +3 -2
- package/dist/commands/setup-accounts.js +4 -4
- package/dist/commands/setup-secrets.d.ts +16 -21
- package/dist/commands/setup-secrets.js +43 -224
- package/dist/commands/ssh.js +2 -2
- package/dist/commands/sync.js +1 -1
- package/dist/commands/update.d.ts +10 -0
- package/dist/commands/update.js +43 -40
- package/dist/commands/versions.d.ts +11 -11
- package/dist/commands/versions.js +131 -83
- package/dist/commands/view.d.ts +1 -9
- package/dist/commands/view.js +41 -55
- package/dist/commands/webhook.js +5 -5
- package/dist/commands/workflows.js +2 -1
- package/dist/index.d.ts +12 -12
- package/dist/index.js +13 -42
- package/dist/lib/account-capabilities.d.ts +1 -1
- package/dist/lib/account-capabilities.js +1 -1
- package/dist/lib/account-catalog.d.ts +121 -6
- package/dist/lib/account-catalog.js +421 -7
- package/dist/lib/account-registry.d.ts +8 -30
- package/dist/lib/account-registry.js +36 -79
- package/dist/lib/account-schema.d.ts +1 -1
- package/dist/lib/account-schema.js +1 -1
- package/dist/lib/accounting/account-pool-collect.js +16 -4
- package/dist/lib/accounting/rotate.d.ts +20 -7
- package/dist/lib/accounting/rotate.js +89 -13
- package/dist/lib/accounting/usage.js +11 -16
- package/dist/lib/accounts/add.d.ts +138 -0
- package/dist/lib/accounts/add.js +651 -0
- package/dist/lib/accounts/migrate.d.ts +117 -0
- package/dist/lib/accounts/migrate.js +536 -0
- package/dist/lib/accounts/slots.d.ts +13 -0
- package/dist/lib/accounts/slots.js +100 -0
- package/dist/lib/agent-spec/agents.d.ts +16 -0
- package/dist/lib/agent-spec/agents.js +38 -4
- package/dist/lib/app-bundle-install.js +5 -4
- package/dist/lib/auth-health.d.ts +3 -0
- package/dist/lib/auth-health.js +11 -0
- package/dist/lib/auth-mint.d.ts +52 -16
- package/dist/lib/auth-mint.js +117 -39
- package/dist/lib/browser/chrome.d.ts +1 -1
- package/dist/lib/browser/chrome.js +5 -5
- package/dist/lib/byok-usage.js +3 -3
- package/dist/lib/claude-account-token.d.ts +47 -3
- package/dist/lib/claude-account-token.js +172 -50
- package/dist/lib/cloud/antigravity.js +4 -4
- package/dist/lib/cloud/cursor.js +4 -4
- package/dist/lib/crabbox/cli.d.ts +1 -1
- package/dist/lib/crabbox/cli.js +6 -6
- package/dist/lib/crabbox/runtimes.d.ts +3 -3
- package/dist/lib/crabbox/runtimes.js +5 -5
- package/dist/lib/daemon/account-state-daemon-service.d.ts +37 -1
- package/dist/lib/daemon/account-state-daemon-service.js +199 -3
- package/dist/lib/daemon/auth-sync-service.js +32 -1
- package/dist/lib/daemon/daemon.d.ts +6 -16
- package/dist/lib/daemon/daemon.js +52 -97
- package/dist/lib/daemon/daemon.test-fixture.d.ts +7 -9
- package/dist/lib/daemon/daemon.test-fixture.js +22 -40
- package/dist/lib/daemon/harness-update-service.d.ts +1 -1
- package/dist/lib/daemon/harness-update-service.js +1 -1
- package/dist/lib/daemon/runner.d.ts +15 -3
- package/dist/lib/daemon/runner.js +49 -24
- package/dist/lib/daemon-health.d.ts +1 -2
- package/dist/lib/daemon-health.js +7 -6
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +1 -11
- package/dist/lib/daemon-webhooks.d.ts +8 -7
- package/dist/lib/daemon-webhooks.js +10 -9
- package/dist/lib/device-config.js +1 -1
- package/dist/lib/devices/doctor-findings.d.ts +1 -1
- package/dist/lib/devices/harness-inventory.d.ts +39 -0
- package/dist/lib/devices/harness-inventory.js +126 -4
- package/dist/lib/doctor-diff.js +2 -1
- package/dist/lib/exec-account-home.d.ts +38 -0
- package/dist/lib/exec-account-home.js +164 -0
- package/dist/lib/exec.d.ts +30 -0
- package/dist/lib/exec.js +101 -31
- package/dist/lib/fleet/apply.d.ts +2 -2
- package/dist/lib/fleet/apply.js +4 -4
- package/dist/lib/fleet/auth-sync.js +4 -3
- package/dist/lib/fleet-shared-repo-sync.js +6 -9
- package/dist/lib/harness/adapter.d.ts +7 -7
- package/dist/lib/harness/adapter.js +10 -6
- package/dist/lib/harness/adapters/grok.js +8 -3
- package/dist/lib/harness/adapters/muse.js +1 -1
- package/dist/lib/harness/adapters/opencode.js +12 -3
- package/dist/lib/harness-auth-capabilities.d.ts +61 -0
- package/dist/lib/harness-auth-capabilities.js +52 -0
- package/dist/lib/helper-versions.d.ts +6 -4
- package/dist/lib/helper-versions.js +5 -4
- package/dist/lib/hosts/credential-transport.d.ts +10 -0
- package/dist/lib/hosts/credential-transport.js +37 -0
- package/dist/lib/hosts/dispatch.d.ts +7 -0
- package/dist/lib/hosts/dispatch.js +13 -6
- package/dist/lib/identity/client.d.ts +3 -3
- package/dist/lib/identity/client.js +3 -3
- package/dist/lib/installations/index.d.ts +1 -1
- package/dist/lib/installations/index.js +1 -1
- package/dist/lib/installations/migrate.js +9 -0
- package/dist/lib/installations/resolve.js +1 -1
- package/dist/lib/installations/shims.d.ts +21 -4
- package/dist/lib/installations/shims.js +86 -5
- package/dist/lib/installations/store.d.ts +43 -0
- package/dist/lib/installations/store.js +75 -7
- package/dist/lib/installations/versions.js +1 -1
- package/dist/lib/menubar/install-menubar.d.ts +3 -3
- package/dist/lib/menubar/install-menubar.js +3 -3
- package/dist/lib/native-accounts.d.ts +35 -0
- package/dist/lib/native-accounts.js +41 -0
- package/dist/lib/net-close.d.ts +11 -0
- package/dist/lib/net-close.js +25 -0
- package/dist/lib/openclaw-keychain.js +27 -1
- package/dist/lib/profiles.js +22 -9
- package/dist/lib/project-resources.js +1 -1
- package/dist/lib/reserved-stores.d.ts +53 -0
- package/dist/lib/reserved-stores.js +120 -0
- package/dist/lib/secrets-client.d.ts +199 -0
- package/dist/lib/secrets-client.js +718 -0
- package/dist/lib/secrets-policy.d.ts +235 -0
- package/dist/lib/secrets-policy.js +505 -0
- package/dist/lib/secrets-types.d.ts +188 -0
- package/dist/lib/secrets-types.js +24 -0
- package/dist/lib/self-heal/checks/shims.js +5 -3
- package/dist/lib/service-manifest.js +3 -4
- package/dist/lib/session/db.d.ts +18 -0
- package/dist/lib/session/db.js +47 -14
- package/dist/lib/session/sync/config.js +2 -2
- package/dist/lib/sha256-asset.d.ts +11 -16
- package/dist/lib/sha256-asset.js +11 -16
- package/dist/lib/share/config.js +14 -5
- package/dist/lib/signin-badge.d.ts +26 -0
- package/dist/lib/signin-badge.js +42 -0
- package/dist/lib/staleness/detectors/workflows.js +3 -105
- package/dist/lib/staleness/writers/workflows.js +2 -1
- package/dist/lib/state.d.ts +7 -4
- package/dist/lib/state.js +18 -7
- package/dist/lib/sync-umbrella.js +2 -2
- package/dist/lib/teams/agents.js +1 -1
- package/dist/lib/types.d.ts +74 -33
- package/dist/lib/view-types.d.ts +6 -3
- package/dist/lib/workflows-registry.d.ts +71 -0
- package/dist/lib/workflows-registry.js +280 -0
- package/dist/lib/workflows.d.ts +21 -21
- package/dist/lib/workflows.js +12 -327
- package/package.json +2 -3
- package/scripts/postinstall.js +35 -30
- package/dist/commands/secrets-import.d.ts +0 -18
- package/dist/commands/secrets-import.js +0 -74
- package/dist/commands/secrets-migrate.d.ts +0 -25
- package/dist/commands/secrets-migrate.js +0 -334
- package/dist/commands/secrets-rotate-passphrase.d.ts +0 -17
- package/dist/commands/secrets-rotate-passphrase.js +0 -96
- package/dist/commands/secrets-sync.d.ts +0 -11
- package/dist/commands/secrets-sync.js +0 -153
- package/dist/commands/secrets-vault.d.ts +0 -10
- package/dist/commands/secrets-vault.js +0 -130
- package/dist/commands/secrets.d.ts +0 -212
- package/dist/commands/secrets.js +0 -3030
- package/dist/lib/accounts/connect.d.ts +0 -176
- package/dist/lib/accounts/connect.js +0 -453
- package/dist/lib/daemon/keychain-reap-service.d.ts +0 -17
- package/dist/lib/daemon/keychain-reap-service.js +0 -32
- package/dist/lib/daemon/secrets-broker-service.d.ts +0 -21
- package/dist/lib/daemon/secrets-broker-service.js +0 -51
- package/dist/lib/secrets/agent.d.ts +0 -358
- package/dist/lib/secrets/agent.js +0 -1291
- package/dist/lib/secrets/audit.d.ts +0 -46
- package/dist/lib/secrets/audit.js +0 -101
- package/dist/lib/secrets/bundles.d.ts +0 -290
- package/dist/lib/secrets/bundles.js +0 -1547
- package/dist/lib/secrets/download-keychain.d.ts +0 -47
- package/dist/lib/secrets/download-keychain.js +0 -70
- package/dist/lib/secrets/drivers/rush.d.ts +0 -14
- package/dist/lib/secrets/drivers/rush.js +0 -90
- package/dist/lib/secrets/fallback.d.ts +0 -48
- package/dist/lib/secrets/fallback.js +0 -48
- package/dist/lib/secrets/filestore.d.ts +0 -222
- package/dist/lib/secrets/filestore.js +0 -1099
- package/dist/lib/secrets/headless.d.ts +0 -43
- package/dist/lib/secrets/headless.js +0 -56
- package/dist/lib/secrets/icloud-import.d.ts +0 -79
- package/dist/lib/secrets/icloud-import.js +0 -206
- package/dist/lib/secrets/index.d.ts +0 -451
- package/dist/lib/secrets/index.js +0 -1568
- package/dist/lib/secrets/install-helper.d.ts +0 -72
- package/dist/lib/secrets/install-helper.js +0 -245
- package/dist/lib/secrets/lease.d.ts +0 -25
- package/dist/lib/secrets/lease.js +0 -44
- package/dist/lib/secrets/linux.d.ts +0 -77
- package/dist/lib/secrets/linux.js +0 -393
- package/dist/lib/secrets/list-filter.d.ts +0 -109
- package/dist/lib/secrets/list-filter.js +0 -261
- package/dist/lib/secrets/mcp.d.ts +0 -93
- package/dist/lib/secrets/mcp.js +0 -211
- package/dist/lib/secrets/profiles.d.ts +0 -10
- package/dist/lib/secrets/profiles.js +0 -13
- package/dist/lib/secrets/push.d.ts +0 -133
- package/dist/lib/secrets/push.js +0 -273
- package/dist/lib/secrets/rc-hygiene.d.ts +0 -63
- package/dist/lib/secrets/rc-hygiene.js +0 -143
- package/dist/lib/secrets/read-backoff.d.ts +0 -27
- package/dist/lib/secrets/read-backoff.js +0 -64
- package/dist/lib/secrets/reaper.d.ts +0 -90
- package/dist/lib/secrets/reaper.js +0 -243
- package/dist/lib/secrets/remote.d.ts +0 -152
- package/dist/lib/secrets/remote.js +0 -344
- package/dist/lib/secrets/reserved-sync.d.ts +0 -66
- package/dist/lib/secrets/reserved-sync.js +0 -147
- package/dist/lib/secrets/scope.d.ts +0 -26
- package/dist/lib/secrets/scope.js +0 -29
- package/dist/lib/secrets/session-store.d.ts +0 -107
- package/dist/lib/secrets/session-store.js +0 -342
- package/dist/lib/secrets/sync-backend.d.ts +0 -48
- package/dist/lib/secrets/sync-backend.js +0 -13
- package/dist/lib/secrets/sync-commands.d.ts +0 -21
- package/dist/lib/secrets/sync-commands.js +0 -21
- package/dist/lib/secrets/sync.d.ts +0 -48
- package/dist/lib/secrets/sync.js +0 -237
- package/dist/lib/secrets/unlock-hints.d.ts +0 -27
- package/dist/lib/secrets/unlock-hints.js +0 -36
- package/dist/lib/secrets/usage-db.d.ts +0 -46
- package/dist/lib/secrets/usage-db.js +0 -96
- package/dist/lib/secrets/vault-age-helper.d.ts +0 -1
- package/dist/lib/secrets/vault-age-helper.js +0 -34
- package/dist/lib/secrets/vault.d.ts +0 -49
- package/dist/lib/secrets/vault.js +0 -399
- package/dist/lib/secrets/windows.d.ts +0 -81
- package/dist/lib/secrets/windows.js +0 -556
- package/scripts/install-helper.js +0 -97
- /package/dist/lib/{secrets/sync-passphrase.d.ts → sync-passphrase.d.ts} +0 -0
- /package/dist/lib/{secrets/sync-passphrase.js → sync-passphrase.js} +0 -0
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agents-owned fleet policy for the secrets engine boundary (PHNX-3989 CTX-1,
|
|
3
|
+
* delta-spec OWN-1). The standalone `secrets` engine has no concept of a
|
|
4
|
+
* "reserved" per-harness credential store, resource profiles, device roles, or
|
|
5
|
+
* fleet election — none of that is portable secret-storage behavior, so it
|
|
6
|
+
* lives here rather than in the engine. Every actual read/write against a
|
|
7
|
+
* bundle or raw item resolves through the process client (`secrets-client.ts`);
|
|
8
|
+
* this module only decides WHICH bundle/key, WHO may reach it, and WHERE it
|
|
9
|
+
* gets pushed.
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from 'node:fs';
|
|
12
|
+
import * as path from 'node:path';
|
|
13
|
+
import { pushBundleToHostAsync, listBundles, } from './secrets-client.js';
|
|
14
|
+
import { filterNamesForActiveResourceProfile, getActiveResourceProfile } from './resource-profiles.js';
|
|
15
|
+
import { isDialableDevice, loadDevicesSync } from './devices/registry.js';
|
|
16
|
+
import { sshTargetFor } from './devices/connect.js';
|
|
17
|
+
import { isHostPinned, isDevicePinned, managedKnownHostsPath } from './devices/known-hosts.js';
|
|
18
|
+
import { machineId, normalizeHost } from './session/sync/config.js';
|
|
19
|
+
import { readFleetSharedDeviceStates, updateFleetSharedDeviceStateAsync, } from './fleet-shared-state.js';
|
|
20
|
+
import { getCacheDir, getUserAgentsDir, readMeta } from './state.js';
|
|
21
|
+
import { listNativeAccounts, readSlots } from './account-registry.js';
|
|
22
|
+
import { claudeAccountTokenKey, provisionWorkerSlot, readReservedCredential } from './claude-account-token.js';
|
|
23
|
+
import { configuredDeviceRole, isHeadedDeviceRole, selfConfiguredDeviceRole } from './device-config.js';
|
|
24
|
+
import { AUTH_STORE_ALIAS, AUTH_BUNDLE_NAME, AUTH_BUNDLE_BACKEND, RESERVED_BUNDLE_NAMES, isReservedBundleName, ReservedBundleWrongBackendError, assertReservedAuthBackend, isReservedBundleBackendError, RESERVED_STORES, reservedStoreName, isReservedStoreName, assertStorableCredentialKind, inspectReservedAuthBundle, } from './reserved-stores.js';
|
|
25
|
+
// --- reserved per-harness credential stores (PHNX-3940 / PHNX-3989) --------
|
|
26
|
+
//
|
|
27
|
+
// Re-exported from the leaf module `reserved-stores.ts` rather than declared
|
|
28
|
+
// here: `claude-account-token.ts` needs these constants AND is itself a
|
|
29
|
+
// dependency of this module's fleet-sync helpers below, so declaring them
|
|
30
|
+
// here would form a real circular import (reproduced as a TDZ
|
|
31
|
+
// ReferenceError under real ESM evaluation order, outside vitest's
|
|
32
|
+
// mock-tolerant transform). Keep the public surface unchanged for existing
|
|
33
|
+
// importers of `secrets-policy.js`.
|
|
34
|
+
export { AUTH_STORE_ALIAS, AUTH_BUNDLE_NAME, AUTH_BUNDLE_BACKEND, RESERVED_BUNDLE_NAMES, isReservedBundleName, ReservedBundleWrongBackendError, assertReservedAuthBackend, isReservedBundleBackendError, RESERVED_STORES, reservedStoreName, isReservedStoreName, assertStorableCredentialKind, inspectReservedAuthBundle, };
|
|
35
|
+
// --- resource-profile -> allowedBundles (CTX-1) -----------------------------
|
|
36
|
+
//
|
|
37
|
+
// A resource profile scopes WHICH bundle names a run may reach. The standalone
|
|
38
|
+
// has no concept of a profile; agents-cli computes the allowed set from the
|
|
39
|
+
// full bundle listing and forwards it as `SecretsContext.allowedBundles` on
|
|
40
|
+
// every bounded request. Absent (no active profile) means full trust, matching
|
|
41
|
+
// the client's own documented default.
|
|
42
|
+
/** Filter `names` down to the ones the active resource profile allows for `secrets`. */
|
|
43
|
+
export function filterBundleNamesForActiveProfile(names) {
|
|
44
|
+
return filterNamesForActiveResourceProfile('secrets', names);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* `SecretsContext.allowedBundles` for the active resource profile, or
|
|
48
|
+
* `undefined` when no profile is active (full trust — the client's default).
|
|
49
|
+
* `allNames` is the caller's already-fetched full bundle listing (e.g. from
|
|
50
|
+
* `listBundlesSync()`), so this stays a pure filter with no extra round trip.
|
|
51
|
+
*/
|
|
52
|
+
export function resolveAllowedBundlesForActiveProfile(allNames) {
|
|
53
|
+
const filtered = filterBundleNamesForActiveProfile(allNames);
|
|
54
|
+
return filtered.length === allNames.length ? undefined : filtered;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The `SecretsContext` a run should pass to every bundle resolution it makes:
|
|
58
|
+
* the run's harness as the opaque `scope`, plus `allowedBundles` computed from
|
|
59
|
+
* a fresh full bundle listing when a resource profile is active (a stale
|
|
60
|
+
* listing could exclude a bundle created after the profile was set). No
|
|
61
|
+
* profile active means full trust and no extra round trip.
|
|
62
|
+
*/
|
|
63
|
+
export async function resolveSecretsContextForRun(scope) {
|
|
64
|
+
if (!getActiveResourceProfile())
|
|
65
|
+
return scope ? { scope } : undefined;
|
|
66
|
+
const allNames = (await listBundles()).map((b) => b.name);
|
|
67
|
+
return { allowedBundles: resolveAllowedBundlesForActiveProfile(allNames), scope };
|
|
68
|
+
}
|
|
69
|
+
// --- bundle@host remote-reference parsing (agents' own `--secrets` flag
|
|
70
|
+
// semantics, REMOTE-1) -------------------------------------------------------
|
|
71
|
+
/** Parse a `--secrets` flag value into its bundle name and optional `@host`. */
|
|
72
|
+
export function splitBundleRef(ref) {
|
|
73
|
+
const at = ref.indexOf('@');
|
|
74
|
+
if (at === -1)
|
|
75
|
+
return { bundle: ref };
|
|
76
|
+
const bundle = ref.slice(0, at);
|
|
77
|
+
const host = ref.slice(at + 1);
|
|
78
|
+
if (!bundle || !host) {
|
|
79
|
+
throw new Error(`Invalid remote bundle reference ${JSON.stringify(ref)}. Expected 'bundle@host'.`);
|
|
80
|
+
}
|
|
81
|
+
return { bundle, host };
|
|
82
|
+
}
|
|
83
|
+
/** A remote (bundle@host) reference does not yet support key-subset or expiry overrides. */
|
|
84
|
+
export function assertRemoteBundleFlagsUnsupported(bundleName, host, opts, flagLabels) {
|
|
85
|
+
const hasKeys = Array.isArray(opts.keys) && opts.keys.length > 0;
|
|
86
|
+
if (!hasKeys && !opts.allowExpired)
|
|
87
|
+
return;
|
|
88
|
+
throw new Error(`Bundle '${bundleName}@${host}': ${flagLabels.keysFlag} and ${flagLabels.allowExpiredFlag} are not supported for remote (bundle@host) bundles yet. ` +
|
|
89
|
+
`Drop the flag or resolve the bundle locally.`);
|
|
90
|
+
}
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Fleet sync of the reserved file-backed `auth` bundle (PHNX-2371/PHNX-3609).
|
|
93
|
+
//
|
|
94
|
+
// Every daemon publishes only a safe auth readiness verdict into its owned
|
|
95
|
+
// fleet-shared state file. A deterministic ready publisher reads those
|
|
96
|
+
// verdicts and transfers the actual bundle only to a peer reporting `missing`.
|
|
97
|
+
// Secret values never enter Git; the exceptional provisioning transfer is SSH,
|
|
98
|
+
// async and kill-bounded so it never blocks the daemon event loop.
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
/** Each import/read-back SSH operation gets this deadline plus the SSH hard-kill grace. */
|
|
101
|
+
export const AUTH_SYNC_PUSH_DEADLINE_MS = 20_000;
|
|
102
|
+
export function planAuthBundlePush(localAuthOk, localIsPublisher, devices) {
|
|
103
|
+
if (!localAuthOk) {
|
|
104
|
+
return devices.map((device) => ({ action: 'skip', device: device.name, reason: 'no local file-backed auth bundle' }));
|
|
105
|
+
}
|
|
106
|
+
if (!localIsPublisher) {
|
|
107
|
+
return devices.map((device) => ({ action: 'skip', device: device.name, reason: 'another ready device is the elected auth publisher' }));
|
|
108
|
+
}
|
|
109
|
+
return devices.map((device) => {
|
|
110
|
+
if (!device.reachable)
|
|
111
|
+
return { action: 'skip', device: device.name, reason: 'unreachable' };
|
|
112
|
+
if (!device.pinned) {
|
|
113
|
+
return { action: 'skip', device: device.name, reason: `host key not pinned; run \`agents ssh ${device.name}\` once` };
|
|
114
|
+
}
|
|
115
|
+
if (device.remoteAuth === 'ready')
|
|
116
|
+
return { action: 'skip', device: device.name, reason: 'already present' };
|
|
117
|
+
if (device.remoteAuth === 'invalid')
|
|
118
|
+
return { action: 'skip', device: device.name, reason: 'remote auth bundle uses the wrong backend' };
|
|
119
|
+
if (device.remoteAuth === 'unknown') {
|
|
120
|
+
return { action: 'skip', device: device.name, reason: 'no shared auth verdict has arrived from this peer' };
|
|
121
|
+
}
|
|
122
|
+
return { action: 'push', device: device.name };
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The one device that pushes credentials this tick. A ready HEADED device
|
|
127
|
+
* (`personal`/`desktop`) wins over any ready worker: the headed box is where
|
|
128
|
+
* tokens are minted (invariant 7), so it is the copy of record; a worker holds
|
|
129
|
+
* only what was once pushed to it. Name order breaks ties so every box elects
|
|
130
|
+
* the same publisher from the same shared verdicts. Before this rule the sort
|
|
131
|
+
* was by name alone, which elected `mac-mini` (a worker with a 6-day-old copy)
|
|
132
|
+
* over `zion`, and zion then skipped every peer as a non-publisher.
|
|
133
|
+
*/
|
|
134
|
+
export function electPublisher(ready, roleOf) {
|
|
135
|
+
const rank = (name) => (isHeadedDeviceRole(roleOf(name)) ? 0 : 1);
|
|
136
|
+
return [...ready].sort((a, b) => rank(a) - rank(b) || normalizeHost(a).localeCompare(normalizeHost(b)))[0] ?? null;
|
|
137
|
+
}
|
|
138
|
+
function defaultDevices() {
|
|
139
|
+
return Object.values(loadDevicesSync());
|
|
140
|
+
}
|
|
141
|
+
function authStatus(local) {
|
|
142
|
+
if (!local.exists)
|
|
143
|
+
return 'missing';
|
|
144
|
+
return local.ok ? 'ready' : 'invalid';
|
|
145
|
+
}
|
|
146
|
+
/** Publish only safe readiness metadata; useful immediately before a repo push. */
|
|
147
|
+
export async function publishReservedAuthVerdict(options = {}) {
|
|
148
|
+
const local = (options.inspectLocal ?? inspectReservedAuthBundle)();
|
|
149
|
+
const status = authStatus(local);
|
|
150
|
+
const device = options.localName ?? machineId();
|
|
151
|
+
try {
|
|
152
|
+
const write = await updateFleetSharedDeviceStateAsync(device, { auth: { status } }, options.userAgentsDir ?? getUserAgentsDir());
|
|
153
|
+
return { device, status, changed: write.changed, error: null };
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
return { device, status, changed: false, error: err.message };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Publish local readiness, elect one ready source, then asynchronously provision
|
|
161
|
+
* only peers whose shared verdict says the bundle is missing.
|
|
162
|
+
*/
|
|
163
|
+
export async function syncReservedAuthBundle(deps = {}) {
|
|
164
|
+
const result = { publisher: null, stateChanged: false, pushed: [], skipped: [], errors: [] };
|
|
165
|
+
const published = await publishReservedAuthVerdict(deps);
|
|
166
|
+
const localStatus = published.status;
|
|
167
|
+
const localName = published.device;
|
|
168
|
+
const localNorm = normalizeHost(localName);
|
|
169
|
+
const root = deps.userAgentsDir ?? getUserAgentsDir();
|
|
170
|
+
const devices = (deps.listDevices ?? defaultDevices)().filter((device) => normalizeHost(device.name) !== localNorm);
|
|
171
|
+
result.stateChanged = published.changed;
|
|
172
|
+
if (published.error)
|
|
173
|
+
result.errors.push({ device: localName, message: `could not publish auth verdict: ${published.error}` });
|
|
174
|
+
const read = readFleetSharedDeviceStates(root);
|
|
175
|
+
result.errors.push(...read.errors);
|
|
176
|
+
const stateByDevice = new Map(read.states.map((state) => [normalizeHost(state.device), state]));
|
|
177
|
+
const registered = new Map(devices.map((device) => [normalizeHost(device.name), device]));
|
|
178
|
+
const readyPublishers = read.states
|
|
179
|
+
.filter((state) => {
|
|
180
|
+
if (state.auth?.status !== 'ready')
|
|
181
|
+
return false;
|
|
182
|
+
if (normalizeHost(state.device) === localNorm)
|
|
183
|
+
return localStatus === 'ready';
|
|
184
|
+
const device = registered.get(normalizeHost(state.device));
|
|
185
|
+
return !!device && isDialableDevice(device);
|
|
186
|
+
})
|
|
187
|
+
.map((state) => state.device);
|
|
188
|
+
if (localStatus === 'ready' && !readyPublishers.some((name) => normalizeHost(name) === localNorm)) {
|
|
189
|
+
readyPublishers.push(localName);
|
|
190
|
+
}
|
|
191
|
+
const peerRole = deps.peerRole ?? configuredDeviceRole;
|
|
192
|
+
const selfRole = deps.selfRole ?? selfConfiguredDeviceRole;
|
|
193
|
+
result.publisher = electPublisher(readyPublishers, (name) => (normalizeHost(name) === localNorm ? selfRole() : peerRole(name)));
|
|
194
|
+
const localIsPublisher = result.publisher !== null && normalizeHost(result.publisher) === localNorm;
|
|
195
|
+
const pinned = deps.isPinned ?? ((name) => isHostPinned(name, managedKnownHostsPath()));
|
|
196
|
+
const plan = planAuthBundlePush(localStatus === 'ready', localIsPublisher, devices.map((device) => ({
|
|
197
|
+
name: device.name,
|
|
198
|
+
reachable: isDialableDevice(device),
|
|
199
|
+
pinned: isDevicePinned(device, pinned),
|
|
200
|
+
remoteAuth: stateByDevice.get(normalizeHost(device.name))?.auth?.status ?? 'unknown',
|
|
201
|
+
})));
|
|
202
|
+
const byName = new Map(devices.map((device) => [device.name, device]));
|
|
203
|
+
const push = deps.push ?? ((bundle, host) => pushBundleToHostAsync(bundle, host, {
|
|
204
|
+
remoteBackend: 'file',
|
|
205
|
+
operation: 'auth-sync',
|
|
206
|
+
agentOnly: true,
|
|
207
|
+
timeoutMs: AUTH_SYNC_PUSH_DEADLINE_MS,
|
|
208
|
+
}));
|
|
209
|
+
const targetOf = deps.sshTarget ?? sshTargetFor;
|
|
210
|
+
const outcomes = await Promise.all(plan.map(async (item) => {
|
|
211
|
+
if (item.action === 'skip')
|
|
212
|
+
return { kind: 'skip', device: item.device, message: item.reason };
|
|
213
|
+
const profile = byName.get(item.device);
|
|
214
|
+
if (!profile)
|
|
215
|
+
return { kind: 'skip', device: item.device, message: 'not in registry' };
|
|
216
|
+
try {
|
|
217
|
+
const out = await push(AUTH_STORE_ALIAS, targetOf(profile));
|
|
218
|
+
return out.ok
|
|
219
|
+
? { kind: 'pushed', device: item.device, message: out.message }
|
|
220
|
+
: { kind: 'error', device: item.device, message: out.message };
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
return { kind: 'error', device: item.device, message: err.message };
|
|
224
|
+
}
|
|
225
|
+
}));
|
|
226
|
+
for (const outcome of outcomes) {
|
|
227
|
+
if (outcome.kind === 'pushed')
|
|
228
|
+
result.pushed.push(outcome.device);
|
|
229
|
+
else if (outcome.kind === 'skip')
|
|
230
|
+
result.skipped.push({ device: outcome.device, reason: outcome.message });
|
|
231
|
+
else
|
|
232
|
+
result.errors.push({ device: outcome.device, message: outcome.message });
|
|
233
|
+
}
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Reserved-store sync, generalized to every portable account (PHNX-3940 T6).
|
|
238
|
+
//
|
|
239
|
+
// The path above pushes ONE bundle (`auth`) to peers reporting a bundle-coarse
|
|
240
|
+
// `missing` verdict. That under-pushes: a peer already holding `auth` (verdict
|
|
241
|
+
// `ready`) never receives a newly-added account's key, so a new account never
|
|
242
|
+
// propagates. It also pushes to any peer regardless of role, and a headed peer
|
|
243
|
+
// must never receive a durable key (owner invariant 7).
|
|
244
|
+
//
|
|
245
|
+
// The functions below fix both. The plan is per ACCOUNT and per KEY
|
|
246
|
+
// (`<ENV>_<accountId>`), not per bundle, and targets `role=worker` peers only.
|
|
247
|
+
//
|
|
248
|
+
// INVARIANT 1 (transport, retain nothing): materializing a worker slot happens
|
|
249
|
+
// on the box where the key LANDED (`reconcileLocalWorkerSlots` ->
|
|
250
|
+
// `provisionWorkerSlot`); the credential itself only ever moves over the
|
|
251
|
+
// existing encrypted SSH bundle push (the client's `pushBundleToHostAsync`),
|
|
252
|
+
// which writes nothing on the sender beyond its existing store. A native
|
|
253
|
+
// OAuth/session file is never transported (`fleet/auth-sync.ts`
|
|
254
|
+
// `isCredentialSafeToPropagate` stays false).
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
const EMPTY_KEY_SET = new Set();
|
|
257
|
+
/**
|
|
258
|
+
* Pure plan: for each worker peer, push each bundle it is missing at least one
|
|
259
|
+
* key of. Deterministic (peers and bundles sorted) so it pins exactly in tests.
|
|
260
|
+
* A headed peer is skipped BEFORE any key comparison -- it never receives a key.
|
|
261
|
+
*/
|
|
262
|
+
export function planReservedStoreSync(accounts, peers) {
|
|
263
|
+
const keysByBundle = new Map();
|
|
264
|
+
for (const account of accounts) {
|
|
265
|
+
let keys = keysByBundle.get(account.bundle);
|
|
266
|
+
if (!keys)
|
|
267
|
+
keysByBundle.set(account.bundle, (keys = new Set()));
|
|
268
|
+
keys.add(account.key);
|
|
269
|
+
}
|
|
270
|
+
const bundles = [...keysByBundle.keys()].sort();
|
|
271
|
+
const items = [];
|
|
272
|
+
for (const peer of [...peers].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
273
|
+
if (peer.headed) {
|
|
274
|
+
items.push({ action: 'skip', device: peer.name, reason: 'headed device receives the account row, never a durable key' });
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (!peer.reachable) {
|
|
278
|
+
items.push({ action: 'skip', device: peer.name, reason: 'unreachable' });
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (!peer.pinned) {
|
|
282
|
+
items.push({ action: 'skip', device: peer.name, reason: `host key not pinned; run \`agents ssh ${peer.name}\` once` });
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
let missingAny = false;
|
|
286
|
+
for (const bundle of bundles) {
|
|
287
|
+
const wanted = keysByBundle.get(bundle);
|
|
288
|
+
const present = peer.presentKeys[bundle] ?? EMPTY_KEY_SET;
|
|
289
|
+
const missing = [...wanted].filter((key) => !present.has(key)).sort();
|
|
290
|
+
if (missing.length > 0) {
|
|
291
|
+
items.push({ action: 'push', device: peer.name, bundle, keys: missing });
|
|
292
|
+
missingAny = true;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (!missingAny)
|
|
296
|
+
items.push({ action: 'skip', device: peer.name, reason: 'all reserved credentials present' });
|
|
297
|
+
}
|
|
298
|
+
return items;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Every portable account's durable worker credential, resolved to (bundle, key).
|
|
302
|
+
* A T1 row carries `workerCredential`; a claude row predating T1 has none, so it
|
|
303
|
+
* falls back to the legacy `auth` bundle keyed by the account email -- the
|
|
304
|
+
* legacy fallback for claude rows that predate T1. An account with no derivable
|
|
305
|
+
* durable credential (per-device harness, or a non-claude row with no
|
|
306
|
+
* `workerCredential`) is not a sync target.
|
|
307
|
+
*/
|
|
308
|
+
export function reservedSyncTargets(meta) {
|
|
309
|
+
const out = [];
|
|
310
|
+
for (const account of listNativeAccounts(meta)) {
|
|
311
|
+
const cred = account.workerCredential;
|
|
312
|
+
if (cred) {
|
|
313
|
+
out.push({ accountId: account.id, harness: account.agent, bundle: cred.bundle, key: cred.key, fingerprint: cred.mintedAt });
|
|
314
|
+
}
|
|
315
|
+
else if (account.agent === 'claude' && account.identityLabel) {
|
|
316
|
+
out.push({ accountId: account.id, harness: 'claude', bundle: AUTH_STORE_ALIAS, key: claudeAccountTokenKey(account.identityLabel), fingerprint: 'legacy' });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
// --- publisher-side delivery memo -----------------------------------------
|
|
322
|
+
// A push sends a WHOLE bundle; the receiver reports no per-key inventory today
|
|
323
|
+
// (that is T3's per-account verdict seam). So the publisher records what it has
|
|
324
|
+
// delivered to each peer, keyed by the credential's fingerprint, to answer "does
|
|
325
|
+
// the peer already have this key" without re-pushing an unchanged bundle every
|
|
326
|
+
// tick. A new account (no memo entry) or a re-mint (fingerprint change) is not a
|
|
327
|
+
// hit, so it propagates within one tick. The memo is LOCAL publisher bookkeeping,
|
|
328
|
+
// never synced.
|
|
329
|
+
function deliveryMemoPath(root = getCacheDir()) {
|
|
330
|
+
return path.join(root, 'reserved-sync-delivered.json');
|
|
331
|
+
}
|
|
332
|
+
function memoKey(peer, bundle, key) {
|
|
333
|
+
return `${normalizeHost(peer)} ${bundle} ${key}`;
|
|
334
|
+
}
|
|
335
|
+
export function readDeliveryMemo(root) {
|
|
336
|
+
try {
|
|
337
|
+
const parsed = JSON.parse(fs.readFileSync(deliveryMemoPath(root), 'utf-8'));
|
|
338
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
339
|
+
return parsed;
|
|
340
|
+
}
|
|
341
|
+
catch { /* missing/malformed → empty memo */ }
|
|
342
|
+
return {};
|
|
343
|
+
}
|
|
344
|
+
function writeDeliveryMemo(memo, root = getCacheDir()) {
|
|
345
|
+
fs.mkdirSync(root, { recursive: true });
|
|
346
|
+
fs.writeFileSync(deliveryMemoPath(root), `${JSON.stringify(memo, null, 2)}\n`, 'utf-8');
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* The keys a peer is KNOWN to hold, per bundle, from what we have delivered to it
|
|
350
|
+
* (memo, matched by fingerprint) plus the coarse legacy-`auth` verdict: a peer
|
|
351
|
+
* reporting `auth.status === 'ready'` is known to hold every legacy `auth` key.
|
|
352
|
+
* Reserved `__<harness>__` bundles have no coarse verdict, so their presence comes
|
|
353
|
+
* from the memo alone -- which is what makes a newly-added reserved account push.
|
|
354
|
+
*/
|
|
355
|
+
export function peerPresentKeys(peer, accounts, memo, legacyAuthReady) {
|
|
356
|
+
const present = {};
|
|
357
|
+
for (const account of accounts) {
|
|
358
|
+
const known = memo[memoKey(peer, account.bundle, account.key)] === account.fingerprint
|
|
359
|
+
|| (legacyAuthReady && account.bundle === AUTH_STORE_ALIAS);
|
|
360
|
+
if (!known)
|
|
361
|
+
continue;
|
|
362
|
+
(present[account.bundle] ??= new Set()).add(account.key);
|
|
363
|
+
}
|
|
364
|
+
return present;
|
|
365
|
+
}
|
|
366
|
+
function defaultHasLocalKey(bundle, key) {
|
|
367
|
+
// Reserved-store aware: readReservedCredential reads a `__<harness>__` item
|
|
368
|
+
// directly and a legacy/provider bundle through the normal resolver.
|
|
369
|
+
return readReservedCredential(bundle, key) !== null;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Push every portable account's reserved store to the worker peers that are
|
|
373
|
+
* missing it, per key and per role. Reuses the same election as
|
|
374
|
+
* {@link syncReservedAuthBundle} (one deterministic ready publisher pushes) so
|
|
375
|
+
* there is no second scheduler. The daemon runs this each tick.
|
|
376
|
+
*/
|
|
377
|
+
export async function syncReservedStores(deps = {}) {
|
|
378
|
+
const result = { publisher: null, pushed: [], skipped: [], errors: [] };
|
|
379
|
+
const localName = deps.localName ?? machineId();
|
|
380
|
+
const localNorm = normalizeHost(localName);
|
|
381
|
+
const root = deps.userAgentsDir ?? getUserAgentsDir();
|
|
382
|
+
const meta = (deps.readMetaFn ?? readMeta)();
|
|
383
|
+
const hasLocalKey = deps.hasLocalKey ?? defaultHasLocalKey;
|
|
384
|
+
const targets = reservedSyncTargets(meta).filter((t) => hasLocalKey(t.bundle, t.key));
|
|
385
|
+
const devices = (deps.listDevices ?? defaultDevices)().filter((d) => normalizeHost(d.name) !== localNorm);
|
|
386
|
+
const read = readFleetSharedDeviceStates(root);
|
|
387
|
+
result.errors.push(...read.errors);
|
|
388
|
+
const stateByDevice = new Map(read.states.map((s) => [normalizeHost(s.device), s]));
|
|
389
|
+
const registered = new Map(devices.map((d) => [normalizeHost(d.name), d]));
|
|
390
|
+
const localReady = deps.localReady ?? inspectReservedAuthBundle().ok;
|
|
391
|
+
const readyPublishers = read.states
|
|
392
|
+
.filter((s) => {
|
|
393
|
+
if (s.auth?.status !== 'ready')
|
|
394
|
+
return false;
|
|
395
|
+
if (normalizeHost(s.device) === localNorm)
|
|
396
|
+
return localReady;
|
|
397
|
+
const d = registered.get(normalizeHost(s.device));
|
|
398
|
+
return !!d && isDialableDevice(d);
|
|
399
|
+
})
|
|
400
|
+
.map((s) => s.device);
|
|
401
|
+
if (localReady && !readyPublishers.some((n) => normalizeHost(n) === localNorm))
|
|
402
|
+
readyPublishers.push(localName);
|
|
403
|
+
const peerRole = deps.peerRole ?? configuredDeviceRole;
|
|
404
|
+
const selfRole = deps.selfRole ?? selfConfiguredDeviceRole;
|
|
405
|
+
result.publisher = electPublisher(readyPublishers, (name) => (normalizeHost(name) === localNorm ? selfRole() : peerRole(name)));
|
|
406
|
+
const localIsPublisher = result.publisher !== null && normalizeHost(result.publisher) === localNorm;
|
|
407
|
+
if (targets.length === 0 || !localIsPublisher) {
|
|
408
|
+
for (const d of devices) {
|
|
409
|
+
result.skipped.push({ device: d.name, reason: targets.length === 0 ? 'no portable account with a durable credential' : 'another ready device is the elected publisher' });
|
|
410
|
+
}
|
|
411
|
+
return result;
|
|
412
|
+
}
|
|
413
|
+
const memo = readDeliveryMemo(deps.cacheDir);
|
|
414
|
+
const pinned = deps.isPinned ?? ((name) => isHostPinned(name, managedKnownHostsPath()));
|
|
415
|
+
const peers = devices.map((d) => ({
|
|
416
|
+
name: d.name,
|
|
417
|
+
headed: isHeadedDeviceRole(peerRole(d.name)),
|
|
418
|
+
reachable: isDialableDevice(d),
|
|
419
|
+
pinned: isDevicePinned(d, pinned),
|
|
420
|
+
presentKeys: peerPresentKeys(d.name, targets, memo, stateByDevice.get(normalizeHost(d.name))?.auth?.status === 'ready'),
|
|
421
|
+
}));
|
|
422
|
+
const plan = planReservedStoreSync(targets, peers);
|
|
423
|
+
const byName = new Map(devices.map((d) => [d.name, d]));
|
|
424
|
+
const push = deps.push ?? ((bundle, host) => pushBundleToHostAsync(bundle, host, {
|
|
425
|
+
remoteBackend: 'file', operation: 'reserved-sync', agentOnly: true, timeoutMs: AUTH_SYNC_PUSH_DEADLINE_MS,
|
|
426
|
+
}));
|
|
427
|
+
const sshTarget = deps.sshTarget ?? sshTargetFor;
|
|
428
|
+
for (const item of plan) {
|
|
429
|
+
if (item.action === 'skip') {
|
|
430
|
+
result.skipped.push({ device: item.device, reason: item.reason });
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
const profile = byName.get(item.device);
|
|
434
|
+
if (!profile) {
|
|
435
|
+
result.skipped.push({ device: item.device, reason: 'not in registry' });
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
try {
|
|
439
|
+
const out = await push(item.bundle, sshTarget(profile));
|
|
440
|
+
if (out.ok) {
|
|
441
|
+
result.pushed.push({ device: item.device, bundle: item.bundle, keys: item.keys });
|
|
442
|
+
for (const key of item.keys) {
|
|
443
|
+
const fp = targets.find((t) => t.bundle === item.bundle && t.key === key)?.fingerprint;
|
|
444
|
+
if (fp)
|
|
445
|
+
memo[memoKey(item.device, item.bundle, key)] = fp;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
result.errors.push({ device: item.device, message: out.message });
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
catch (err) {
|
|
453
|
+
result.errors.push({ device: item.device, message: err.message });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
writeDeliveryMemo(memo, deps.cacheDir ?? getCacheDir());
|
|
457
|
+
return result;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Worker-side: after a durable key lands, materialize a slot for each portable
|
|
461
|
+
* account whose credential is now present locally. Runs only on a NON-headed
|
|
462
|
+
* (worker or unmarked) device -- a headed device provisions its slots from an
|
|
463
|
+
* interactive native login (`accounts add`), never from an injected durable key
|
|
464
|
+
* (invariant 7). Idempotent: an account already backed by a `durable` slot is
|
|
465
|
+
* skipped.
|
|
466
|
+
*/
|
|
467
|
+
export function reconcileLocalWorkerSlots(deps = {}) {
|
|
468
|
+
const result = { provisioned: [], skipped: [], errors: [] };
|
|
469
|
+
const role = deps.selfRole ?? selfConfiguredDeviceRole();
|
|
470
|
+
if (isHeadedDeviceRole(role))
|
|
471
|
+
return result; // headed boxes provision via native login
|
|
472
|
+
const meta = (deps.readMetaFn ?? readMeta)();
|
|
473
|
+
const slots = readSlots(meta);
|
|
474
|
+
const hasLocalKey = deps.hasLocalKey ?? defaultHasLocalKey;
|
|
475
|
+
const provision = deps.provision ?? provisionWorkerSlot;
|
|
476
|
+
const byId = new Map(listNativeAccounts(meta).map((account) => [account.id, account]));
|
|
477
|
+
// Resolve each account to the one (bundle, key) the push plan uses: a T1 row's
|
|
478
|
+
// reserved `__<harness>__` key, or the legacy `auth` key by email for a claude
|
|
479
|
+
// row predating T1. Both are worker credentials this box may already hold, so
|
|
480
|
+
// both get a slot. Skipping the legacy rows here is what left every registered
|
|
481
|
+
// pre-T1 account without a slot on every worker while its token sat in `auth`:
|
|
482
|
+
// the picker then fell back to identity-less version homes and Claude showed a
|
|
483
|
+
// login screen on a headless box (the mac-mini 2026-09-06 incident).
|
|
484
|
+
for (const target of reservedSyncTargets(meta)) {
|
|
485
|
+
const account = byId.get(target.accountId);
|
|
486
|
+
if (!account)
|
|
487
|
+
continue;
|
|
488
|
+
if (!hasLocalKey(target.bundle, target.key)) {
|
|
489
|
+
result.skipped.push({ accountId: account.id, reason: 'durable key not synced yet' });
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
if (slots[account.id]?.authMode === 'durable') {
|
|
493
|
+
result.skipped.push({ accountId: account.id, reason: 'slot already provisioned' });
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
try {
|
|
497
|
+
provision(account);
|
|
498
|
+
result.provisioned.push(account.id);
|
|
499
|
+
}
|
|
500
|
+
catch (err) {
|
|
501
|
+
result.errors.push({ accountId: account.id, message: err.message });
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return result;
|
|
505
|
+
}
|