@phnx-labs/agents-cli 1.20.69 → 1.20.71
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 +81 -0
- package/README.md +19 -6
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.js +36 -3
- package/dist/commands/check.js +3 -1
- package/dist/commands/doctor.js +5 -0
- package/dist/commands/exec.js +12 -0
- package/dist/commands/fleet-capture.d.ts +14 -0
- package/dist/commands/fleet-capture.js +107 -0
- package/dist/commands/fork.js +13 -7
- package/dist/commands/hosts.js +11 -0
- package/dist/commands/secrets.d.ts +1 -0
- package/dist/commands/secrets.js +24 -4
- package/dist/commands/sessions-tail.js +1 -2
- package/dist/commands/sessions.js +6 -6
- package/dist/commands/ssh.js +63 -16
- package/dist/commands/usage.d.ts +13 -0
- package/dist/commands/usage.js +50 -28
- package/dist/commands/view.d.ts +1 -0
- package/dist/commands/view.js +5 -2
- package/dist/lib/auth-health.d.ts +38 -0
- package/dist/lib/auth-health.js +48 -3
- package/dist/lib/commands.d.ts +10 -0
- package/dist/lib/commands.js +31 -7
- package/dist/lib/computer/download.d.ts +3 -0
- package/dist/lib/computer/download.js +13 -12
- package/dist/lib/daemon.js +38 -0
- package/dist/lib/devices/connect.d.ts +13 -0
- package/dist/lib/devices/connect.js +20 -0
- package/dist/lib/devices/fleet.d.ts +36 -3
- package/dist/lib/devices/fleet.js +42 -4
- package/dist/lib/devices/health-report.d.ts +11 -0
- package/dist/lib/devices/health-report.js +73 -4
- package/dist/lib/devices/stats-cache.d.ts +36 -0
- package/dist/lib/devices/stats-cache.js +115 -0
- package/dist/lib/devices/sync.d.ts +30 -0
- package/dist/lib/devices/sync.js +50 -0
- package/dist/lib/doctor-diff.js +38 -16
- package/dist/lib/fleet/apply.d.ts +4 -0
- package/dist/lib/fleet/apply.js +28 -5
- package/dist/lib/fleet/capture.d.ts +35 -0
- package/dist/lib/fleet/capture.js +51 -0
- package/dist/lib/fleet/manifest.d.ts +6 -0
- package/dist/lib/fleet/manifest.js +24 -1
- package/dist/lib/fleet/types.d.ts +24 -1
- package/dist/lib/git.d.ts +14 -0
- package/dist/lib/git.js +39 -1
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +12 -0
- package/dist/lib/menubar/install-menubar.js +26 -13
- package/dist/lib/secrets/agent.d.ts +5 -0
- package/dist/lib/secrets/agent.js +35 -3
- package/dist/lib/secrets/bundles.js +28 -0
- package/dist/lib/secrets/index.d.ts +14 -0
- package/dist/lib/secrets/index.js +36 -5
- package/dist/lib/secrets/session-store.d.ts +90 -0
- package/dist/lib/secrets/session-store.js +221 -0
- package/dist/lib/session/render.d.ts +9 -1
- package/dist/lib/session/render.js +35 -4
- package/dist/lib/share/capture.d.ts +2 -0
- package/dist/lib/share/capture.js +12 -5
- package/dist/lib/skills.d.ts +8 -1
- package/dist/lib/skills.js +11 -1
- package/dist/lib/types.d.ts +5 -1
- package/package.json +1 -1
package/dist/lib/doctor-diff.js
CHANGED
|
@@ -22,21 +22,19 @@
|
|
|
22
22
|
import * as fs from 'fs';
|
|
23
23
|
import * as path from 'path';
|
|
24
24
|
import { AGENTS, agentConfigDirName } from './agents.js';
|
|
25
|
-
import { getProjectAgentsDir, getUserAgentsDir, getSystemAgentsDir, getEnabledExtraRepos, getResolvedRulesDir, getUserRulesDir, getEffectivePromptcutsPath, } from './state.js';
|
|
25
|
+
import { getProjectAgentsDir, getUserAgentsDir, getSystemAgentsDir, getEnabledExtraRepos, getResolvedRulesDir, getUserRulesDir, getEffectivePromptcutsPath, getActiveRulesPreset, } from './state.js';
|
|
26
|
+
import { composeRulesFromState } from './rules/compose.js';
|
|
26
27
|
import { getAvailableResources, getActuallySyncedResources, getVersionHomePath, compareVersions, } from './versions.js';
|
|
27
28
|
import { discoverPlugins, marketplaceSpecForName } from './plugins.js';
|
|
28
29
|
import { pluginInstallDir, repairableManifestFields } from './plugin-marketplace.js';
|
|
29
30
|
import { markdownToToml } from './convert.js';
|
|
30
|
-
import {
|
|
31
|
-
import { listCommandsInVersionHome, getVersionCommandsDir } from './commands.js';
|
|
31
|
+
import { listCommandsInVersionHome, getVersionCommandsDir, listPluginCommandNames } from './commands.js';
|
|
32
32
|
import { shouldInstallCommandAsSkill, commandSkillMatches, commandSkillName } from './command-skills.js';
|
|
33
33
|
import { gooseCommandMatches, gooseCommandsDir } from './goose-commands.js';
|
|
34
34
|
import { supports } from './capabilities.js';
|
|
35
35
|
import { listSkillsInVersionHome, getVersionSkillsDir } from './skills.js';
|
|
36
36
|
import { listHooksInVersionHome, listHookEntriesFromDir } from './hooks.js';
|
|
37
37
|
const RULES_DOC_FILENAME = 'README.md';
|
|
38
|
-
const COMPILED_HEADER = '<!-- Auto-compiled by agents-cli from ~/.agents/rules/AGENTS.md + imports.\n' +
|
|
39
|
-
' Edit the source files under ~/.agents/rules/ — edits to this file will be overwritten on next sync. -->\n\n';
|
|
40
38
|
const ALL_KINDS = [
|
|
41
39
|
'commands',
|
|
42
40
|
'skills',
|
|
@@ -173,9 +171,16 @@ function diffCommands(agent, version, cwd, excludeProject = false) {
|
|
|
173
171
|
homePath,
|
|
174
172
|
});
|
|
175
173
|
}
|
|
174
|
+
// Plugin-bundled commands (installed as `<plugin>-<cmd>`) are source-managed by
|
|
175
|
+
// their plugin, tracked under the `plugins` kind — not extras. Without this,
|
|
176
|
+
// `agents doctor` shows every plugin command (swarm-plan, code-review, …) as an
|
|
177
|
+
// unmanaged extra, mirroring the orphan false-positive the prune path had.
|
|
178
|
+
const pluginCommands = listPluginCommandNames();
|
|
176
179
|
for (const name of installed) {
|
|
177
180
|
if (seen.has(name))
|
|
178
181
|
continue;
|
|
182
|
+
if (pluginCommands.has(name))
|
|
183
|
+
continue;
|
|
179
184
|
const extraHome = asSkill
|
|
180
185
|
? path.join(agentDir, 'skills', commandSkillName(name), 'SKILL.md')
|
|
181
186
|
: agent === 'goose'
|
|
@@ -236,6 +241,14 @@ function dirsContentMatch(src, dst) {
|
|
|
236
241
|
return true;
|
|
237
242
|
}
|
|
238
243
|
function diffSkills(agent, version, cwd, excludeProject = false) {
|
|
244
|
+
// Native ~/.agents/skills consumers (Gemini, …) read central skills directly.
|
|
245
|
+
// The orchestrator DELETES their version-home skills dir (syncResourcesToVersion)
|
|
246
|
+
// and registers no skills writer, so there is nothing in the version home to
|
|
247
|
+
// reconcile. Mirror diffVersionSkills' native gate (skills.ts) — without it
|
|
248
|
+
// every central skill is false-reported `missing` and held as unreconcilable
|
|
249
|
+
// forever (drift never clears for these agents).
|
|
250
|
+
if (AGENTS[agent].nativeAgentsSkillsDir)
|
|
251
|
+
return [];
|
|
239
252
|
const homeDir = getVersionSkillsDir(agent, version);
|
|
240
253
|
const installed = new Set(listSkillsInVersionHome(agent, version));
|
|
241
254
|
const layerBases = buildLayerBases(cwd, 'skills', { excludeProject });
|
|
@@ -380,18 +393,27 @@ function listRulesNames(cwd, excludeProject = false) {
|
|
|
380
393
|
}
|
|
381
394
|
return out;
|
|
382
395
|
}
|
|
383
|
-
function expectedRuleContent(agent, name, sourcePath) {
|
|
384
|
-
//
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
396
|
+
function expectedRuleContent(agent, name, version, sourcePath) {
|
|
397
|
+
// The instruction file (AGENTS → the agent's CLAUDE.md/GEMINI.md/AGENTS.md)
|
|
398
|
+
// is COMPOSED from `subrules/` fragments for the version's active preset —
|
|
399
|
+
// that is exactly what the rules writer emits (see
|
|
400
|
+
// staleness/writers/rules.ts → composeRulesFromState). Compare against the
|
|
401
|
+
// same rendering so a correctly-synced home file reconciles instead of being
|
|
402
|
+
// held forever against the raw `rules/AGENTS.md` (the whole-repo doc, which a
|
|
403
|
+
// preset composition deliberately never equals — system subrules don't
|
|
404
|
+
// auto-append). The `agent` is not part of the rendering (every capable agent
|
|
405
|
+
// gets identical composed bytes); it is kept for symmetry with the writer's
|
|
406
|
+
// per-agent dispatch and future per-agent presets.
|
|
407
|
+
if (name === 'AGENTS') {
|
|
408
|
+
try {
|
|
409
|
+
return composeRulesFromState({ preset: getActiveRulesPreset(agent, version) }).content;
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
// No rules.yaml / unknown preset — the writer skips too; treat as unknown.
|
|
388
413
|
return null;
|
|
389
|
-
|
|
390
|
-
// its imports relative to the project rules dir, not the user one.
|
|
391
|
-
const baseDir = path.dirname(sourcePath);
|
|
392
|
-
const { content } = resolveImports(root, baseDir);
|
|
393
|
-
return COMPILED_HEADER + content;
|
|
414
|
+
}
|
|
394
415
|
}
|
|
416
|
+
// Any sibling top-level rules file syncs as a raw copy.
|
|
395
417
|
return readSafe(sourcePath);
|
|
396
418
|
}
|
|
397
419
|
function diffRules(agent, version, cwd, excludeProject = false) {
|
|
@@ -418,7 +440,7 @@ function diffRules(agent, version, cwd, excludeProject = false) {
|
|
|
418
440
|
rows.push({ kind: 'rules', name, status: 'missing', source: src.layer, sourcePath: src.path });
|
|
419
441
|
continue;
|
|
420
442
|
}
|
|
421
|
-
const expected = expectedRuleContent(agent, name, src.path);
|
|
443
|
+
const expected = expectedRuleContent(agent, name, version, src.path);
|
|
422
444
|
const actual = readSafe(homePath);
|
|
423
445
|
if (expected == null || actual == null) {
|
|
424
446
|
rows.push({ kind: 'rules', name, status: 'diff', source: src.layer, sourcePath: src.path, homePath });
|
|
@@ -30,6 +30,10 @@ export interface DiffContext {
|
|
|
30
30
|
/** agents-cli version the source is on — the fleet target version. */
|
|
31
31
|
targetCliVersion: string;
|
|
32
32
|
sourceAuth: SourceAuth;
|
|
33
|
+
/** Secrets-bundle names the profile declares. Values are keychain-local and
|
|
34
|
+
* can't be pushed, so each reachable device surfaces them as a manual recreate
|
|
35
|
+
* (`needs-secret`) — informational, never an executed mutation. */
|
|
36
|
+
secretsBundles?: string[];
|
|
33
37
|
}
|
|
34
38
|
/** Pure: desired vs probed -> per-device diff + flat action list. */
|
|
35
39
|
export declare function diffFleet(desired: DeviceDesired[], probes: Map<string, DeviceProbe>, ctx: DiffContext): FleetPlan;
|
package/dist/lib/fleet/apply.js
CHANGED
|
@@ -44,6 +44,7 @@ export function diffFleet(desired, probes, ctx) {
|
|
|
44
44
|
};
|
|
45
45
|
const rowActions = [];
|
|
46
46
|
const loginBlocked = [];
|
|
47
|
+
const secretsNeeded = [];
|
|
47
48
|
if (probe.reachable) {
|
|
48
49
|
// agents-cli presence.
|
|
49
50
|
if (!probe.cliVersion) {
|
|
@@ -82,8 +83,18 @@ export function diffFleet(desired, probes, ctx) {
|
|
|
82
83
|
}
|
|
83
84
|
}
|
|
84
85
|
}
|
|
86
|
+
// secrets — surfaced, never pushed (values are keychain-local). Declared
|
|
87
|
+
// once at the manifest level, so every reachable device gets the same
|
|
88
|
+
// manual-recreate reminder. Not an executable action (see idempotence
|
|
89
|
+
// check in commands/apply.ts, which excludes needs-* kinds).
|
|
90
|
+
if (ctx.secretsBundles && ctx.secretsBundles.length > 0) {
|
|
91
|
+
for (const bundle of ctx.secretsBundles) {
|
|
92
|
+
secretsNeeded.push(bundle);
|
|
93
|
+
rowActions.push({ device: d.device, kind: 'needs-secret', detail: `recreate secrets bundle '${bundle}' (\`agents secrets create ${bundle}\`)` });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
85
96
|
}
|
|
86
|
-
devices.push({ device: d.device, desired: d, probe, actions: rowActions, loginBlocked });
|
|
97
|
+
devices.push({ device: d.device, desired: d, probe, actions: rowActions, loginBlocked, secretsNeeded });
|
|
87
98
|
actions.push(...rowActions);
|
|
88
99
|
}
|
|
89
100
|
return { devices, actions };
|
|
@@ -161,11 +172,18 @@ export function reconcileDevice(row, device, ctx) {
|
|
|
161
172
|
steps.push({ kind: 'add-agent', ok: r.code === 0, detail: a.detail });
|
|
162
173
|
ok = ok && r.code === 0;
|
|
163
174
|
}
|
|
164
|
-
// 3. config sync.
|
|
175
|
+
// 3. config sync — one `agents sync <scope>` per declared scope. Each scope is
|
|
176
|
+
// a positional repo target (system/user/project/alias); a bare `agents sync`
|
|
177
|
+
// would ignore the profile's declared scopes entirely.
|
|
165
178
|
if (row.actions.some((a) => a.kind === 'sync-config')) {
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
179
|
+
const scopes = row.desired.sync.length > 0 ? row.desired.sync : [''];
|
|
180
|
+
let syncOk = true;
|
|
181
|
+
for (const scope of scopes) {
|
|
182
|
+
const r = sshAgents(scope ? ['sync', scope] : ['sync']);
|
|
183
|
+
syncOk = syncOk && r.code === 0;
|
|
184
|
+
}
|
|
185
|
+
steps.push({ kind: 'sync-config', ok: syncOk, detail: `sync config (${row.desired.sync.join(', ') || 'default'})` });
|
|
186
|
+
ok = ok && syncOk;
|
|
169
187
|
}
|
|
170
188
|
// 4. login propagation — one bundle for all pushable agents on this device.
|
|
171
189
|
const pushAgents = [...new Set(row.actions.filter((a) => a.kind === 'push-login').map((a) => a.agent))];
|
|
@@ -182,6 +200,11 @@ export function reconcileDevice(row, device, ctx) {
|
|
|
182
200
|
for (const blocked of row.loginBlocked) {
|
|
183
201
|
steps.push({ kind: 'needs-login', ok: false, detail: `${blocked} needs a manual login (\`agents ssh ${row.device} -- ${blocked}\`)` });
|
|
184
202
|
}
|
|
203
|
+
// Surface declared secrets bundles as (non-fatal) manual-recreate reminders —
|
|
204
|
+
// values are keychain-local, never captured or pushed.
|
|
205
|
+
for (const bundle of row.secretsNeeded) {
|
|
206
|
+
steps.push({ kind: 'needs-secret', ok: false, detail: `secrets bundle '${bundle}' must exist on ${row.device} (\`agents ssh ${row.device} -- secrets create ${bundle}\`)` });
|
|
207
|
+
}
|
|
185
208
|
return { device: row.device, ok, steps };
|
|
186
209
|
}
|
|
187
210
|
/** Run a pool of async tasks with a concurrency cap, preserving input order. */
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialize the live environment into a `fleet:` manifest.
|
|
3
|
+
*
|
|
4
|
+
* `captureFleet` is PURE — it takes the previous manifest plus already-gathered
|
|
5
|
+
* inputs (device names, per-device agent specs, browser profiles, secret-bundle
|
|
6
|
+
* names, routine names) and returns the new manifest. All I/O (registry read,
|
|
7
|
+
* `agents secrets`/`routines` enumeration, YAML write) happens in the command
|
|
8
|
+
* (`commands/fleet-capture.ts`); keeping this pure makes the privacy contract —
|
|
9
|
+
* NAMES ONLY, never IPs/usernames — trivially unit-testable.
|
|
10
|
+
*
|
|
11
|
+
* Additive by design: it merges OVER an existing manifest and never clobbers a
|
|
12
|
+
* hand-authored per-device override (`agents:` you set by hand wins over a
|
|
13
|
+
* captured one). The captured roster reflects live state, so it becomes the
|
|
14
|
+
* source of truth for WHICH devices exist.
|
|
15
|
+
*/
|
|
16
|
+
import type { FleetManifest, FleetDefaults } from './types.js';
|
|
17
|
+
export interface CaptureInputs {
|
|
18
|
+
/** Registered, non-control device names to record (the roster — names only). */
|
|
19
|
+
devices: string[];
|
|
20
|
+
/** Optional per-device agent specs (e.g. from `--from-pins`), keyed by name. */
|
|
21
|
+
agentsByDevice?: Record<string, string[]>;
|
|
22
|
+
/** Fleet defaults to seed when the manifest has none (source's own agents). */
|
|
23
|
+
defaults?: FleetDefaults;
|
|
24
|
+
/** Secrets-bundle NAMES to ensure exist (values stay in the keychain). */
|
|
25
|
+
secretsBundles?: string[];
|
|
26
|
+
/** Routine NAMES that should be active on the fleet. */
|
|
27
|
+
routines?: string[];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Build the new `fleet:` manifest from the previous one and captured inputs.
|
|
31
|
+
* Pure — no SSH, no filesystem, no registry. The returned object carries device
|
|
32
|
+
* names + desired state only; a caller that serializes it to YAML can assert no
|
|
33
|
+
* address/username ever appears.
|
|
34
|
+
*/
|
|
35
|
+
export declare function captureFleet(prev: FleetManifest | undefined, inputs: CaptureInputs): FleetManifest;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialize the live environment into a `fleet:` manifest.
|
|
3
|
+
*
|
|
4
|
+
* `captureFleet` is PURE — it takes the previous manifest plus already-gathered
|
|
5
|
+
* inputs (device names, per-device agent specs, browser profiles, secret-bundle
|
|
6
|
+
* names, routine names) and returns the new manifest. All I/O (registry read,
|
|
7
|
+
* `agents secrets`/`routines` enumeration, YAML write) happens in the command
|
|
8
|
+
* (`commands/fleet-capture.ts`); keeping this pure makes the privacy contract —
|
|
9
|
+
* NAMES ONLY, never IPs/usernames — trivially unit-testable.
|
|
10
|
+
*
|
|
11
|
+
* Additive by design: it merges OVER an existing manifest and never clobbers a
|
|
12
|
+
* hand-authored per-device override (`agents:` you set by hand wins over a
|
|
13
|
+
* captured one). The captured roster reflects live state, so it becomes the
|
|
14
|
+
* source of truth for WHICH devices exist.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Build the new `fleet:` manifest from the previous one and captured inputs.
|
|
18
|
+
* Pure — no SSH, no filesystem, no registry. The returned object carries device
|
|
19
|
+
* names + desired state only; a caller that serializes it to YAML can assert no
|
|
20
|
+
* address/username ever appears.
|
|
21
|
+
*/
|
|
22
|
+
export function captureFleet(prev, inputs) {
|
|
23
|
+
const prevDevices = prev && prev.devices !== 'all' && typeof prev.devices === 'object'
|
|
24
|
+
? prev.devices
|
|
25
|
+
: {};
|
|
26
|
+
// Roster: explicit map of the captured names. A hand-authored override for a
|
|
27
|
+
// device that still exists is preserved; a captured agent list only fills in
|
|
28
|
+
// when the manifest didn't already pin one for that device.
|
|
29
|
+
const devices = {};
|
|
30
|
+
for (const name of inputs.devices) {
|
|
31
|
+
const prevOverride = prevDevices[name] ?? {};
|
|
32
|
+
const override = { ...prevOverride };
|
|
33
|
+
const captured = inputs.agentsByDevice?.[name];
|
|
34
|
+
if (override.agents === undefined && captured && captured.length > 0) {
|
|
35
|
+
override.agents = captured;
|
|
36
|
+
}
|
|
37
|
+
devices[name] = override;
|
|
38
|
+
}
|
|
39
|
+
const manifest = {
|
|
40
|
+
// Keep hand-authored defaults; otherwise seed from the source snapshot.
|
|
41
|
+
defaults: prev?.defaults ?? inputs.defaults ?? {},
|
|
42
|
+
devices,
|
|
43
|
+
};
|
|
44
|
+
const bundles = inputs.secretsBundles ?? prev?.secrets?.bundles;
|
|
45
|
+
if (bundles && bundles.length > 0)
|
|
46
|
+
manifest.secrets = { bundles: [...bundles].sort() };
|
|
47
|
+
const routines = inputs.routines ?? prev?.routines;
|
|
48
|
+
if (routines && routines.length > 0)
|
|
49
|
+
manifest.routines = [...routines].sort();
|
|
50
|
+
return manifest;
|
|
51
|
+
}
|
|
@@ -19,6 +19,12 @@ export interface ResolveContext {
|
|
|
19
19
|
registeredDevices: string[];
|
|
20
20
|
/** The source machine, always excluded from the target set. */
|
|
21
21
|
source: string;
|
|
22
|
+
/** Names the bootstrap could not resolve from Tailscale (off-tailnet, ignored,
|
|
23
|
+
* or a typo). These are SKIPPED with the caller's warning rather than aborting
|
|
24
|
+
* the whole reconcile — a manifest naming an asleep laptop must not hard-fail
|
|
25
|
+
* every other device. Without a bootstrap, this is empty and an unregistered
|
|
26
|
+
* name still throws (genuine misconfig, caught early). */
|
|
27
|
+
unresolved?: string[];
|
|
22
28
|
}
|
|
23
29
|
/**
|
|
24
30
|
* Expand a manifest into concrete per-device desired states. `devices: all`
|
|
@@ -66,7 +66,25 @@ export function parseFleetManifest(raw) {
|
|
|
66
66
|
else {
|
|
67
67
|
throw new Error(`fleet: devices must be the string 'all' or a mapping of device -> overrides (got ${JSON.stringify(o.devices)}).`);
|
|
68
68
|
}
|
|
69
|
-
|
|
69
|
+
const manifest = { defaults, devices };
|
|
70
|
+
// Additive, backward-compatible extras (captured by `agents fleet capture`).
|
|
71
|
+
if (o.secrets !== undefined) {
|
|
72
|
+
if (typeof o.secrets !== 'object' || o.secrets === null || Array.isArray(o.secrets)) {
|
|
73
|
+
throw new Error('fleet: secrets must be a mapping with a `bundles:` list.');
|
|
74
|
+
}
|
|
75
|
+
const bundles = o.secrets.bundles;
|
|
76
|
+
if (bundles !== undefined && !isStringArray(bundles)) {
|
|
77
|
+
throw new Error('fleet: secrets.bundles must be a list of bundle names (e.g. [attio]).');
|
|
78
|
+
}
|
|
79
|
+
manifest.secrets = { bundles: bundles };
|
|
80
|
+
}
|
|
81
|
+
if (o.routines !== undefined) {
|
|
82
|
+
if (!isStringArray(o.routines)) {
|
|
83
|
+
throw new Error('fleet: routines must be a list of routine names.');
|
|
84
|
+
}
|
|
85
|
+
manifest.routines = o.routines;
|
|
86
|
+
}
|
|
87
|
+
return manifest;
|
|
70
88
|
}
|
|
71
89
|
/** Read a YAML file and extract + validate its `fleet:` block. */
|
|
72
90
|
export function readFleetFile(filePath) {
|
|
@@ -115,9 +133,14 @@ export function resolveDesired(manifest, ctx) {
|
|
|
115
133
|
}
|
|
116
134
|
return out;
|
|
117
135
|
}
|
|
136
|
+
const unresolved = new Set(ctx.unresolved ?? []);
|
|
118
137
|
for (const [name, override] of Object.entries(manifest.devices)) {
|
|
119
138
|
if (name === ctx.source)
|
|
120
139
|
continue;
|
|
140
|
+
// Bootstrap couldn't register this name (off-tailnet / ignored / typo) —
|
|
141
|
+
// skip it (the caller already surfaced it) instead of aborting the run.
|
|
142
|
+
if (unresolved.has(name))
|
|
143
|
+
continue;
|
|
121
144
|
if (!ctx.registeredDevices.includes(name)) {
|
|
122
145
|
throw new Error(`fleet: device '${name}' is not a registered device. Run \`agents devices add ${name}\` or fix the manifest.`);
|
|
123
146
|
}
|
|
@@ -42,6 +42,23 @@ export interface FleetDeviceOverride {
|
|
|
42
42
|
export interface FleetManifest {
|
|
43
43
|
defaults?: FleetDefaults;
|
|
44
44
|
devices: 'all' | Record<string, FleetDeviceOverride>;
|
|
45
|
+
/**
|
|
46
|
+
* Fleet-wide extras captured by `agents fleet capture` so a fresh machine can
|
|
47
|
+
* reconstruct the whole environment, not just installed agents. All additive,
|
|
48
|
+
* portable, and LEAK-FREE — names only, never connection details.
|
|
49
|
+
*
|
|
50
|
+
* (Browser profiles are deliberately NOT captured here: the central `browser:`
|
|
51
|
+
* block already syncs verbatim via `agents repo push/pull`, so duplicating it
|
|
52
|
+
* would be redundant — and its ssh:// endpoints can carry `user@host`, which
|
|
53
|
+
* must never be copied into a second location.)
|
|
54
|
+
*/
|
|
55
|
+
/** Secrets-bundle NAMES to ensure exist — values live in the keychain and are
|
|
56
|
+
* never captured or pushed; `apply` surfaces missing bundles to recreate. */
|
|
57
|
+
secrets?: {
|
|
58
|
+
bundles?: string[];
|
|
59
|
+
};
|
|
60
|
+
/** Routine NAMES that should be active on the fleet (files sync via the repo). */
|
|
61
|
+
routines?: string[];
|
|
45
62
|
}
|
|
46
63
|
/**
|
|
47
64
|
* A device's desired state after merging defaults with its override and
|
|
@@ -74,7 +91,10 @@ export interface DeviceProbe {
|
|
|
74
91
|
note?: string;
|
|
75
92
|
}
|
|
76
93
|
/** One planned action against a device, in a single reconcile dimension. */
|
|
77
|
-
export type FleetActionKind = 'install-cli' | 'upgrade-cli' | 'add-agent' | 'sync-config' | 'push-login' | 'needs-login'
|
|
94
|
+
export type FleetActionKind = 'install-cli' | 'upgrade-cli' | 'add-agent' | 'sync-config' | 'push-login' | 'needs-login'
|
|
95
|
+
/** Secrets bundles the profile declares but that can't be pushed (values are
|
|
96
|
+
* keychain-local) — surfaced as a manual recreate, like `needs-login`. */
|
|
97
|
+
| 'needs-secret';
|
|
78
98
|
export interface FleetAction {
|
|
79
99
|
device: string;
|
|
80
100
|
kind: FleetActionKind;
|
|
@@ -101,6 +121,9 @@ export interface DeviceDiff {
|
|
|
101
121
|
/** Agents that must be logged in on the device but can't be propagated
|
|
102
122
|
* (source token is device-bound, e.g. macOS keychain). Surfaced, not faked. */
|
|
103
123
|
loginBlocked: string[];
|
|
124
|
+
/** Secrets-bundle names the profile declares that must be recreated on the
|
|
125
|
+
* device (values are keychain-local — never captured or pushed). Surfaced. */
|
|
126
|
+
secretsNeeded: string[];
|
|
104
127
|
}
|
|
105
128
|
/** A portable auth file captured from a source agent home, ready to propagate. */
|
|
106
129
|
export interface AuthFilePayload {
|
package/dist/lib/git.d.ts
CHANGED
|
@@ -219,6 +219,20 @@ export declare function displayHomePath(dir: string): string;
|
|
|
219
219
|
* branch reconciles instead of failing with "Need to specify how to reconcile
|
|
220
220
|
* divergent branches".
|
|
221
221
|
*/
|
|
222
|
+
/**
|
|
223
|
+
* Auto-commit the machine's OWN generated per-device meta file
|
|
224
|
+
* (`devices/<machineId>/agents.yaml`) if it's the dirty state blocking a pull.
|
|
225
|
+
*
|
|
226
|
+
* That file is committed + synced (so every machine can introspect every other
|
|
227
|
+
* machine's pins), but each box rewrites its own copy whenever a pin changes —
|
|
228
|
+
* leaving the tree perpetually dirty and wedging `agents repo pull` (which
|
|
229
|
+
* refuses a dirty tree). This durably commits just that one path (explicit
|
|
230
|
+
* pathspec) so the pull can proceed; genuine user edits to OTHER files are left
|
|
231
|
+
* untouched and still (correctly) block the pull. No-op when the meta path isn't
|
|
232
|
+
* inside `dir` (system/extra repos) or isn't dirty. Returns the committed rel
|
|
233
|
+
* path, or null. `metaAbs` is injectable for tests; defaults to the live path.
|
|
234
|
+
*/
|
|
235
|
+
export declare function commitOwnDeviceMeta(dir: string, metaAbs?: string): Promise<string | null>;
|
|
222
236
|
export declare function pullRepo(dir: string): Promise<{
|
|
223
237
|
success: boolean;
|
|
224
238
|
commit: string;
|
package/dist/lib/git.js
CHANGED
|
@@ -10,7 +10,7 @@ import * as fs from 'fs';
|
|
|
10
10
|
import * as os from 'os';
|
|
11
11
|
import * as path from 'path';
|
|
12
12
|
import { IS_WINDOWS, isWindowsAbsolutePath } from './platform/index.js';
|
|
13
|
-
import { getPackageLocalPath } from './state.js';
|
|
13
|
+
import { getPackageLocalPath, getDeviceMetaPath } from './state.js';
|
|
14
14
|
import { DEFAULT_SYSTEM_REPO, systemRepoSlug } from './types.js';
|
|
15
15
|
/**
|
|
16
16
|
* Validate that a clone/pull source uses a safe git transport before it is
|
|
@@ -733,9 +733,47 @@ export function displayHomePath(dir) {
|
|
|
733
733
|
* branch reconciles instead of failing with "Need to specify how to reconcile
|
|
734
734
|
* divergent branches".
|
|
735
735
|
*/
|
|
736
|
+
/**
|
|
737
|
+
* Auto-commit the machine's OWN generated per-device meta file
|
|
738
|
+
* (`devices/<machineId>/agents.yaml`) if it's the dirty state blocking a pull.
|
|
739
|
+
*
|
|
740
|
+
* That file is committed + synced (so every machine can introspect every other
|
|
741
|
+
* machine's pins), but each box rewrites its own copy whenever a pin changes —
|
|
742
|
+
* leaving the tree perpetually dirty and wedging `agents repo pull` (which
|
|
743
|
+
* refuses a dirty tree). This durably commits just that one path (explicit
|
|
744
|
+
* pathspec) so the pull can proceed; genuine user edits to OTHER files are left
|
|
745
|
+
* untouched and still (correctly) block the pull. No-op when the meta path isn't
|
|
746
|
+
* inside `dir` (system/extra repos) or isn't dirty. Returns the committed rel
|
|
747
|
+
* path, or null. `metaAbs` is injectable for tests; defaults to the live path.
|
|
748
|
+
*/
|
|
749
|
+
export async function commitOwnDeviceMeta(dir, metaAbs = getDeviceMetaPath()) {
|
|
750
|
+
const resolvedDir = path.resolve(dir);
|
|
751
|
+
const resolvedMeta = path.resolve(metaAbs);
|
|
752
|
+
if (resolvedMeta !== resolvedDir && !resolvedMeta.startsWith(resolvedDir + path.sep)) {
|
|
753
|
+
return null; // meta lives outside this repo — not ours to commit here
|
|
754
|
+
}
|
|
755
|
+
const rel = path.relative(resolvedDir, resolvedMeta).split(path.sep).join('/');
|
|
756
|
+
try {
|
|
757
|
+
const git = simpleGit(dir);
|
|
758
|
+
const status = await git.status();
|
|
759
|
+
const dirty = status.files.some((f) => f.path === rel);
|
|
760
|
+
if (!dirty)
|
|
761
|
+
return null;
|
|
762
|
+
await git.add([rel]);
|
|
763
|
+
const machine = path.basename(path.dirname(resolvedMeta));
|
|
764
|
+
await git.commit(`chore(devices): snapshot ${machine} agent pins`, [rel]);
|
|
765
|
+
return rel;
|
|
766
|
+
}
|
|
767
|
+
catch {
|
|
768
|
+
return null; // best-effort — never let this block the pull path
|
|
769
|
+
}
|
|
770
|
+
}
|
|
736
771
|
export async function pullRepo(dir) {
|
|
737
772
|
try {
|
|
738
773
|
const git = simpleGit(dir);
|
|
774
|
+
// Commit this machine's own device-meta first so a per-machine pin change
|
|
775
|
+
// never wedges the pull. Genuine edits elsewhere still block below.
|
|
776
|
+
await commitOwnDeviceMeta(dir);
|
|
739
777
|
const status = await git.status();
|
|
740
778
|
if (!status.isClean()) {
|
|
741
779
|
return {
|
|
Binary file
|
|
@@ -26,6 +26,18 @@ export declare function menubarServiceInstalled(): boolean;
|
|
|
26
26
|
export declare function ensureMenubarAppInstalled(opts?: {
|
|
27
27
|
forceReinstall?: boolean;
|
|
28
28
|
}): string | null;
|
|
29
|
+
/**
|
|
30
|
+
* Restart the menu-bar launchd agent from a clean state.
|
|
31
|
+
*
|
|
32
|
+
* Always `bootout` first: on modern macOS `bootstrap` fails when the job is
|
|
33
|
+
* already bootstrapped, and the deprecated `load -w` fallback is unreliable.
|
|
34
|
+
* A prior WindowServer disconnect can leave the job throttled so that a plain
|
|
35
|
+
* `kickstart -k` does not bring it back; booting it out and bootstrapping fresh
|
|
36
|
+
* is the only sequence that reliably re-attaches the status item.
|
|
37
|
+
*/
|
|
38
|
+
export declare function restartMenubarLaunchAgent(uid: number, plist: string, exec?: (cmd: string, args: readonly string[], opts: {
|
|
39
|
+
stdio: ['ignore', 'ignore', 'ignore'];
|
|
40
|
+
}) => Buffer): void;
|
|
29
41
|
/**
|
|
30
42
|
* Install + start the menu-bar helper as a launchd user service (idempotent).
|
|
31
43
|
* Clears the sticky opt-out, installs the .app, writes the plist, and
|
|
@@ -210,6 +210,31 @@ ${envXml}
|
|
|
210
210
|
</dict>
|
|
211
211
|
</plist>`;
|
|
212
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* Restart the menu-bar launchd agent from a clean state.
|
|
215
|
+
*
|
|
216
|
+
* Always `bootout` first: on modern macOS `bootstrap` fails when the job is
|
|
217
|
+
* already bootstrapped, and the deprecated `load -w` fallback is unreliable.
|
|
218
|
+
* A prior WindowServer disconnect can leave the job throttled so that a plain
|
|
219
|
+
* `kickstart -k` does not bring it back; booting it out and bootstrapping fresh
|
|
220
|
+
* is the only sequence that reliably re-attaches the status item.
|
|
221
|
+
*/
|
|
222
|
+
export function restartMenubarLaunchAgent(uid, plist, exec = execFileSync) {
|
|
223
|
+
const serviceTarget = `gui/${uid}/${SERVICE_LABEL}`;
|
|
224
|
+
const opts = { stdio: ['ignore', 'ignore', 'ignore'] };
|
|
225
|
+
try {
|
|
226
|
+
exec('launchctl', ['bootout', serviceTarget], opts);
|
|
227
|
+
}
|
|
228
|
+
catch { /* may not be loaded */ }
|
|
229
|
+
try {
|
|
230
|
+
exec('launchctl', ['bootstrap', `gui/${uid}`, plist], opts);
|
|
231
|
+
}
|
|
232
|
+
catch { /* best effort */ }
|
|
233
|
+
try {
|
|
234
|
+
exec('launchctl', ['kickstart', serviceTarget], opts);
|
|
235
|
+
}
|
|
236
|
+
catch { /* best effort */ }
|
|
237
|
+
}
|
|
213
238
|
/**
|
|
214
239
|
* Install + start the menu-bar helper as a launchd user service (idempotent).
|
|
215
240
|
* Clears the sticky opt-out, installs the .app, writes the plist, and
|
|
@@ -232,19 +257,7 @@ export function enableMenubarService(opts = { clearOptOut: true }) {
|
|
|
232
257
|
fs.mkdirSync(path.dirname(plist), { recursive: true });
|
|
233
258
|
fs.writeFileSync(plist, generateServicePlist(exec));
|
|
234
259
|
const uid = process.getuid?.() ?? 0;
|
|
235
|
-
|
|
236
|
-
execFileSync('launchctl', ['bootstrap', `gui/${uid}`, plist], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
237
|
-
}
|
|
238
|
-
catch {
|
|
239
|
-
try {
|
|
240
|
-
execFileSync('launchctl', ['load', '-w', plist], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
241
|
-
}
|
|
242
|
-
catch { /* may already be loaded */ }
|
|
243
|
-
}
|
|
244
|
-
try {
|
|
245
|
-
execFileSync('launchctl', ['kickstart', '-k', `gui/${uid}/${SERVICE_LABEL}`], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
246
|
-
}
|
|
247
|
-
catch { /* best effort */ }
|
|
260
|
+
restartMenubarLaunchAgent(uid, plist);
|
|
248
261
|
// Stamp the version we just installed so the upgrade self-heal can tell when
|
|
249
262
|
// a later release ships a newer helper that needs reinstalling.
|
|
250
263
|
try {
|
|
@@ -280,6 +280,11 @@ export declare function agentAutoLoadMetaSync(nameSetHash: string, bundles: Secr
|
|
|
280
280
|
* per ~7d), so auto-caching is ON by default; opt out with
|
|
281
281
|
* `secrets.agent.auto: false`. Best-effort; an unreadable meta reads as on. */
|
|
282
282
|
export declare function secretsAgentAutoEnabled(): boolean;
|
|
283
|
+
/** Default `sleepPersist` for a new unlock when `--durable` is not passed. OFF by
|
|
284
|
+
* default (the secure split default: survive restart, re-lock on sleep); set
|
|
285
|
+
* `secrets.agent.durable: true` in agents.yaml to make every unlock sleep-durable.
|
|
286
|
+
* Best-effort; an unreadable meta reads as off. */
|
|
287
|
+
export declare function secretsAgentDurable(): boolean;
|
|
283
288
|
/** Minimum / maximum bounds for the configurable hold window. A too-small value
|
|
284
289
|
* would defeat the broker (constant re-prompts); a too-large one pins secrets in
|
|
285
290
|
* memory far longer than intended. */
|
|
@@ -34,6 +34,7 @@ import { isAlive } from '../platform/process.js';
|
|
|
34
34
|
import { getKeychainHelperPath } from './install-helper.js';
|
|
35
35
|
import { getCliVersion, getCliVersionFresh } from '../version.js';
|
|
36
36
|
import { getCliLaunch } from '../cli-entry.js';
|
|
37
|
+
import { rehydrateSessions, pruneSessionsOnSleep } from './session-store.js';
|
|
37
38
|
/** Bumped when the wire protocol changes; a client that pings a mismatched
|
|
38
39
|
* server kills and respawns it rather than talking a stale dialect. */
|
|
39
40
|
const PROTOCOL_VERSION = 1;
|
|
@@ -93,6 +94,20 @@ export function shouldTeardownVersionSkewedBroker(realHeldBundles) {
|
|
|
93
94
|
function onDarwin() {
|
|
94
95
|
return process.platform === 'darwin';
|
|
95
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Build the broker's in-memory store, REHYDRATED from the durable session store
|
|
99
|
+
* (session-store.ts) so an unlock survives a daemon restart / agents-cli upgrade.
|
|
100
|
+
* Expired sessions are dropped; `--durable` and default entries alike come back
|
|
101
|
+
* (SLEEP already pruned the non-durable ones from the keychain while the broker
|
|
102
|
+
* was alive). Empty off darwin or when nothing was held.
|
|
103
|
+
*/
|
|
104
|
+
function rehydrateStore(now = Date.now()) {
|
|
105
|
+
const store = new Map();
|
|
106
|
+
for (const { name, entry } of rehydrateSessions(now)) {
|
|
107
|
+
store.set(name, { bundle: entry.bundle, env: entry.env, expiresAt: entry.expiresAt });
|
|
108
|
+
}
|
|
109
|
+
return store;
|
|
110
|
+
}
|
|
96
111
|
/** Broker runtime dir under the regenerable cache, locked to the user (0700).
|
|
97
112
|
* AGENTS_SECRETS_AGENT_DIR overrides the location — a test seam so the suite can
|
|
98
113
|
* run a real broker on a temp socket without touching the user's real dir. */
|
|
@@ -442,7 +457,7 @@ export async function runSecretsAgent(opts = {}) {
|
|
|
442
457
|
throw err;
|
|
443
458
|
}
|
|
444
459
|
}
|
|
445
|
-
const store =
|
|
460
|
+
const store = rehydrateStore();
|
|
446
461
|
// emptySince tracks the last moment the store held something; the sweep exits
|
|
447
462
|
// the process once it's been empty for IDLE_EXIT_MS so no idle broker lingers.
|
|
448
463
|
let emptySince = Date.now();
|
|
@@ -589,6 +604,9 @@ export async function runSecretsAgent(opts = {}) {
|
|
|
589
604
|
if (shouldWipeOnWatchEvent(chunk)) {
|
|
590
605
|
store.clear();
|
|
591
606
|
emptySince = Date.now();
|
|
607
|
+
// Split default: delete non-`--durable` durable sessions too, so a default
|
|
608
|
+
// unlock re-locks on sleep; `--durable` ones survive (session-store.ts).
|
|
609
|
+
pruneSessionsOnSleep();
|
|
592
610
|
}
|
|
593
611
|
});
|
|
594
612
|
watcher.on('error', () => { watcher = null; });
|
|
@@ -627,7 +645,7 @@ export async function runSecretsAgent(opts = {}) {
|
|
|
627
645
|
export async function startHostedBroker() {
|
|
628
646
|
if (!onDarwin())
|
|
629
647
|
return null;
|
|
630
|
-
const store =
|
|
648
|
+
const store = rehydrateStore();
|
|
631
649
|
const sock = socketPath(); // agentDir() creates the 0700 dir as a side effect
|
|
632
650
|
const handle = (req) => handleAgentRequest(store, req);
|
|
633
651
|
const onConn = makeConnectionHandler(handle, readAgentToken);
|
|
@@ -651,8 +669,10 @@ export async function startHostedBroker() {
|
|
|
651
669
|
watcher = spawn(getKeychainHelperPath(), ['watch-lock'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
652
670
|
watcher.stdout?.setEncoding('utf-8');
|
|
653
671
|
watcher.stdout?.on('data', (chunk) => {
|
|
654
|
-
if (shouldWipeOnWatchEvent(chunk))
|
|
672
|
+
if (shouldWipeOnWatchEvent(chunk)) {
|
|
655
673
|
store.clear();
|
|
674
|
+
pruneSessionsOnSleep(); // split default — see runSecretsAgent's handler
|
|
675
|
+
}
|
|
656
676
|
});
|
|
657
677
|
watcher.on('error', () => { watcher = null; });
|
|
658
678
|
}
|
|
@@ -912,6 +932,18 @@ export function secretsAgentAutoEnabled() {
|
|
|
912
932
|
return true;
|
|
913
933
|
}
|
|
914
934
|
}
|
|
935
|
+
/** Default `sleepPersist` for a new unlock when `--durable` is not passed. OFF by
|
|
936
|
+
* default (the secure split default: survive restart, re-lock on sleep); set
|
|
937
|
+
* `secrets.agent.durable: true` in agents.yaml to make every unlock sleep-durable.
|
|
938
|
+
* Best-effort; an unreadable meta reads as off. */
|
|
939
|
+
export function secretsAgentDurable() {
|
|
940
|
+
try {
|
|
941
|
+
return readMeta().secrets?.agent?.durable === true;
|
|
942
|
+
}
|
|
943
|
+
catch {
|
|
944
|
+
return false;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
915
947
|
/** Minimum / maximum bounds for the configurable hold window. A too-small value
|
|
916
948
|
* would defeat the broker (constant re-prompts); a too-large one pins secrets in
|
|
917
949
|
* memory far longer than intended. */
|