@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
|
@@ -223,9 +223,9 @@ export declare function isDaemonEnabled(): boolean;
|
|
|
223
223
|
/**
|
|
224
224
|
* Throw when the daemon is disabled on this machine, naming the setting and
|
|
225
225
|
* the fix. Every AUTO-start surface (routines add/start/catchup/webhook,
|
|
226
|
-
* `ensureDaemonStarted`) refuses with this before calling
|
|
227
|
-
* `agents daemon start` is the deliberate override and does
|
|
228
|
-
* disable only blocks auto-start, mirroring `systemctl disable`.
|
|
226
|
+
* monitors add, `ensureDaemonStarted`) refuses with this before calling
|
|
227
|
+
* `startDaemon()`. `agents daemon start` is the deliberate override and does
|
|
228
|
+
* NOT call this — disable only blocks auto-start, mirroring `systemctl disable`.
|
|
229
229
|
*/
|
|
230
230
|
export declare function assertDaemonEnabled(): void;
|
|
231
231
|
/**
|
|
@@ -145,7 +145,7 @@ export const CONFIG_KEYS = [
|
|
|
145
145
|
defaultValue: true,
|
|
146
146
|
description: 'Whether the daemon may run on this device at all (secrets broker, browser IPC, watchdog, and the ' +
|
|
147
147
|
'routines scheduler). Disabling is the top-level kill switch: nothing auto-starts the daemon while it ' +
|
|
148
|
-
'is set, including `routines add`/`routines start`/`routines catchup`/webhook triggers. ' +
|
|
148
|
+
'is set, including `routines add`/`routines start`/`routines catchup`/`monitors add`/webhook triggers. ' +
|
|
149
149
|
'`agents daemon start` still starts it explicitly.',
|
|
150
150
|
},
|
|
151
151
|
{
|
|
@@ -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
|
{
|
|
@@ -175,7 +176,7 @@ export const CONFIG_KEYS = [
|
|
|
175
176
|
visibility: 'machine',
|
|
176
177
|
type: 'bool',
|
|
177
178
|
defaultValue: false,
|
|
178
|
-
description: "Whether other fleet machines may drive THIS device's browser over `browser --device <this-device
|
|
179
|
+
description: "Whether other fleet machines may drive THIS device's browser over `browser --device <this-device>` or `agents ssh <this-device> …` (`agents browser` / `ag browser` / standalone `browser`). " +
|
|
179
180
|
'Default off — a fleet-remote drive is refused until the owner runs `agents browser remote-control on`.',
|
|
180
181
|
},
|
|
181
182
|
{
|
|
@@ -812,9 +813,9 @@ export function isDaemonEnabled() {
|
|
|
812
813
|
/**
|
|
813
814
|
* Throw when the daemon is disabled on this machine, naming the setting and
|
|
814
815
|
* the fix. Every AUTO-start surface (routines add/start/catchup/webhook,
|
|
815
|
-
* `ensureDaemonStarted`) refuses with this before calling
|
|
816
|
-
* `agents daemon start` is the deliberate override and does
|
|
817
|
-
* disable only blocks auto-start, mirroring `systemctl disable`.
|
|
816
|
+
* monitors add, `ensureDaemonStarted`) refuses with this before calling
|
|
817
|
+
* `startDaemon()`. `agents daemon start` is the deliberate override and does
|
|
818
|
+
* NOT call this — disable only blocks auto-start, mirroring `systemctl disable`.
|
|
818
819
|
*/
|
|
819
820
|
export function assertDaemonEnabled() {
|
|
820
821
|
if (isDaemonEnabled())
|
|
@@ -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) {
|
|
@@ -37,6 +37,26 @@ export declare function fleetDialTarget(device: DeviceProfile): string;
|
|
|
37
37
|
* login).
|
|
38
38
|
*/
|
|
39
39
|
export declare function wrapRemoteCommand(device: DeviceProfile, cmd: string[]): string | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* True when `cmd` is a browser drive: `agents browser …`, `ag browser …`, or
|
|
42
|
+
* the standalone `browser` binary (`cli/package.json` `bin.browser` →
|
|
43
|
+
* `src/browser.ts`), including the quoted single-string form
|
|
44
|
+
* `agents ssh box 'browser …'`. The `--device` passthrough stamps
|
|
45
|
+
* {@link markFleetRemote} on this shape so the far-side consent gate can fire;
|
|
46
|
+
* `agents ssh` must too (PHNX-3065).
|
|
47
|
+
*/
|
|
48
|
+
export declare function isAgentsBrowserDrive(cmd: string[]): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Prefix a remote command so the far side sees `AGENTS_FLEET_REMOTE=1` — the
|
|
51
|
+
* marker the browser consent gate reads. `wrapRemoteCommand` joins the argv
|
|
52
|
+
* with spaces (POSIX) or base64-encodes it for PowerShell, so a
|
|
53
|
+
* shell-appropriate leading token rides through both: `env VAR=1 …` on POSIX,
|
|
54
|
+
* `$env:VAR='1'; …` on PowerShell.
|
|
55
|
+
*
|
|
56
|
+
* Already-marked argv (the `--device` fan-out stamps this before
|
|
57
|
+
* {@link buildSshInvocation}) is left unchanged so the prefix is not doubled.
|
|
58
|
+
*/
|
|
59
|
+
export declare function markFleetRemote(cmd: string[], device: Pick<DeviceProfile, 'shell'>): string[];
|
|
40
60
|
/**
|
|
41
61
|
* Build the remote command that starts an INTERACTIVE LOGIN shell inside a
|
|
42
62
|
* mirrored project directory, falling back to the remote home when that
|
|
@@ -97,6 +117,12 @@ export declare function deviceIdentityArgs(device: DeviceProfile): string[];
|
|
|
97
117
|
* to the remote home), matching `agents run --device`. It is ignored when a `cmd`
|
|
98
118
|
* is given: an explicit command keeps its current cwd (the remote home) and its
|
|
99
119
|
* behavior unchanged (RUSH-2412).
|
|
120
|
+
*
|
|
121
|
+
* An `agents browser …` / `ag browser …` / standalone `browser …` command is
|
|
122
|
+
* prefixed with {@link markFleetRemote} so the far-side consent gate sees
|
|
123
|
+
* `AGENTS_FLEET_REMOTE=1` the same way `browser --device` does. The local `env`
|
|
124
|
+
* overlay here is for the ssh *client* (askpass); it cannot carry the marker —
|
|
125
|
+
* OpenSSH does not forward arbitrary env (PHNX-3065).
|
|
100
126
|
*/
|
|
101
127
|
export declare function buildSshInvocation(device: DeviceProfile, cmd: string[], askpassShimPath: string, hostKey?: SshHostKeyOptions, opts?: {
|
|
102
128
|
agentOnly?: boolean;
|
|
@@ -86,6 +86,43 @@ export function wrapRemoteCommand(device, cmd) {
|
|
|
86
86
|
}
|
|
87
87
|
return joined;
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* True when `cmd` is a browser drive: `agents browser …`, `ag browser …`, or
|
|
91
|
+
* the standalone `browser` binary (`cli/package.json` `bin.browser` →
|
|
92
|
+
* `src/browser.ts`), including the quoted single-string form
|
|
93
|
+
* `agents ssh box 'browser …'`. The `--device` passthrough stamps
|
|
94
|
+
* {@link markFleetRemote} on this shape so the far-side consent gate can fire;
|
|
95
|
+
* `agents ssh` must too (PHNX-3065).
|
|
96
|
+
*/
|
|
97
|
+
export function isAgentsBrowserDrive(cmd) {
|
|
98
|
+
if (cmd.length === 0)
|
|
99
|
+
return false;
|
|
100
|
+
const tokens = cmd.length === 1 && /\s/.test(cmd[0]) ? cmd[0].trim().split(/\s+/) : cmd;
|
|
101
|
+
const bin = tokens[0];
|
|
102
|
+
if (bin === 'browser')
|
|
103
|
+
return true;
|
|
104
|
+
return (bin === 'agents' || bin === 'ag') && tokens[1] === 'browser';
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Prefix a remote command so the far side sees `AGENTS_FLEET_REMOTE=1` — the
|
|
108
|
+
* marker the browser consent gate reads. `wrapRemoteCommand` joins the argv
|
|
109
|
+
* with spaces (POSIX) or base64-encodes it for PowerShell, so a
|
|
110
|
+
* shell-appropriate leading token rides through both: `env VAR=1 …` on POSIX,
|
|
111
|
+
* `$env:VAR='1'; …` on PowerShell.
|
|
112
|
+
*
|
|
113
|
+
* Already-marked argv (the `--device` fan-out stamps this before
|
|
114
|
+
* {@link buildSshInvocation}) is left unchanged so the prefix is not doubled.
|
|
115
|
+
*/
|
|
116
|
+
export function markFleetRemote(cmd, device) {
|
|
117
|
+
if (device.shell === 'powershell') {
|
|
118
|
+
return cmd[0] === `$env:AGENTS_FLEET_REMOTE='1';`
|
|
119
|
+
? cmd
|
|
120
|
+
: [`$env:AGENTS_FLEET_REMOTE='1';`, ...cmd];
|
|
121
|
+
}
|
|
122
|
+
return cmd[0] === 'env' && cmd[1] === 'AGENTS_FLEET_REMOTE=1'
|
|
123
|
+
? cmd
|
|
124
|
+
: ['env', 'AGENTS_FLEET_REMOTE=1', ...cmd];
|
|
125
|
+
}
|
|
89
126
|
/**
|
|
90
127
|
* Build the remote command that starts an INTERACTIVE LOGIN shell inside a
|
|
91
128
|
* mirrored project directory, falling back to the remote home when that
|
|
@@ -156,6 +193,12 @@ export function deviceIdentityArgs(device) {
|
|
|
156
193
|
* to the remote home), matching `agents run --device`. It is ignored when a `cmd`
|
|
157
194
|
* is given: an explicit command keeps its current cwd (the remote home) and its
|
|
158
195
|
* behavior unchanged (RUSH-2412).
|
|
196
|
+
*
|
|
197
|
+
* An `agents browser …` / `ag browser …` / standalone `browser …` command is
|
|
198
|
+
* prefixed with {@link markFleetRemote} so the far-side consent gate sees
|
|
199
|
+
* `AGENTS_FLEET_REMOTE=1` the same way `browser --device` does. The local `env`
|
|
200
|
+
* overlay here is for the ssh *client* (askpass); it cannot carry the marker —
|
|
201
|
+
* OpenSSH does not forward arbitrary env (PHNX-3065).
|
|
159
202
|
*/
|
|
160
203
|
export function buildSshInvocation(device, cmd, askpassShimPath, hostKey = {}, opts = {}) {
|
|
161
204
|
// The effective profile: central config (ssh.*/platform/user) overlaid on
|
|
@@ -166,9 +209,13 @@ export function buildSshInvocation(device, cmd, askpassShimPath, hostKey = {}, o
|
|
|
166
209
|
// wrapper (interactiveCwd), which is an interactive login too and still needs
|
|
167
210
|
// a real tty below.
|
|
168
211
|
const interactive = cmd.length === 0;
|
|
212
|
+
// Stamp the consent marker on the REMOTE command, not the local ssh env:
|
|
213
|
+
// SSH_ASKPASS lives on this side; AGENTS_FLEET_REMOTE must be visible to the
|
|
214
|
+
// process that runs on the peer.
|
|
215
|
+
const remoteCmd = !interactive && isAgentsBrowserDrive(cmd) ? markFleetRemote(cmd, device) : cmd;
|
|
169
216
|
const remote = interactive
|
|
170
217
|
? buildInteractiveShellCommand(device, opts.interactiveCwd)
|
|
171
|
-
: wrapRemoteCommand(device,
|
|
218
|
+
: wrapRemoteCommand(device, remoteCmd);
|
|
172
219
|
const env = {};
|
|
173
220
|
const args = [
|
|
174
221
|
...hostKeyCheckingOpts(hostKey.pinned ?? false, hostKey.knownHostsFile),
|
|
@@ -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
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Synced device approval/ignore policy and local registry reconciliation. */
|
|
2
2
|
import { readMeta, updateMeta } from '../state.js';
|
|
3
|
+
import { unionDeviceDiscovery } from './device-docs.js';
|
|
3
4
|
import { addIgnored, assertValidDeviceName, loadDevices, removeDevice, removeIgnored, upsertDevice, } from './registry.js';
|
|
4
5
|
import { localLoginUser, withDefaultUser } from './sync.js';
|
|
5
6
|
import { nodeToDeviceInput, parseTailscaleStatus, tailscaleStatusJson } from './tailscale.js';
|
|
@@ -8,36 +9,46 @@ export function getDeviceDiscoveryStatus(name) {
|
|
|
8
9
|
assertValidDeviceName(name);
|
|
9
10
|
return loadDeviceDiscoveryPolicies().get(name);
|
|
10
11
|
}
|
|
11
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Persist ONE discovery decision in THIS box's device doc (PHNX-3315). Each box
|
|
14
|
+
* records only its own choices in `devices/<machine>/agents.yaml` `fleet.discovery`,
|
|
15
|
+
* so N boxes no longer rewrite one shared central map (the guaranteed pull
|
|
16
|
+
* conflict). The effective policy is the union across every box
|
|
17
|
+
* ({@link loadDeviceDiscoveryPolicies}).
|
|
18
|
+
*/
|
|
12
19
|
export function setDeviceDiscoveryStatus(name, status) {
|
|
13
20
|
assertValidDeviceName(name);
|
|
14
21
|
updateMeta((meta) => {
|
|
15
|
-
const discovery = { ...meta.
|
|
22
|
+
const discovery = { ...meta.deviceFleet?.discovery };
|
|
16
23
|
if (status)
|
|
17
24
|
discovery[name] = status;
|
|
18
25
|
else
|
|
19
26
|
delete discovery[name];
|
|
20
|
-
|
|
21
|
-
...meta.fleet,
|
|
22
|
-
devices: meta.fleet?.devices ?? {},
|
|
23
|
-
// Keep an empty map as the synced tombstone: it distinguishes "policy is
|
|
24
|
-
// authoritative and every device is pending" from an older config that
|
|
25
|
-
// has never opted into portable discovery decisions.
|
|
26
|
-
discovery,
|
|
27
|
-
};
|
|
28
|
-
return { ...meta, fleet };
|
|
27
|
+
return { ...meta, deviceFleet: { ...meta.deviceFleet, discovery } };
|
|
29
28
|
});
|
|
30
29
|
}
|
|
31
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* The effective discovery policy: the UNION across every box's device doc, plus
|
|
32
|
+
* any lingering central-legacy map (drained by the fold-then-delete migration).
|
|
33
|
+
* Precedence for a name declared by more than one box is deterministic and
|
|
34
|
+
* order-independent — `ignored` beats `approved` — so every box computes the
|
|
35
|
+
* identical policy. Absence means pending.
|
|
36
|
+
*/
|
|
32
37
|
export function loadDeviceDiscoveryPolicies() {
|
|
33
38
|
const policies = new Map();
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
const apply = (rec) => {
|
|
40
|
+
for (const [name, status] of Object.entries(rec ?? {})) {
|
|
41
|
+
assertValidDeviceName(name);
|
|
42
|
+
if (status !== 'approved' && status !== 'ignored') {
|
|
43
|
+
throw new Error(`Device discovery policy for '${name}' must be approved or ignored.`);
|
|
44
|
+
}
|
|
45
|
+
if (policies.get(name) === 'ignored')
|
|
46
|
+
continue; // ignored is never downgraded
|
|
47
|
+
policies.set(name, status);
|
|
38
48
|
}
|
|
39
|
-
|
|
40
|
-
|
|
49
|
+
};
|
|
50
|
+
apply(readMeta().fleet?.discovery); // central legacy, until the migration drains it
|
|
51
|
+
apply(unionDeviceDiscovery()); // per-box device docs (ignored still wins)
|
|
41
52
|
return policies;
|
|
42
53
|
}
|
|
43
54
|
/**
|
|
@@ -55,11 +66,10 @@ export function loadDeviceDiscoveryPolicies() {
|
|
|
55
66
|
* that never meant to touch them.
|
|
56
67
|
*/
|
|
57
68
|
export async function reconcileDeviceDiscoveryPolicies() {
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
69
|
+
const policies = loadDeviceDiscoveryPolicies();
|
|
70
|
+
if (policies.size === 0) {
|
|
60
71
|
return { approved: [], ignored: [], registered: [], unresolved: [] };
|
|
61
72
|
}
|
|
62
|
-
const policies = loadDeviceDiscoveryPolicies();
|
|
63
73
|
const approved = [...policies].filter(([, s]) => s === 'approved').map(([n]) => n).sort();
|
|
64
74
|
const ignored = [...policies].filter(([, s]) => s === 'ignored').map(([n]) => n).sort();
|
|
65
75
|
for (const name of ignored) {
|