@phnx-labs/agents-cli 1.22.52 → 1.22.53
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 +146 -0
- package/README.md +1 -1
- package/dist/commands/accounts.js +1 -1
- package/dist/commands/exec.js +16 -9
- package/dist/commands/fleet-capture.js +7 -0
- package/dist/commands/focus.js +2 -0
- package/dist/commands/go.js +2 -1
- package/dist/commands/sessions-inject.js +8 -3
- package/dist/commands/sessions-picker.js +2 -1
- package/dist/commands/sessions.js +30 -20
- package/dist/commands/ssh.js +35 -12
- package/dist/commands/sync.js +44 -0
- package/dist/lib/account-registry.d.ts +15 -5
- package/dist/lib/account-registry.js +150 -50
- package/dist/lib/answer-router.js +2 -1
- 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/daemon/runner.js +10 -2
- package/dist/lib/device-config.js +3 -2
- package/dist/lib/devices/config-migration.js +147 -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/registry.d.ts +11 -5
- package/dist/lib/devices/registry.js +46 -18
- package/dist/lib/exec.d.ts +60 -30
- package/dist/lib/exec.js +65 -27
- package/dist/lib/feed/feed.d.ts +10 -2
- package/dist/lib/feed/feed.js +12 -1
- package/dist/lib/hosts/dispatch.d.ts +4 -3
- package/dist/lib/hosts/dispatch.js +12 -8
- 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/session/active.d.ts +10 -1
- package/dist/lib/session/active.js +7 -1
- 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 +1 -1
- package/dist/lib/session/db.js +39 -3
- package/dist/lib/session/discover.js +7 -12
- 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/types.d.ts +17 -0
- package/dist/lib/session/types.js +10 -0
- package/dist/lib/share/worker-template.js +12 -7
- package/dist/lib/state.d.ts +8 -0
- package/dist/lib/state.js +143 -11
- package/dist/lib/terminal/resolve.d.ts +7 -0
- package/dist/lib/terminal/resolve.js +41 -2
- 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/watchdog/runner.js +18 -4
- package/package.json +1 -1
|
@@ -2,34 +2,108 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { isDeepStrictEqual } from 'node:util';
|
|
4
4
|
import * as yaml from 'yaml';
|
|
5
|
-
import { getUserAgentsDir, readMeta, updateMeta } from '../state.js';
|
|
5
|
+
import { getUserAgentsDir, readMeta, updateMeta, withMetaLock, writeMetaUnlocked } from '../state.js';
|
|
6
6
|
function isRecord(value) {
|
|
7
7
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
8
8
|
}
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* The leftover central `browser:` tombstone from before per-device declarations.
|
|
11
|
+
* Empty when none remain — and `agents sync` now drains it automatically via
|
|
12
|
+
* {@link autoEvictCentralBrowserProfiles}, so on a synced box this is empty and
|
|
13
|
+
* {@link profileRegistry} is the single source of truth. It survives only to
|
|
14
|
+
* fold in migration and to explain an as-yet-unclaimed profile in a resolve
|
|
15
|
+
* error (a profile hostable on a peer that has not synced yet), never as a
|
|
16
|
+
* parallel store callers read for resolution.
|
|
17
|
+
*/
|
|
10
18
|
export function centralBrowserProfiles() {
|
|
11
19
|
const central = readMeta().browser;
|
|
12
20
|
if (!central || Object.keys(central).length === 0)
|
|
13
21
|
return {};
|
|
14
22
|
return { ...central };
|
|
15
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Pick the central `browser:` entries this machine may fold into its device doc.
|
|
26
|
+
*
|
|
27
|
+
* A profile is claimable only when `canHostHere` accepts it — the endpoint or
|
|
28
|
+
* binary resolves to THIS machine — so a `cdp://localhost:*` profile owned by a
|
|
29
|
+
* peer is never claimed here (the logged-out-headless-browser bug this module
|
|
30
|
+
* exists to prevent). A name already declared locally with an IDENTICAL config
|
|
31
|
+
* is folded too, which just drops the redundant central copy. A name declared
|
|
32
|
+
* locally with a DIFFERENT config is a genuine conflict: `onConflict: 'throw'`
|
|
33
|
+
* (the explicit `claim` command) surfaces it; `onConflict: 'skip'` (automatic
|
|
34
|
+
* eviction on sync) leaves it central so a stray duplicate can never wedge a
|
|
35
|
+
* sync — an operator resolves it with an explicit claim.
|
|
36
|
+
*/
|
|
37
|
+
function selectClaimableCentral(central, local, canHostHere, opts) {
|
|
38
|
+
const toClaim = {};
|
|
39
|
+
const skipped = [];
|
|
40
|
+
for (const [profileName, config] of Object.entries(central)) {
|
|
41
|
+
if (opts.name && profileName !== opts.name)
|
|
42
|
+
continue;
|
|
43
|
+
if (!canHostHere(config)) {
|
|
44
|
+
skipped.push(profileName);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const existing = local[profileName];
|
|
48
|
+
if (existing && !isDeepStrictEqual(existing, config)) {
|
|
49
|
+
if (opts.onConflict === 'throw') {
|
|
50
|
+
throw new Error(`Cannot migrate browser profile "${profileName}": central agents.yaml and this device's ` +
|
|
51
|
+
`agents.yaml declare different configurations. Resolve the duplicate before retrying.`);
|
|
52
|
+
}
|
|
53
|
+
skipped.push(profileName);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
toClaim[profileName] = config;
|
|
57
|
+
}
|
|
58
|
+
return { toClaim, skipped };
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Return `current` with `toClaim` moved out of the central `browser:` map and
|
|
62
|
+
* into this device's `deviceBrowser` doc. The central key is dropped entirely
|
|
63
|
+
* once drained, so the tombstone disappears rather than lingering as an empty
|
|
64
|
+
* map. Pure — the caller applies it under the meta lock.
|
|
65
|
+
*/
|
|
66
|
+
function buildEvictedMeta(current, toClaim) {
|
|
67
|
+
const legacy = current;
|
|
68
|
+
const remaining = {};
|
|
69
|
+
for (const [profileName, config] of Object.entries(legacy.browser ?? {})) {
|
|
70
|
+
if (!(profileName in toClaim))
|
|
71
|
+
remaining[profileName] = config;
|
|
72
|
+
}
|
|
73
|
+
const { browser: _removed, ...withoutCentralBrowser } = legacy;
|
|
74
|
+
return {
|
|
75
|
+
...withoutCentralBrowser,
|
|
76
|
+
...(Object.keys(remaining).length > 0 ? { browser: remaining } : {}),
|
|
77
|
+
deviceBrowser: { ...current.deviceBrowser, ...toClaim },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Commit a claim computed by the EXPLICIT path (`migrateCentralBrowserProfiles`).
|
|
82
|
+
* `toClaim` was selected from an unlocked snapshot — acceptable for that manual,
|
|
83
|
+
* deliberate command; the automatic path instead selects INSIDE the lock (see
|
|
84
|
+
* {@link autoEvictCentralBrowserProfiles}) so an unrelated concurrent write can
|
|
85
|
+
* never be clobbered by a stale selection.
|
|
86
|
+
*/
|
|
87
|
+
function commitCentralClaim(toClaim) {
|
|
88
|
+
updateMeta((current) => buildEvictedMeta(current, toClaim));
|
|
89
|
+
}
|
|
16
90
|
/**
|
|
17
91
|
* Fold leftover central `browser:` entries into THIS device's declaration file.
|
|
18
92
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
93
|
+
* The EXPLICIT operator action (`agents browser profiles claim`), run on the
|
|
94
|
+
* machine that actually owns the browser. It is never called implicitly from a
|
|
95
|
+
* read, and that is the point: every device can read the central map, so an
|
|
96
|
+
* implicit claim races — whichever box reads first claims the name, {@link
|
|
97
|
+
* profileKind} then reports it `identity`, and the daemon tunnels to that box,
|
|
98
|
+
* which for a fleet-wide `cdp://localhost:*` profile is a logged-out headless
|
|
99
|
+
* browser wearing a credentialed browser's name. {@link autoEvictCentralBrowserProfiles}
|
|
100
|
+
* closes that race for the automatic path (host-gated + non-throwing on sync);
|
|
101
|
+
* this one is the manual, throw-on-conflict counterpart.
|
|
26
102
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* Only profiles this machine can host are claimed; the rest stay central,
|
|
32
|
-
* undeclared, and fail loudly on resolve.
|
|
103
|
+
* `canHostHere` is supplied by the command layer so this module stays a leaf —
|
|
104
|
+
* it must not import `isProfileLaunchableHere` (that would cycle through
|
|
105
|
+
* chrome.ts → profiles.ts). Only profiles this machine can host are claimed; the
|
|
106
|
+
* rest stay central, undeclared, and fail loudly on resolve.
|
|
33
107
|
*/
|
|
34
108
|
export function migrateCentralBrowserProfiles(canHostHere, name) {
|
|
35
109
|
const meta = readMeta();
|
|
@@ -53,42 +127,64 @@ export function migrateCentralBrowserProfiles(canHostHere, name) {
|
|
|
53
127
|
`(its browser/binary isn't installed here). Run this command on the machine that has that browser.`);
|
|
54
128
|
}
|
|
55
129
|
}
|
|
56
|
-
const toClaim = {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
continue;
|
|
61
|
-
if (!canHostHere(config)) {
|
|
62
|
-
skipped.push(profileName);
|
|
63
|
-
continue;
|
|
64
|
-
}
|
|
65
|
-
const existing = local[profileName];
|
|
66
|
-
if (existing && !isDeepStrictEqual(existing, config)) {
|
|
67
|
-
throw new Error(`Cannot migrate browser profile "${profileName}": central agents.yaml and this device's ` +
|
|
68
|
-
`agents.yaml declare different configurations. Resolve the duplicate before retrying.`);
|
|
69
|
-
}
|
|
70
|
-
toClaim[profileName] = config;
|
|
71
|
-
}
|
|
130
|
+
const { toClaim, skipped } = selectClaimableCentral(central, local, canHostHere, {
|
|
131
|
+
name,
|
|
132
|
+
onConflict: 'throw',
|
|
133
|
+
});
|
|
72
134
|
const claimed = Object.keys(toClaim).sort();
|
|
73
135
|
skipped.sort();
|
|
74
136
|
if (claimed.length === 0)
|
|
75
137
|
return { claimed, skipped };
|
|
76
|
-
|
|
77
|
-
const legacy = current;
|
|
78
|
-
const remaining = {};
|
|
79
|
-
for (const [profileName, config] of Object.entries(legacy.browser ?? {})) {
|
|
80
|
-
if (!(profileName in toClaim))
|
|
81
|
-
remaining[profileName] = config;
|
|
82
|
-
}
|
|
83
|
-
const { browser: _removed, ...withoutCentralBrowser } = legacy;
|
|
84
|
-
return {
|
|
85
|
-
...withoutCentralBrowser,
|
|
86
|
-
...(Object.keys(remaining).length > 0 ? { browser: remaining } : {}),
|
|
87
|
-
deviceBrowser: { ...current.deviceBrowser, ...toClaim },
|
|
88
|
-
};
|
|
89
|
-
});
|
|
138
|
+
commitCentralClaim(toClaim);
|
|
90
139
|
return { claimed, skipped };
|
|
91
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Automatic, self-draining counterpart to {@link migrateCentralBrowserProfiles}:
|
|
143
|
+
* fold every lingering central `browser:` profile THIS machine can host into its
|
|
144
|
+
* device doc and clear it from central, so the tombstone drains itself on
|
|
145
|
+
* `agents sync` with no manual `agents browser profiles claim`.
|
|
146
|
+
*
|
|
147
|
+
* Safe to call implicitly, unlike a claim at registry-read time (which races —
|
|
148
|
+
* see the `profileRegistry does not claim central declarations` guard), but the
|
|
149
|
+
* CALLER owns one ordering invariant: invoke it AFTER the sync's repo pull. Two
|
|
150
|
+
* boxes can both host the same profile (`canHostHere` is launchability, not
|
|
151
|
+
* ownership), so a box acting on a PRE-pull view of central could re-claim a
|
|
152
|
+
* profile a peer already drained and pushed — both writes land in different
|
|
153
|
+
* device files with no git conflict, and the profile flips identity->fungible.
|
|
154
|
+
* Running post-pull means central already reflects peers' drains, so the tombstone
|
|
155
|
+
* is gone locally before this box looks. The selection is also computed INSIDE
|
|
156
|
+
* the meta lock, and the path never throws — an unhostable profile or one that
|
|
157
|
+
* conflicts with an existing local declaration is left central for an explicit
|
|
158
|
+
* claim rather than wedging the sync. After it runs, {@link profileRegistry} is
|
|
159
|
+
* the single source of truth: a claimed profile lives in the device doc alone,
|
|
160
|
+
* never double-counted across the two stores.
|
|
161
|
+
*/
|
|
162
|
+
export function autoEvictCentralBrowserProfiles(canHostHere) {
|
|
163
|
+
// Read fresh, select, and commit inside ONE meta-lock acquisition. Reading
|
|
164
|
+
// central before the lock and committing after (the manual path's shape) leaves
|
|
165
|
+
// a window where a concurrent write on THIS machine changes central/deviceBrowser
|
|
166
|
+
// between snapshot and commit, so the stale selection would be merged over the
|
|
167
|
+
// fresher value. Selecting from the state the lock just handed us closes that
|
|
168
|
+
// window. We write ONLY when something is claimed, so a no-op sync never touches
|
|
169
|
+
// any doc (writeMetaUnlocked would otherwise re-serialize the device doc every
|
|
170
|
+
// run). This mirrors updateMeta's own body, minus the unconditional write.
|
|
171
|
+
return withMetaLock(() => {
|
|
172
|
+
const meta = readMeta();
|
|
173
|
+
const central = meta.browser;
|
|
174
|
+
if (!central || Object.keys(central).length === 0)
|
|
175
|
+
return { claimed: [], skipped: [] };
|
|
176
|
+
const local = meta.deviceBrowser ?? {};
|
|
177
|
+
const { toClaim, skipped } = selectClaimableCentral(central, local, canHostHere, {
|
|
178
|
+
onConflict: 'skip',
|
|
179
|
+
});
|
|
180
|
+
const claimed = Object.keys(toClaim).sort();
|
|
181
|
+
skipped.sort();
|
|
182
|
+
if (claimed.length === 0)
|
|
183
|
+
return { claimed, skipped };
|
|
184
|
+
writeMetaUnlocked(buildEvictedMeta(meta, toClaim));
|
|
185
|
+
return { claimed, skipped };
|
|
186
|
+
});
|
|
187
|
+
}
|
|
92
188
|
/**
|
|
93
189
|
* Every profile any device declares, keyed by name to all declaring devices.
|
|
94
190
|
* Reads every `devices/<name>/agents.yaml`; declarations never overwrite.
|
|
@@ -1313,10 +1313,17 @@ async function executeJobPlaced(config, deps, attempt) {
|
|
|
1313
1313
|
// Workflows run via `agents run <workflow>` which delegates to claude under the hood.
|
|
1314
1314
|
// Use 'claude' as the effective agent for report extraction and metadata when workflow is set.
|
|
1315
1315
|
// (command jobs branched out earlier, so config.agent is set on the non-workflow path.)
|
|
1316
|
+
// Custom harness/profile name (e.g. `deepseek`) stays on harnessName; the
|
|
1317
|
+
// HOST CLI is effectiveAgent. The loop path spawns in-process via runLoop
|
|
1318
|
+
// and never re-enters `agents run`, so it must stamp harnessName itself
|
|
1319
|
+
// (PHNX-2935) — same field commands/exec.ts sets on ExecOptions.
|
|
1320
|
+
const harnessName = !config.workflow && config.agent && isCustomHarnessName(config.agent)
|
|
1321
|
+
? config.agent
|
|
1322
|
+
: undefined;
|
|
1316
1323
|
const effectiveAgent = config.workflow
|
|
1317
1324
|
? 'claude'
|
|
1318
|
-
:
|
|
1319
|
-
? readProfile(
|
|
1325
|
+
: harnessName
|
|
1326
|
+
? readProfile(harnessName).host.agent
|
|
1320
1327
|
: config.agent;
|
|
1321
1328
|
if (!dispatchesViaAgentsRun(config)) {
|
|
1322
1329
|
const { findUnifiedAccount, resolveAccountSelection, resolveCredentialAccount } = await import('../account-registry.js');
|
|
@@ -1377,6 +1384,7 @@ async function executeJobPlaced(config, deps, attempt) {
|
|
|
1377
1384
|
const spawnEnv = buildRoutineSpawnEnv(baseEnv, effectiveAgent, primaryVersion, config.timezone, overlayHome);
|
|
1378
1385
|
const execOptions = {
|
|
1379
1386
|
agent: effectiveAgent,
|
|
1387
|
+
harnessName,
|
|
1380
1388
|
// Routine-supported self-updating CLIs (Cursor/Droid) use one global
|
|
1381
1389
|
// binary; a versioned shim would point at a nonexistent isolated install.
|
|
1382
1390
|
version: isSelfUpdatingAgent(effectiveAgent) ? undefined : primaryVersion,
|
|
@@ -164,8 +164,9 @@ export const CONFIG_KEYS = [
|
|
|
164
164
|
visibility: 'machine',
|
|
165
165
|
type: 'bool',
|
|
166
166
|
defaultValue: false,
|
|
167
|
-
description: 'Whether an interactive `agents run` on this device is wrapped in the shared-socket tmux session
|
|
168
|
-
'Off, the default, spawns the agent directly
|
|
167
|
+
description: 'Whether an interactive `agents run` on this device is wrapped in the shared-socket tmux session — local runs and ' +
|
|
168
|
+
'followed `--device` runs alike (PHNX-3316). Off, the default, spawns the agent directly; a remote run left bare ' +
|
|
169
|
+
'is protected by reconnect-and-resume, not a pane. Turn it on to give every agent an addressable pane for ' +
|
|
169
170
|
'`agents message`, injection, and `agents focus` once the tmux mouse, clipboard, and scrollback behavior suits this device.',
|
|
170
171
|
},
|
|
171
172
|
{
|
|
@@ -44,6 +44,7 @@ import { META_HEADER, getDevicesAutoLaunchPath, getDevicesIgnoredPath, getDevice
|
|
|
44
44
|
import { atomicWriteFileSync } from '../fs-atomic.js';
|
|
45
45
|
import { machineId } from '../machine-id.js';
|
|
46
46
|
import { withIgnoredAdded } from './registry.js';
|
|
47
|
+
import { addIgnoredEntry } from './device-docs.js';
|
|
47
48
|
/** Read every device doc. A doc that fails to parse is loudly skipped. */
|
|
48
49
|
function readDeviceDocs(devicesRoot) {
|
|
49
50
|
const docs = [];
|
|
@@ -176,6 +177,21 @@ export function migrateDeviceConfigStores() {
|
|
|
176
177
|
const legacyIgnoredPath = getDevicesIgnoredPath();
|
|
177
178
|
const legacyIgnoredPending = fs.existsSync(legacyIgnoredPath);
|
|
178
179
|
const legacyIgnored = legacyIgnoredPending ? readLegacyIgnoredFile(legacyIgnoredPath) : null;
|
|
180
|
+
// Central shared fleet.discovery / fleet.ignored (PHNX-3315): the maps N boxes
|
|
181
|
+
// used to rewrite. Folded into THIS box's device doc below, then stripped.
|
|
182
|
+
const centralFleetState = !!(fleet &&
|
|
183
|
+
((fleet.discovery && Object.keys(fleet.discovery).length > 0) ||
|
|
184
|
+
(Array.isArray(fleet.ignored) && fleet.ignored.length > 0)));
|
|
185
|
+
// Central shared `hosts:` map (PHNX-3315): the host registry N boxes used to
|
|
186
|
+
// rewrite. Folded into THIS box's device doc below, then the central key is
|
|
187
|
+
// dropped entirely.
|
|
188
|
+
const centralHosts = readMeta().hosts;
|
|
189
|
+
const centralHostsPending = !!(centralHosts && Object.keys(centralHosts).length > 0);
|
|
190
|
+
// Central device-scoped native accounts (PHNX-3315): a native login is
|
|
191
|
+
// machine-local, so `scope:'device'` identities (and the bindings that target
|
|
192
|
+
// them) belong in this box's device doc, off the git-tracked shared file.
|
|
193
|
+
const centralAccounts = readMeta().accounts;
|
|
194
|
+
const accountsPending = !!(centralAccounts?.native && Object.values(centralAccounts.native).some((a) => a.scope === 'device'));
|
|
179
195
|
const pinsPath = getDevicePinsPath();
|
|
180
196
|
let selfPins = {};
|
|
181
197
|
try {
|
|
@@ -209,7 +225,7 @@ export function migrateDeviceConfigStores() {
|
|
|
209
225
|
: undefined;
|
|
210
226
|
const hasDestinationWork = plans.length > 0 || newDocs.length > 0 || docAgents !== undefined || docIsolated !== undefined;
|
|
211
227
|
const autoLaunchPending = fs.existsSync(autoLaunchPath);
|
|
212
|
-
if (!centralHasConfig && !hasDestinationWork && !autoLaunchPending && !legacyIgnoredPending)
|
|
228
|
+
if (!centralHasConfig && !hasDestinationWork && !autoLaunchPending && !legacyIgnoredPending && !centralFleetState && !centralHostsPending && !accountsPending)
|
|
213
229
|
return;
|
|
214
230
|
// ── 2. Destination writes FIRST (crash-safe), under the meta lock so they
|
|
215
231
|
// serialize against writeMetaUnlocked's own read-merge-write of the doc.
|
|
@@ -302,6 +318,136 @@ export function migrateDeviceConfigStores() {
|
|
|
302
318
|
console.error(`device config migration: could not remove ${legacyIgnoredPath} (${err.message}); a later run retries`);
|
|
303
319
|
}
|
|
304
320
|
}
|
|
321
|
+
// ── 5. Central fleet.discovery / fleet.ignored → THIS box's device doc ─────
|
|
322
|
+
// (PHNX-3315) The shared maps every box used to rewrite fold into this box's
|
|
323
|
+
// deviceFleet, then are stripped from central. writeMetaUnlocked writes the
|
|
324
|
+
// device doc BEFORE the central strip, so a crash re-folds on the next run and
|
|
325
|
+
// the union-dedup (ignored beats approved; newest ignoredAt wins) makes the
|
|
326
|
+
// re-fold a no-op. Idempotent — once central holds neither key the gather
|
|
327
|
+
// guard above skips this whole pass.
|
|
328
|
+
if (centralFleetState) {
|
|
329
|
+
updateMeta((m) => {
|
|
330
|
+
const disc = m.fleet?.discovery;
|
|
331
|
+
const ign = m.fleet?.ignored;
|
|
332
|
+
const hasDisc = !!disc && Object.keys(disc).length > 0;
|
|
333
|
+
const hasIgn = Array.isArray(ign) && ign.length > 0;
|
|
334
|
+
if (!hasDisc && !hasIgn)
|
|
335
|
+
return m;
|
|
336
|
+
const discovery = { ...m.deviceFleet?.discovery };
|
|
337
|
+
if (hasDisc) {
|
|
338
|
+
for (const [name, status] of Object.entries(disc)) {
|
|
339
|
+
if (status !== 'approved' && status !== 'ignored')
|
|
340
|
+
continue;
|
|
341
|
+
if (discovery[name] === 'ignored')
|
|
342
|
+
continue; // ignored is never downgraded
|
|
343
|
+
discovery[name] = status;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const byName = new Map();
|
|
347
|
+
for (const e of m.deviceFleet?.ignored ?? [])
|
|
348
|
+
addIgnoredEntry(byName, e);
|
|
349
|
+
if (hasIgn)
|
|
350
|
+
for (const e of ign)
|
|
351
|
+
addIgnoredEntry(byName, e);
|
|
352
|
+
const ignored = [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
353
|
+
const deviceFleet = {
|
|
354
|
+
...(Object.keys(discovery).length > 0 ? { discovery } : {}),
|
|
355
|
+
...(ignored.length > 0 ? { ignored } : {}),
|
|
356
|
+
};
|
|
357
|
+
const fleet = m.fleet ? { ...m.fleet } : undefined;
|
|
358
|
+
if (fleet) {
|
|
359
|
+
delete fleet.discovery;
|
|
360
|
+
delete fleet.ignored;
|
|
361
|
+
}
|
|
362
|
+
// Drop an emptied fleet block entirely (mirrors step 3a's guard) so the
|
|
363
|
+
// strip does not leave a bare `fleet: { devices: {} }` behind.
|
|
364
|
+
const fleetEmpty = !fleet ||
|
|
365
|
+
((fleet.devices === undefined ||
|
|
366
|
+
(fleet.devices !== 'all' && Object.keys(fleet.devices).length === 0)) &&
|
|
367
|
+
!fleet.defaults &&
|
|
368
|
+
!fleet.secrets &&
|
|
369
|
+
!fleet.routines);
|
|
370
|
+
if (fleetEmpty) {
|
|
371
|
+
const { fleet: _drop, ...rest } = m;
|
|
372
|
+
void _drop;
|
|
373
|
+
return { ...rest, deviceFleet };
|
|
374
|
+
}
|
|
375
|
+
return { ...m, deviceFleet, fleet };
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
// ── 6. Central hosts map → THIS box's device doc (PHNX-3315) ───────────────
|
|
379
|
+
// The shared host registry folds into this box's deviceHosts, then the central
|
|
380
|
+
// key is dropped. writeMetaUnlocked writes the device doc before the central
|
|
381
|
+
// strip, so a crash re-folds and the newest-addedAt union makes it a no-op.
|
|
382
|
+
// Idempotent — once central holds no `hosts:` the gather guard skips this.
|
|
383
|
+
if (centralHostsPending) {
|
|
384
|
+
updateMeta((m) => {
|
|
385
|
+
const hosts = m.hosts;
|
|
386
|
+
if (!hosts || Object.keys(hosts).length === 0)
|
|
387
|
+
return m;
|
|
388
|
+
// This box's own device-doc entries win over the shared legacy on a name
|
|
389
|
+
// collision (a re-fold after a crash must not clobber a fresher local edit).
|
|
390
|
+
const deviceHosts = { ...hosts, ...m.deviceHosts };
|
|
391
|
+
const { hosts: _drop, ...rest } = m;
|
|
392
|
+
void _drop;
|
|
393
|
+
return { ...rest, deviceHosts };
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
// ── 7. Central device-scoped native accounts → THIS box's device doc ───────
|
|
397
|
+
// (PHNX-3315) Fold `scope:'device'` natives and the bindings that target them
|
|
398
|
+
// out of central and into the device doc, removing their identity PII from the
|
|
399
|
+
// git-tracked shared file. The selection is recomputed INSIDE the lock so a
|
|
400
|
+
// concurrent write is never clobbered; the device-doc write precedes the
|
|
401
|
+
// central strip, so a crash re-folds and the id-keyed merge makes it a no-op.
|
|
402
|
+
if (accountsPending) {
|
|
403
|
+
updateMeta((m) => {
|
|
404
|
+
const native = { ...m.accounts?.native };
|
|
405
|
+
const bindings = { ...m.accounts?.bindings };
|
|
406
|
+
const devNative = { ...m.deviceAccounts?.native };
|
|
407
|
+
const devBindings = { ...m.deviceAccounts?.bindings };
|
|
408
|
+
const movedIds = new Set();
|
|
409
|
+
let changed = false;
|
|
410
|
+
for (const [id, entry] of Object.entries(native)) {
|
|
411
|
+
if (entry.scope === 'device') {
|
|
412
|
+
devNative[id] = entry;
|
|
413
|
+
movedIds.add(id);
|
|
414
|
+
delete native[id];
|
|
415
|
+
changed = true;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
for (const [target, id] of Object.entries(bindings)) {
|
|
419
|
+
if (movedIds.has(id)) {
|
|
420
|
+
devBindings[target] = id;
|
|
421
|
+
delete bindings[target];
|
|
422
|
+
changed = true;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (!changed)
|
|
426
|
+
return m;
|
|
427
|
+
const deviceAccounts = {
|
|
428
|
+
...(Object.keys(devNative).length > 0 ? { native: devNative } : {}),
|
|
429
|
+
...(Object.keys(devBindings).length > 0 ? { bindings: devBindings } : {}),
|
|
430
|
+
};
|
|
431
|
+
const accounts = { ...m.accounts };
|
|
432
|
+
if (Object.keys(native).length > 0)
|
|
433
|
+
accounts.native = native;
|
|
434
|
+
else
|
|
435
|
+
delete accounts.native;
|
|
436
|
+
if (Object.keys(bindings).length > 0)
|
|
437
|
+
accounts.bindings = bindings;
|
|
438
|
+
else
|
|
439
|
+
delete accounts.bindings;
|
|
440
|
+
// Drop the whole central `accounts` block only when nothing fleet-shared
|
|
441
|
+
// remains (no version natives, no bindings, no defaults).
|
|
442
|
+
const accountsEmpty = !accounts.native && !accounts.bindings && !accounts.defaults;
|
|
443
|
+
if (accountsEmpty) {
|
|
444
|
+
const { accounts: _drop, ...rest } = m;
|
|
445
|
+
void _drop;
|
|
446
|
+
return { ...rest, deviceAccounts };
|
|
447
|
+
}
|
|
448
|
+
return { ...m, accounts, deviceAccounts };
|
|
449
|
+
});
|
|
450
|
+
}
|
|
305
451
|
}
|
|
306
452
|
/** Shallow-set equality for folded doc content (values compared deep via JSON). */
|
|
307
453
|
function sameDocContent(a, b) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { HostEntry } from '../types.js';
|
|
2
|
+
import type { IgnoredDeviceEntry } from '../fleet/types.js';
|
|
3
|
+
/** One parsed device doc under `~/.agents/devices/`. */
|
|
4
|
+
export interface DeviceDoc {
|
|
5
|
+
device: string;
|
|
6
|
+
doc: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Every device doc, sorted by device name so the union is order-independent. A
|
|
10
|
+
* corrupt or non-map doc throws (loud), never silently skipped — a dropped doc
|
|
11
|
+
* would let the next writer clobber that peer's slice.
|
|
12
|
+
*/
|
|
13
|
+
export declare function readAllDeviceDocs(): DeviceDoc[];
|
|
14
|
+
/**
|
|
15
|
+
* Union the per-box `fleet.discovery` maps. Precedence for a name declared by
|
|
16
|
+
* more than one box is deterministic and order-independent: `ignored` beats
|
|
17
|
+
* `approved` (a dismissal on any box wins), so every box computes the identical
|
|
18
|
+
* effective policy regardless of walk order.
|
|
19
|
+
*/
|
|
20
|
+
export declare function unionDeviceDiscovery(docs?: DeviceDoc[]): Record<string, 'approved' | 'ignored'>;
|
|
21
|
+
/**
|
|
22
|
+
* Union the per-box `fleet.ignored` dismissal lists by node name. When two boxes
|
|
23
|
+
* dismissed the same node, the entry with the newest `ignoredAt` wins (ties
|
|
24
|
+
* broken by `ignoredOn`), so the attribution is deterministic and
|
|
25
|
+
* order-independent. Sorted by name to match the central writer's ordering.
|
|
26
|
+
*/
|
|
27
|
+
export declare function unionDeviceIgnored(docs?: DeviceDoc[]): IgnoredDeviceEntry[];
|
|
28
|
+
/** Insert/replace a dismissal, newest ignoredAt (then ignoredOn) winning. */
|
|
29
|
+
export declare function addIgnoredEntry(byName: Map<string, IgnoredDeviceEntry>, entry: IgnoredDeviceEntry): void;
|
|
30
|
+
/**
|
|
31
|
+
* Union the per-box `hosts:` registries by host name. When two boxes registered
|
|
32
|
+
* the same name, the newest `addedAt` wins (ties broken by device name), so the
|
|
33
|
+
* merged directory is deterministic. Entries missing `addedAt` sort oldest.
|
|
34
|
+
*/
|
|
35
|
+
export declare function unionDeviceHosts(docs?: DeviceDoc[]): Record<string, HostEntry>;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Union-on-read across every tracked device document (PHNX-3315).
|
|
3
|
+
*
|
|
4
|
+
* Phase 2 device-scopes three writers that used to rewrite one fleet-shared
|
|
5
|
+
* block in the central `agents.yaml` (fleet discovery/dismissals, the host
|
|
6
|
+
* registry, and device-scoped accounts). Each box now records only its OWN
|
|
7
|
+
* decisions in `~/.agents/devices/<machine>/agents.yaml`, so pulls never
|
|
8
|
+
* conflict — and the effective fleet view is recomputed here as a deterministic,
|
|
9
|
+
* order-independent UNION across every device doc, exactly like the browser
|
|
10
|
+
* profile registry (`lib/browser/registry.ts` `profileRegistry`).
|
|
11
|
+
*
|
|
12
|
+
* These readers walk the device docs ONLY. The central-legacy values (present
|
|
13
|
+
* until the fold-then-delete migration drains them) are merged in by each
|
|
14
|
+
* caller, so a value mid-migration is never lost. A doc that fails to parse is a
|
|
15
|
+
* hard error — silently returning an empty union would let a later write wipe a
|
|
16
|
+
* peer's decision, the same corruption contract `routine-activation.ts` and the
|
|
17
|
+
* device registry keep.
|
|
18
|
+
*/
|
|
19
|
+
import * as fs from 'node:fs';
|
|
20
|
+
import * as path from 'node:path';
|
|
21
|
+
import * as yaml from 'yaml';
|
|
22
|
+
import { getUserAgentsDir } from '../state.js';
|
|
23
|
+
function isRecord(value) {
|
|
24
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Every device doc, sorted by device name so the union is order-independent. A
|
|
28
|
+
* corrupt or non-map doc throws (loud), never silently skipped — a dropped doc
|
|
29
|
+
* would let the next writer clobber that peer's slice.
|
|
30
|
+
*/
|
|
31
|
+
export function readAllDeviceDocs() {
|
|
32
|
+
const devicesDir = path.join(getUserAgentsDir(), 'devices');
|
|
33
|
+
if (!fs.existsSync(devicesDir))
|
|
34
|
+
return [];
|
|
35
|
+
const out = [];
|
|
36
|
+
const names = fs
|
|
37
|
+
.readdirSync(devicesDir, { withFileTypes: true })
|
|
38
|
+
.filter((entry) => entry.isDirectory())
|
|
39
|
+
.map((entry) => entry.name)
|
|
40
|
+
.sort();
|
|
41
|
+
for (const device of names) {
|
|
42
|
+
const file = path.join(devicesDir, device, 'agents.yaml');
|
|
43
|
+
if (!fs.existsSync(file))
|
|
44
|
+
continue;
|
|
45
|
+
let parsed;
|
|
46
|
+
try {
|
|
47
|
+
parsed = yaml.parse(fs.readFileSync(file, 'utf8'));
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
throw new Error(`Device config corrupted at ${file}: ${err.message}. Inspect and restore from backup.`);
|
|
51
|
+
}
|
|
52
|
+
if (parsed == null)
|
|
53
|
+
continue;
|
|
54
|
+
if (!isRecord(parsed)) {
|
|
55
|
+
throw new Error(`Device config corrupted at ${file}: document root must be a map.`);
|
|
56
|
+
}
|
|
57
|
+
out.push({ device, doc: parsed });
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Union the per-box `fleet.discovery` maps. Precedence for a name declared by
|
|
63
|
+
* more than one box is deterministic and order-independent: `ignored` beats
|
|
64
|
+
* `approved` (a dismissal on any box wins), so every box computes the identical
|
|
65
|
+
* effective policy regardless of walk order.
|
|
66
|
+
*/
|
|
67
|
+
export function unionDeviceDiscovery(docs = readAllDeviceDocs()) {
|
|
68
|
+
const out = {};
|
|
69
|
+
for (const { device, doc } of docs) {
|
|
70
|
+
const fleet = doc.fleet;
|
|
71
|
+
if (fleet === undefined)
|
|
72
|
+
continue;
|
|
73
|
+
if (!isRecord(fleet))
|
|
74
|
+
throw new Error(`Device config corrupted at devices/${device}/agents.yaml: fleet must be a map.`);
|
|
75
|
+
const discovery = fleet.discovery;
|
|
76
|
+
if (discovery === undefined)
|
|
77
|
+
continue;
|
|
78
|
+
if (!isRecord(discovery))
|
|
79
|
+
throw new Error(`Device config corrupted at devices/${device}/agents.yaml: fleet.discovery must be a map.`);
|
|
80
|
+
for (const [name, status] of Object.entries(discovery)) {
|
|
81
|
+
if (status !== 'approved' && status !== 'ignored') {
|
|
82
|
+
throw new Error(`Device discovery policy for '${name}' in devices/${device}/agents.yaml must be approved or ignored.`);
|
|
83
|
+
}
|
|
84
|
+
// ignored beats approved; once ignored, never downgraded by another box.
|
|
85
|
+
if (out[name] === 'ignored')
|
|
86
|
+
continue;
|
|
87
|
+
out[name] = status;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Union the per-box `fleet.ignored` dismissal lists by node name. When two boxes
|
|
94
|
+
* dismissed the same node, the entry with the newest `ignoredAt` wins (ties
|
|
95
|
+
* broken by `ignoredOn`), so the attribution is deterministic and
|
|
96
|
+
* order-independent. Sorted by name to match the central writer's ordering.
|
|
97
|
+
*/
|
|
98
|
+
export function unionDeviceIgnored(docs = readAllDeviceDocs()) {
|
|
99
|
+
const byName = new Map();
|
|
100
|
+
for (const { device, doc } of docs) {
|
|
101
|
+
const fleet = doc.fleet;
|
|
102
|
+
if (fleet === undefined)
|
|
103
|
+
continue;
|
|
104
|
+
if (!isRecord(fleet))
|
|
105
|
+
throw new Error(`Device config corrupted at devices/${device}/agents.yaml: fleet must be a map.`);
|
|
106
|
+
const ignored = fleet.ignored;
|
|
107
|
+
if (ignored === undefined)
|
|
108
|
+
continue;
|
|
109
|
+
if (!Array.isArray(ignored))
|
|
110
|
+
throw new Error(`Device config corrupted at devices/${device}/agents.yaml: fleet.ignored must be a list.`);
|
|
111
|
+
for (const raw of ignored) {
|
|
112
|
+
if (!raw || typeof raw.name !== 'string' || typeof raw.ignoredAt !== 'string' || typeof raw.ignoredOn !== 'string') {
|
|
113
|
+
throw new Error(`Device config corrupted at devices/${device}/agents.yaml: fleet.ignored entries must be { name, ignoredAt, ignoredOn }.`);
|
|
114
|
+
}
|
|
115
|
+
addIgnoredEntry(byName, raw);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
119
|
+
}
|
|
120
|
+
/** Insert/replace a dismissal, newest ignoredAt (then ignoredOn) winning. */
|
|
121
|
+
export function addIgnoredEntry(byName, entry) {
|
|
122
|
+
const prev = byName.get(entry.name);
|
|
123
|
+
if (!prev) {
|
|
124
|
+
byName.set(entry.name, entry);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const at = Date.parse(entry.ignoredAt);
|
|
128
|
+
const prevAt = Date.parse(prev.ignoredAt);
|
|
129
|
+
if (at > prevAt || (at === prevAt && entry.ignoredOn.localeCompare(prev.ignoredOn) > 0)) {
|
|
130
|
+
byName.set(entry.name, entry);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Union the per-box `hosts:` registries by host name. When two boxes registered
|
|
135
|
+
* the same name, the newest `addedAt` wins (ties broken by device name), so the
|
|
136
|
+
* merged directory is deterministic. Entries missing `addedAt` sort oldest.
|
|
137
|
+
*/
|
|
138
|
+
export function unionDeviceHosts(docs = readAllDeviceDocs()) {
|
|
139
|
+
const out = {};
|
|
140
|
+
const wonAt = new Map();
|
|
141
|
+
for (const { device, doc } of docs) {
|
|
142
|
+
const hosts = doc.hosts;
|
|
143
|
+
if (hosts === undefined)
|
|
144
|
+
continue;
|
|
145
|
+
if (!isRecord(hosts))
|
|
146
|
+
throw new Error(`Device config corrupted at devices/${device}/agents.yaml: hosts must be a map.`);
|
|
147
|
+
for (const [name, entry] of Object.entries(hosts)) {
|
|
148
|
+
if (!isRecord(entry))
|
|
149
|
+
throw new Error(`Device config corrupted at devices/${device}/agents.yaml: host '${name}' must be a map.`);
|
|
150
|
+
const at = typeof entry.addedAt === 'string' ? Date.parse(entry.addedAt) : 0;
|
|
151
|
+
const prev = wonAt.get(name);
|
|
152
|
+
if (prev === undefined || at > prev) {
|
|
153
|
+
out[name] = entry;
|
|
154
|
+
wonAt.set(name, Number.isNaN(at) ? 0 : at);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
// Native accounts and their bindings are machine-local (a native login lives in
|
|
161
|
+
// the harness home on ONE box), so account-registry.ts reads THIS box's own
|
|
162
|
+
// slice (meta.deviceAccounts) merged with the fleet-shared central store rather
|
|
163
|
+
// than a cross-box union — there is deliberately no unionDeviceAccounts here.
|
|
@@ -7,9 +7,21 @@ export interface DeviceDiscoveryReconcileResult {
|
|
|
7
7
|
}
|
|
8
8
|
/** Read one portable decision. Absence means pending. */
|
|
9
9
|
export declare function getDeviceDiscoveryStatus(name: string): DeviceDiscoveryStatus | undefined;
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Persist ONE discovery decision in THIS box's device doc (PHNX-3315). Each box
|
|
12
|
+
* records only its own choices in `devices/<machine>/agents.yaml` `fleet.discovery`,
|
|
13
|
+
* so N boxes no longer rewrite one shared central map (the guaranteed pull
|
|
14
|
+
* conflict). The effective policy is the union across every box
|
|
15
|
+
* ({@link loadDeviceDiscoveryPolicies}).
|
|
16
|
+
*/
|
|
11
17
|
export declare function setDeviceDiscoveryStatus(name: string, status: DeviceDiscoveryStatus | undefined): void;
|
|
12
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* The effective discovery policy: the UNION across every box's device doc, plus
|
|
20
|
+
* any lingering central-legacy map (drained by the fold-then-delete migration).
|
|
21
|
+
* Precedence for a name declared by more than one box is deterministic and
|
|
22
|
+
* order-independent — `ignored` beats `approved` — so every box computes the
|
|
23
|
+
* identical policy. Absence means pending.
|
|
24
|
+
*/
|
|
13
25
|
export declare function loadDeviceDiscoveryPolicies(): Map<string, DeviceDiscoveryStatus>;
|
|
14
26
|
/**
|
|
15
27
|
* Apply synced intent to this machine's local registry. Approval resolves live
|