@phnx-labs/agents-cli 1.22.0 → 1.22.3
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 +115 -0
- package/README.md +2 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/cloud.js +1 -1
- package/dist/commands/doctor.js +34 -2
- package/dist/commands/exec.js +1 -1
- package/dist/commands/projects.js +128 -66
- package/dist/commands/secrets.js +9 -5
- package/dist/commands/teams.js +2 -2
- package/dist/commands/watchdog.js +64 -5
- package/dist/lib/app-bundle-install.d.ts +17 -0
- package/dist/lib/app-bundle-install.js +94 -0
- package/dist/lib/devices/doctor-findings.js +12 -4
- package/dist/lib/devices/doctor-overview-cache.d.ts +45 -0
- package/dist/lib/devices/doctor-overview-cache.js +168 -0
- package/dist/lib/devices/fleet.js +7 -2
- package/dist/lib/devices/self-host.d.ts +9 -0
- package/dist/lib/devices/self-host.js +61 -0
- package/dist/lib/hosts/passthrough.js +8 -6
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.js +27 -23
- package/dist/lib/project-status.d.ts +7 -0
- package/dist/lib/project-status.js +9 -0
- package/dist/lib/projects.d.ts +26 -2
- package/dist/lib/projects.js +69 -9
- package/dist/lib/rotate.d.ts +16 -1
- package/dist/lib/rotate.js +33 -7
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/bundles.d.ts +20 -0
- package/dist/lib/secrets/bundles.js +50 -0
- package/dist/lib/secrets/install-helper.js +28 -31
- package/dist/lib/share/config.d.ts +14 -6
- package/dist/lib/share/config.js +23 -5
- package/dist/lib/types.d.ts +10 -0
- package/dist/lib/versions.js +69 -22
- package/dist/lib/watchdog/rotate.d.ts +218 -0
- package/dist/lib/watchdog/rotate.js +378 -0
- package/dist/lib/watchdog/runner.d.ts +33 -1
- package/dist/lib/watchdog/runner.js +303 -0
- package/package.json +1 -1
|
@@ -22,6 +22,7 @@ import * as path from 'path';
|
|
|
22
22
|
import { sleepSync } from '../fs-atomic.js';
|
|
23
23
|
import { getRuntimeStateDir, getHelpersDir } from '../state.js';
|
|
24
24
|
import { getCliVersion, resolveAgentsBin, resolveInstalledLayout } from '../version.js';
|
|
25
|
+
import { copyAppBundle, withInstallLock } from '../app-bundle-install.js';
|
|
25
26
|
const APP_BUNDLE_NAME = 'MenubarHelper.app';
|
|
26
27
|
const INSTALL_DIR_NAME = 'agents-cli';
|
|
27
28
|
const SERVICE_LABEL = 'com.phnx-labs.agents-menubar';
|
|
@@ -154,17 +155,6 @@ function resolveCliEntry() {
|
|
|
154
155
|
}
|
|
155
156
|
return null;
|
|
156
157
|
}
|
|
157
|
-
function copyAppBundle(src, dest) {
|
|
158
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
159
|
-
if (fs.existsSync(dest))
|
|
160
|
-
fs.rmSync(dest, { recursive: true, force: true });
|
|
161
|
-
// `cp -R` preserves the bundle's signature and resource forks (see install-helper.ts).
|
|
162
|
-
const r = spawnSync('cp', ['-R', src, dest], { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' });
|
|
163
|
-
if (r.status !== 0) {
|
|
164
|
-
const msg = (r.stderr || r.stdout || '').toString().trim();
|
|
165
|
-
throw new Error(`Failed to copy ${src} -> ${dest}: ${msg || 'unknown error'}`);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
158
|
/**
|
|
169
159
|
* Register the freshly-installed bundle with LaunchServices.
|
|
170
160
|
*
|
|
@@ -232,18 +222,32 @@ export function ensureMenubarAppInstalled(opts = {}) {
|
|
|
232
222
|
if (!src)
|
|
233
223
|
return null;
|
|
234
224
|
const dest = installedAppPath();
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (
|
|
239
|
-
return
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
|
|
225
|
+
// Heal an older install that was ad-hoc re-signed over a Developer ID source:
|
|
226
|
+
// that unstable identity made Accessibility re-prompt on every upgrade.
|
|
227
|
+
const needsInstall = () => {
|
|
228
|
+
if (opts.forceReinstall)
|
|
229
|
+
return true;
|
|
230
|
+
if (!fs.existsSync(dest))
|
|
231
|
+
return true;
|
|
232
|
+
return hasDeveloperIdSignature(src) && !hasDeveloperIdSignature(dest);
|
|
233
|
+
};
|
|
234
|
+
// Fast path: nothing to do.
|
|
235
|
+
if (!needsInstall())
|
|
236
|
+
return installedExecutablePath();
|
|
237
|
+
// Serialize the atomic install so concurrent `agents` invocations (this runs
|
|
238
|
+
// on the darwin startup path) don't race the swap or each re-copy — the
|
|
239
|
+
// stampede that transiently corrupted MenubarHelper.app and tripped the
|
|
240
|
+
// "damaged" dialog.
|
|
241
|
+
withInstallLock(dest, (heartbeat) => {
|
|
242
|
+
if (!needsInstall())
|
|
243
|
+
return;
|
|
244
|
+
copyAppBundle(src, dest);
|
|
245
|
+
heartbeat(); // cp -R done; keep the lock fresh across lsregister
|
|
246
|
+
// A fresh copy is exactly when the bundle's icon can be new (first install) or
|
|
247
|
+
// superseded (upgrade) — register it so LaunchServices knows the bundle and can
|
|
248
|
+
// resolve its AppIcon for the left-hand slot of daemon notifications.
|
|
249
|
+
refreshBundleIconRegistration(dest);
|
|
250
|
+
});
|
|
247
251
|
return installedExecutablePath();
|
|
248
252
|
}
|
|
249
253
|
function xmlEscape(s) {
|
|
@@ -54,6 +54,13 @@ export interface ProjectSessionRollup {
|
|
|
54
54
|
* with the full definition list to show zero-agent projects.
|
|
55
55
|
*/
|
|
56
56
|
export declare function rollupSessionsByProject(defs: ProjectDef[], sessions: ActiveSession[]): Map<string, ProjectSessionRollup>;
|
|
57
|
+
/**
|
|
58
|
+
* True when a session's status means it is over. Exported so the card can keep
|
|
59
|
+
* the `agents` roster to live sessions: the headline and the `dead` row already
|
|
60
|
+
* separate the two, and a roster that reads `crashed ×25` beside `23 live`
|
|
61
|
+
* makes the reader distrust both numbers.
|
|
62
|
+
*/
|
|
63
|
+
export declare function isDeadStatus(status: string): boolean;
|
|
57
64
|
/** Live vs finished sessions on a project. */
|
|
58
65
|
export interface LiveDeadSplit {
|
|
59
66
|
live: number;
|
|
@@ -93,6 +93,15 @@ export function rollupSessionsByProject(defs, sessions) {
|
|
|
93
93
|
* that are running unattended.
|
|
94
94
|
*/
|
|
95
95
|
const DEAD_STATUSES = new Set(['closed', 'crashed']);
|
|
96
|
+
/**
|
|
97
|
+
* True when a session's status means it is over. Exported so the card can keep
|
|
98
|
+
* the `agents` roster to live sessions: the headline and the `dead` row already
|
|
99
|
+
* separate the two, and a roster that reads `crashed ×25` beside `23 live`
|
|
100
|
+
* makes the reader distrust both numbers.
|
|
101
|
+
*/
|
|
102
|
+
export function isDeadStatus(status) {
|
|
103
|
+
return DEAD_STATUSES.has(status);
|
|
104
|
+
}
|
|
96
105
|
/**
|
|
97
106
|
* Split a rollup's sessions into what is working and what is wreckage.
|
|
98
107
|
*
|
package/dist/lib/projects.d.ts
CHANGED
|
@@ -5,8 +5,17 @@
|
|
|
5
5
|
* directory by pure convention (`<projectRoot>/<slug>`, see `project-root.ts`).
|
|
6
6
|
* This module adds editable definitions on top: one YAML file per project under
|
|
7
7
|
* `~/.agents/projects/<name>.yaml`, sitting beside the existing `routines/`,
|
|
8
|
-
* `monitors/`, and `teams/` dirs in the user repo
|
|
9
|
-
*
|
|
8
|
+
* `monitors/`, and `teams/` dirs in the user repo.
|
|
9
|
+
*
|
|
10
|
+
* That location makes definitions SYNCABLE, not automatically synced: they ride
|
|
11
|
+
* the user repo only once they are committed to it, via `agents repo push user`
|
|
12
|
+
* (`agents push` was removed). Until then the directory is untracked, and a
|
|
13
|
+
* reconcile that cleans the working tree deletes it — observed twice on one
|
|
14
|
+
* machine, taking four definitions with it each time. The recovery is an
|
|
15
|
+
* orphaned `chore(local): save …-sync drift` commit, which is not a guarantee:
|
|
16
|
+
* unreachable objects are collected. Say "commit them" rather than "for free".
|
|
17
|
+
*
|
|
18
|
+
* A defined project can name itself
|
|
10
19
|
* independently of its folder, bind more than one repo, pin a monorepo subpath,
|
|
11
20
|
* describe context subdirectories an agent should start from, carry a Linear
|
|
12
21
|
* link and external integrations, and set an explicit default path.
|
|
@@ -42,6 +51,19 @@ export interface ProjectContext {
|
|
|
42
51
|
/** One line on how this subtree relates to the project. */
|
|
43
52
|
purpose: string;
|
|
44
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* A project goal — the OKR-shaped "why". A project serves one or more goals: a
|
|
56
|
+
* qualitative `objective` ("Ship agents-cli 2.0") and an optional `measure`, the
|
|
57
|
+
* key result that says whether it's landing ("fleet on 2.x", "p95 < 200ms"). The
|
|
58
|
+
* goal is the outcome the work is chasing; milestones (dated checkpoints, pulled
|
|
59
|
+
* from Linear) and live work (agents / PRs / artifacts) are how far along it is.
|
|
60
|
+
*/
|
|
61
|
+
export interface ProjectGoal {
|
|
62
|
+
/** The outcome, in a line. */
|
|
63
|
+
objective: string;
|
|
64
|
+
/** Optional key result — how success is measured. */
|
|
65
|
+
measure?: string;
|
|
66
|
+
}
|
|
45
67
|
/** An external context source hung off the project (surfaced in `projects show`). */
|
|
46
68
|
export interface ProjectIntegration {
|
|
47
69
|
/** e.g. `gdrive`, `notion`, `figma`, `url`. */
|
|
@@ -64,6 +86,8 @@ export interface ProjectDef {
|
|
|
64
86
|
repos?: ProjectRepo[];
|
|
65
87
|
/** Described starting points inside the project. */
|
|
66
88
|
contexts?: ProjectContext[];
|
|
89
|
+
/** The outcomes this project serves (OKR-shaped); a project may have several. */
|
|
90
|
+
goals?: ProjectGoal[];
|
|
67
91
|
/** External context sources (Drive, docs, …). */
|
|
68
92
|
integrations?: ProjectIntegration[];
|
|
69
93
|
/** Linear project link — reuses the existing GraphQL path. */
|
package/dist/lib/projects.js
CHANGED
|
@@ -5,8 +5,17 @@
|
|
|
5
5
|
* directory by pure convention (`<projectRoot>/<slug>`, see `project-root.ts`).
|
|
6
6
|
* This module adds editable definitions on top: one YAML file per project under
|
|
7
7
|
* `~/.agents/projects/<name>.yaml`, sitting beside the existing `routines/`,
|
|
8
|
-
* `monitors/`, and `teams/` dirs in the user repo
|
|
9
|
-
*
|
|
8
|
+
* `monitors/`, and `teams/` dirs in the user repo.
|
|
9
|
+
*
|
|
10
|
+
* That location makes definitions SYNCABLE, not automatically synced: they ride
|
|
11
|
+
* the user repo only once they are committed to it, via `agents repo push user`
|
|
12
|
+
* (`agents push` was removed). Until then the directory is untracked, and a
|
|
13
|
+
* reconcile that cleans the working tree deletes it — observed twice on one
|
|
14
|
+
* machine, taking four definitions with it each time. The recovery is an
|
|
15
|
+
* orphaned `chore(local): save …-sync drift` commit, which is not a guarantee:
|
|
16
|
+
* unreachable objects are collected. Say "commit them" rather than "for free".
|
|
17
|
+
*
|
|
18
|
+
* A defined project can name itself
|
|
10
19
|
* independently of its folder, bind more than one repo, pin a monorepo subpath,
|
|
11
20
|
* describe context subdirectories an agent should start from, carry a Linear
|
|
12
21
|
* link and external integrations, and set an explicit default path.
|
|
@@ -112,6 +121,18 @@ export function validateProjectDef(raw, sourceName) {
|
|
|
112
121
|
return [];
|
|
113
122
|
});
|
|
114
123
|
}
|
|
124
|
+
if (Array.isArray(o.goals)) {
|
|
125
|
+
def.goals = o.goals.flatMap((g) => {
|
|
126
|
+
if (g && typeof g === 'object' && typeof g.objective === 'string') {
|
|
127
|
+
const gg = g;
|
|
128
|
+
const goal = { objective: gg.objective };
|
|
129
|
+
if (typeof gg.measure === 'string')
|
|
130
|
+
goal.measure = gg.measure;
|
|
131
|
+
return [goal];
|
|
132
|
+
}
|
|
133
|
+
return [];
|
|
134
|
+
});
|
|
135
|
+
}
|
|
115
136
|
if (Array.isArray(o.integrations)) {
|
|
116
137
|
def.integrations = o.integrations.flatMap((i) => {
|
|
117
138
|
if (i &&
|
|
@@ -235,11 +256,40 @@ export function projectBasePath(def, forRemote) {
|
|
|
235
256
|
}
|
|
236
257
|
function projectRootsAbs(defs) {
|
|
237
258
|
const out = [];
|
|
238
|
-
|
|
239
|
-
const raw = def.root ?? def.defaultPath;
|
|
259
|
+
const push = (name, raw) => {
|
|
240
260
|
if (!raw)
|
|
241
|
-
|
|
242
|
-
out.push({ name
|
|
261
|
+
return;
|
|
262
|
+
out.push({ name, abs: path.resolve(expandLocalHome(raw)) });
|
|
263
|
+
};
|
|
264
|
+
for (const def of defs) {
|
|
265
|
+
// `root` says where the CHECKOUT is; `defaultPath` says which work is this
|
|
266
|
+
// project's. For a monorepo subproject those differ, and only the narrower
|
|
267
|
+
// one is a membership claim — a project scoped to `rush/apps/cli` does not
|
|
268
|
+
// own `rush/apps/web`.
|
|
269
|
+
//
|
|
270
|
+
// The old `root ?? defaultPath` collapsed such a subproject onto the
|
|
271
|
+
// monorepo root, the same path its umbrella anchors at, so the longest-match
|
|
272
|
+
// tiebreak below had nothing to separate them and a session in
|
|
273
|
+
// `rush/apps/cli` counted toward whichever definition was listed first.
|
|
274
|
+
const rootAbs = def.root ? path.resolve(expandLocalHome(def.root)) : undefined;
|
|
275
|
+
const defaultAbs = def.defaultPath ? path.resolve(expandLocalHome(def.defaultPath)) : undefined;
|
|
276
|
+
const narrowed = !!(rootAbs && defaultAbs && defaultAbs !== rootAbs && isUnder(defaultAbs, rootAbs));
|
|
277
|
+
// A narrowed project's root is a WEAK claim: it still covers the rest of the
|
|
278
|
+
// checkout when nobody else wants it, but yields to any project that claims
|
|
279
|
+
// a path outright. Dropping it entirely regressed the single-project case —
|
|
280
|
+
// `add foo --root ~/src/foo --path apps/web` stopped attributing work
|
|
281
|
+
// anywhere else in its own repo, and `--path` means where agents START, not
|
|
282
|
+
// which work counts.
|
|
283
|
+
if (rootAbs)
|
|
284
|
+
out.push({ name: def.name, abs: rootAbs, weak: narrowed });
|
|
285
|
+
if (defaultAbs && defaultAbs !== rootAbs)
|
|
286
|
+
out.push({ name: def.name, abs: defaultAbs });
|
|
287
|
+
for (const r of def.repos ?? []) {
|
|
288
|
+
push(def.name, r.path);
|
|
289
|
+
// A repo pinned to a monorepo subpath anchors at that subpath too.
|
|
290
|
+
if (r.path && r.subpath)
|
|
291
|
+
push(def.name, path.join(expandLocalHome(r.path), r.subpath));
|
|
292
|
+
}
|
|
243
293
|
}
|
|
244
294
|
return out;
|
|
245
295
|
}
|
|
@@ -269,13 +319,23 @@ export function projectNameForCwd(cwd, defs) {
|
|
|
269
319
|
const abs = path.resolve(expandLocalHome(cwd));
|
|
270
320
|
let best;
|
|
271
321
|
let bestLen = -1;
|
|
272
|
-
|
|
273
|
-
|
|
322
|
+
let weakBest;
|
|
323
|
+
let weakLen = -1;
|
|
324
|
+
for (const { name, abs: root, weak } of projectRootsAbs(defs)) {
|
|
325
|
+
if (!isUnder(abs, root))
|
|
326
|
+
continue;
|
|
327
|
+
if (weak) {
|
|
328
|
+
if (root.length > weakLen) {
|
|
329
|
+
weakBest = name;
|
|
330
|
+
weakLen = root.length;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
else if (root.length > bestLen) {
|
|
274
334
|
best = name;
|
|
275
335
|
bestLen = root.length;
|
|
276
336
|
}
|
|
277
337
|
}
|
|
278
|
-
return best;
|
|
338
|
+
return best ?? weakBest;
|
|
279
339
|
}
|
|
280
340
|
/**
|
|
281
341
|
* The canonical project label for a cwd, for every surface that buckets work by
|
package/dist/lib/rotate.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { AgentId, RunStrategy } from './types.js';
|
|
8
8
|
import type { FallbackEntry } from './exec.js';
|
|
9
|
-
import { type AccountInfo } from './agents.js';
|
|
9
|
+
import { type AccountInfo, type CredentialPresence } from './agents.js';
|
|
10
10
|
import { type UsageSnapshot } from './usage.js';
|
|
11
11
|
export interface RotateCandidate {
|
|
12
12
|
agent: AgentId;
|
|
@@ -75,6 +75,21 @@ export declare function getProjectRunStrategy(agent: AgentId, startPath: string)
|
|
|
75
75
|
export declare function getConfiguredRunStrategy(agent: AgentId, startPath?: string): RunStrategy;
|
|
76
76
|
/** Persist the global run strategy used by bare `agents run <agent>`. */
|
|
77
77
|
export declare function setGlobalRunStrategy(agent: AgentId, strategy: RunStrategy): void;
|
|
78
|
+
/**
|
|
79
|
+
* Whether a version home can actually authenticate a launch.
|
|
80
|
+
*
|
|
81
|
+
* `getAccountInfo` falls back to the active/global HOME when a version home has
|
|
82
|
+
* no credential of its own, so `agents view` still shows who is logged in. Launch
|
|
83
|
+
* paths isolate config (GROK_HOME, CODEX_HOME, KIMI_CODE_HOME, CLAUDE_CONFIG_DIR,
|
|
84
|
+
* …) to the per-version home, so a home that only "inherits" the active login
|
|
85
|
+
* cannot spawn a signed-in agent — balanced kept picking those empty homes and
|
|
86
|
+
* the run died on "Not signed in".
|
|
87
|
+
*
|
|
88
|
+
* When we know where the credential lives (`knownLocation`), require it under
|
|
89
|
+
* THIS version home. When we don't (keychain-only / unmapped agents), trust the
|
|
90
|
+
* existing `signedIn` signal.
|
|
91
|
+
*/
|
|
92
|
+
export declare function isLaunchableSignedIn(signedIn: boolean, presence: Pick<CredentialPresence, 'knownLocation' | 'perVersion'>): boolean;
|
|
78
93
|
/**
|
|
79
94
|
* How old a usage snapshot may be and still settle a routing DECISION.
|
|
80
95
|
*
|
package/dist/lib/rotate.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import * as fs from 'fs';
|
|
8
8
|
import * as path from 'path';
|
|
9
|
-
import { accountDisplayLabel, getAccountInfo, ALL_AGENT_IDS } from './agents.js';
|
|
9
|
+
import { accountDisplayLabel, getAccountInfo, credentialPresence, ALL_AGENT_IDS, } from './agents.js';
|
|
10
10
|
import { readMeta, writeMeta, getHelpersDir } from './state.js';
|
|
11
11
|
import { listInstalledVersions, getVersionHomePath, resolveVersion } from './versions.js';
|
|
12
12
|
import { getProjectRunConfigs } from './run-config.js';
|
|
@@ -68,6 +68,27 @@ export function setGlobalRunStrategy(agent, strategy) {
|
|
|
68
68
|
function isRotationEligible(candidate) {
|
|
69
69
|
return candidate.signedIn && hasUsageAvailable(candidate);
|
|
70
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Whether a version home can actually authenticate a launch.
|
|
73
|
+
*
|
|
74
|
+
* `getAccountInfo` falls back to the active/global HOME when a version home has
|
|
75
|
+
* no credential of its own, so `agents view` still shows who is logged in. Launch
|
|
76
|
+
* paths isolate config (GROK_HOME, CODEX_HOME, KIMI_CODE_HOME, CLAUDE_CONFIG_DIR,
|
|
77
|
+
* …) to the per-version home, so a home that only "inherits" the active login
|
|
78
|
+
* cannot spawn a signed-in agent — balanced kept picking those empty homes and
|
|
79
|
+
* the run died on "Not signed in".
|
|
80
|
+
*
|
|
81
|
+
* When we know where the credential lives (`knownLocation`), require it under
|
|
82
|
+
* THIS version home. When we don't (keychain-only / unmapped agents), trust the
|
|
83
|
+
* existing `signedIn` signal.
|
|
84
|
+
*/
|
|
85
|
+
export function isLaunchableSignedIn(signedIn, presence) {
|
|
86
|
+
if (!signedIn)
|
|
87
|
+
return false;
|
|
88
|
+
if (!presence.knownLocation)
|
|
89
|
+
return true;
|
|
90
|
+
return presence.perVersion;
|
|
91
|
+
}
|
|
71
92
|
function isAvailableEligible(candidate) {
|
|
72
93
|
return isRotationEligible(candidate);
|
|
73
94
|
}
|
|
@@ -484,17 +505,22 @@ export async function collectRunCandidates(agent) {
|
|
|
484
505
|
// one per installed version, every time `agents run` cold-starts. If
|
|
485
506
|
// claude's stored token has actually expired, the spawned agent detects
|
|
486
507
|
// it at its own startup and re-auths; that's the correct UX.
|
|
508
|
+
//
|
|
509
|
+
// Gate signedIn on a real per-version credential when we know where it
|
|
510
|
+
// lives — see isLaunchableSignedIn. Do not reuse the active-home fallback
|
|
511
|
+
// identity for routing, or empty version homes look healthy and die at spawn.
|
|
512
|
+
const launchable = isLaunchableSignedIn(info.signedIn, credentialPresence(agent, home));
|
|
487
513
|
return {
|
|
488
514
|
agent,
|
|
489
515
|
version,
|
|
490
516
|
home,
|
|
491
517
|
info,
|
|
492
|
-
accountKey: info.accountKey,
|
|
493
|
-
accountLabel: accountDisplayLabel(info),
|
|
494
|
-
email: info.email,
|
|
495
|
-
usageStatus: info.usageStatus,
|
|
496
|
-
plan: info.plan,
|
|
497
|
-
signedIn:
|
|
518
|
+
accountKey: launchable ? info.accountKey : null,
|
|
519
|
+
accountLabel: launchable ? accountDisplayLabel(info) : '',
|
|
520
|
+
email: launchable ? info.email : null,
|
|
521
|
+
usageStatus: launchable ? info.usageStatus : null,
|
|
522
|
+
plan: launchable ? info.plan : null,
|
|
523
|
+
signedIn: launchable,
|
|
498
524
|
lastActive: info.lastActive,
|
|
499
525
|
};
|
|
500
526
|
}));
|
|
Binary file
|
|
Binary file
|
|
@@ -344,6 +344,26 @@ export interface RotateOptions {
|
|
|
344
344
|
* unless `clearMeta` or a `meta` patch is supplied.
|
|
345
345
|
*/
|
|
346
346
|
export declare function rotateBundleSecret(bundle: SecretsBundle, key: string, opts: RotateOptions): void;
|
|
347
|
+
/**
|
|
348
|
+
* Reconcile a bundle's keychain-backed VALUE items to its CURRENT policy, then
|
|
349
|
+
* write the (always no-ACL) metadata.
|
|
350
|
+
*
|
|
351
|
+
* `writeBundle` only rewrites the metadata item, so a policy change alone leaves
|
|
352
|
+
* every value item carrying the ACL it was created with — and macOS gates each
|
|
353
|
+
* read on the ITEM's ACL, not the bundle's declared tier (spec SEC-19). Without
|
|
354
|
+
* this reconcile, `agents secrets policy <b> never` reports "silent" while the
|
|
355
|
+
* still-ACL'd value keeps popping Touch ID on every read, forever.
|
|
356
|
+
*
|
|
357
|
+
* hold/always -> never strips the biometry ACL (helper `set-no-acl`: delete+add)
|
|
358
|
+
* never -> hold/always re-attaches it (helper `set`)
|
|
359
|
+
*
|
|
360
|
+
* The current values are read in ONE batch, so the reconcile costs at most a
|
|
361
|
+
* single Touch ID — the last prompt a hold->never bundle will ever raise (a
|
|
362
|
+
* never->* flip reads silently, since the items are already no-ACL). No-op on the
|
|
363
|
+
* ACL to write for non-keychain backends (file/vault have no biometry concept),
|
|
364
|
+
* and a metadata-only write when the bundle has no keychain-backed values.
|
|
365
|
+
*/
|
|
366
|
+
export declare function reAclBundleItems(bundle: SecretsBundle): void;
|
|
347
367
|
/** Options for renameBundle. */
|
|
348
368
|
export interface RenameOptions {
|
|
349
369
|
/** When true, overwrite an existing destination bundle (purges its keychain items first). */
|
|
@@ -1376,6 +1376,56 @@ export function rotateBundleSecret(bundle, key, opts) {
|
|
|
1376
1376
|
}
|
|
1377
1377
|
writeBundle(bundle);
|
|
1378
1378
|
}
|
|
1379
|
+
/**
|
|
1380
|
+
* Reconcile a bundle's keychain-backed VALUE items to its CURRENT policy, then
|
|
1381
|
+
* write the (always no-ACL) metadata.
|
|
1382
|
+
*
|
|
1383
|
+
* `writeBundle` only rewrites the metadata item, so a policy change alone leaves
|
|
1384
|
+
* every value item carrying the ACL it was created with — and macOS gates each
|
|
1385
|
+
* read on the ITEM's ACL, not the bundle's declared tier (spec SEC-19). Without
|
|
1386
|
+
* this reconcile, `agents secrets policy <b> never` reports "silent" while the
|
|
1387
|
+
* still-ACL'd value keeps popping Touch ID on every read, forever.
|
|
1388
|
+
*
|
|
1389
|
+
* hold/always -> never strips the biometry ACL (helper `set-no-acl`: delete+add)
|
|
1390
|
+
* never -> hold/always re-attaches it (helper `set`)
|
|
1391
|
+
*
|
|
1392
|
+
* The current values are read in ONE batch, so the reconcile costs at most a
|
|
1393
|
+
* single Touch ID — the last prompt a hold->never bundle will ever raise (a
|
|
1394
|
+
* never->* flip reads silently, since the items are already no-ACL). No-op on the
|
|
1395
|
+
* ACL to write for non-keychain backends (file/vault have no biometry concept),
|
|
1396
|
+
* and a metadata-only write when the bundle has no keychain-backed values.
|
|
1397
|
+
*/
|
|
1398
|
+
export function reAclBundleItems(bundle) {
|
|
1399
|
+
if ((bundle.backend ?? 'keychain') !== 'keychain') {
|
|
1400
|
+
// No biometry ACL off the keychain backend — only metadata needs persisting.
|
|
1401
|
+
writeBundle(bundle);
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
const store = itemStore('keychain');
|
|
1405
|
+
// keychainItemsForBundle already returns ONLY keychain-backed value items
|
|
1406
|
+
// (via parseBundleValue), so no extra ref-shape filtering here.
|
|
1407
|
+
const entries = keychainItemsForBundle(bundle);
|
|
1408
|
+
if (entries.length === 0) {
|
|
1409
|
+
// Literal/ref-only bundle: nothing to re-ACL, just refresh metadata.
|
|
1410
|
+
writeBundle(bundle);
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
// One batched read = at most one Touch ID for the whole reconcile.
|
|
1414
|
+
const values = store.getBatch(entries.map((e) => e.item));
|
|
1415
|
+
const rewrite = new Map();
|
|
1416
|
+
for (const { item } of entries) {
|
|
1417
|
+
const value = values.get(item);
|
|
1418
|
+
// A key present in metadata but with no readable value item is real
|
|
1419
|
+
// corruption, not something to silently skip (no fallbacks — fail loud).
|
|
1420
|
+
if (value === undefined) {
|
|
1421
|
+
throw new Error(`Cannot change policy for '${bundle.name}': a keychain value is missing or unreadable. Rotate that key, then retry.`);
|
|
1422
|
+
}
|
|
1423
|
+
rewrite.set(item, value);
|
|
1424
|
+
}
|
|
1425
|
+
// writeBundleWithItems re-stores each value with { noAcl: policy === 'never' }
|
|
1426
|
+
// and the metadata no-ACL (metadata-last), and evicts any broker-held copy.
|
|
1427
|
+
writeBundleWithItems(bundle, rewrite);
|
|
1428
|
+
}
|
|
1379
1429
|
/**
|
|
1380
1430
|
* Rename a bundle: move metadata + every keychain-backed value to a new name.
|
|
1381
1431
|
*
|
|
@@ -18,6 +18,7 @@ import { createHash } from 'crypto';
|
|
|
18
18
|
import * as fs from 'fs';
|
|
19
19
|
import * as os from 'os';
|
|
20
20
|
import * as path from 'path';
|
|
21
|
+
import { copyAppBundle, withInstallLock } from '../app-bundle-install.js';
|
|
21
22
|
const APP_BUNDLE_NAME = 'Agents CLI.app';
|
|
22
23
|
const INSTALL_DIR_NAME = 'agents-cli';
|
|
23
24
|
let installRootOverride = null;
|
|
@@ -107,19 +108,6 @@ function spctlAssess(appPath) {
|
|
|
107
108
|
});
|
|
108
109
|
return { ok: r.status === 0, output: (r.stderr || r.stdout || '').toString().trim() };
|
|
109
110
|
}
|
|
110
|
-
function copyAppBundle(src, dest) {
|
|
111
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
112
|
-
if (fs.existsSync(dest))
|
|
113
|
-
fs.rmSync(dest, { recursive: true, force: true });
|
|
114
|
-
// `cp -R` preserves the bundle's signature, symlinks, and resource forks.
|
|
115
|
-
// `fs.cpSync({recursive: true})` works on simple trees but has historically
|
|
116
|
-
// mishandled extended attributes on `.app` bundles, breaking codesign.
|
|
117
|
-
const r = spawnSync('cp', ['-R', src, dest], { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' });
|
|
118
|
-
if (r.status !== 0) {
|
|
119
|
-
const msg = (r.stderr || r.stdout || '').toString().trim();
|
|
120
|
-
throw new Error(`Failed to copy ${src} -> ${dest}: ${msg || 'unknown error'}`);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
111
|
/**
|
|
124
112
|
* Idempotent install. Copies the bundled `.app` to the stable user path. Skips
|
|
125
113
|
* if the destination already exists, `codesign --verify` passes, AND the
|
|
@@ -135,25 +123,34 @@ function copyAppBundle(src, dest) {
|
|
|
135
123
|
export function ensureKeychainHelperInstalled(opts = {}) {
|
|
136
124
|
assertDarwin();
|
|
137
125
|
const dest = installedAppPath();
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
126
|
+
const upToDate = () => fs.existsSync(dest) && codesignVerify(dest).ok && !installedHelperIsStale();
|
|
127
|
+
// Fast path: already installed, valid, and current — no lock, no copy.
|
|
128
|
+
if (!opts.forceReinstall && upToDate())
|
|
129
|
+
return;
|
|
130
|
+
// Serialize the install so concurrent `agents` invocations don't race the
|
|
131
|
+
// atomic swap (and don't each run a redundant `cp`). The re-check inside the
|
|
132
|
+
// lock means a burst of callers copies once, not N times — the stampede that
|
|
133
|
+
// transiently corrupted the bundle and tripped the "damaged" dialog.
|
|
134
|
+
withInstallLock(dest, (heartbeat) => {
|
|
135
|
+
if (!opts.forceReinstall && upToDate())
|
|
141
136
|
return;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
137
|
+
const src = sourceAppPath();
|
|
138
|
+
copyAppBundle(src, dest);
|
|
139
|
+
heartbeat(); // cp -R done; keep the lock fresh across the codesign/spctl spawns
|
|
140
|
+
const verify = codesignVerify(dest);
|
|
141
|
+
if (!verify.ok) {
|
|
142
|
+
throw new Error(`Installed helper failed codesign verification at ${dest}.\n${verify.output}\n` +
|
|
143
|
+
'The bundle may be corrupted. Try `agents helper install` to reinstall, or reinstall agents-cli.');
|
|
144
|
+
}
|
|
145
|
+
heartbeat(); // codesign done; spctl does a network Gatekeeper lookup — refresh again
|
|
146
|
+
const assess = spctlAssess(dest);
|
|
147
|
+
if (!assess.ok) {
|
|
148
|
+
// Warn, do not fail. Gatekeeper ticket lookup needs network; offline
|
|
149
|
+
// installs and CI runners commonly fail this check. The ACL semantics
|
|
150
|
+
// we care about depend on codesign, not spctl.
|
|
151
|
+
process.stderr.write(`agents-cli: notarization check (spctl) did not pass for ${dest}: ${assess.output}\n`);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
157
154
|
}
|
|
158
155
|
/**
|
|
159
156
|
* Return the absolute path to the helper executable. If the installed bundle
|
|
@@ -32,12 +32,20 @@ export declare function readWriteTokenFromBundle(): string;
|
|
|
32
32
|
/** Read the raw write token from injected env first, then the local bundle.
|
|
33
33
|
* Throws with an actionable message if absent (run setup/join first). */
|
|
34
34
|
export declare function readWriteToken(): string;
|
|
35
|
-
/** Best-effort runtime env for spawned agents. Never throws
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
/** Best-effort runtime env for spawned agents. Never throws AND never prompts.
|
|
36
|
+
*
|
|
37
|
+
* Auto-injecting the share write token on every `agents run` is a background
|
|
38
|
+
* convenience, NOT a user-initiated secret access — so it MUST NOT raise a Touch
|
|
39
|
+
* ID sheet (SEC-13: an agent launch never pops biometry on its own). This was the
|
|
40
|
+
* per-run prompt storm: `share` is a keychain bundle that is rarely broker-held,
|
|
41
|
+
* so an interactive read here spawned the helper and popped Touch ID on EVERY
|
|
42
|
+
* launch. The read is now always `agentOnly` — it resolves the token only from the
|
|
43
|
+
* injected env or an already-held / no-ACL bundle, and silently returns undefined
|
|
44
|
+
* otherwise (the caller runs without auto-share; the agent can still publish via
|
|
45
|
+
* its own `agents share`). To get zero-friction auto-share with no prompt: unlock
|
|
46
|
+
* once (`agents secrets unlock share`) or make it no-ACL (`agents secrets policy
|
|
47
|
+
* share never`). */
|
|
48
|
+
export declare function shareRuntimeEnv(): Record<string, string> | undefined;
|
|
41
49
|
/** Cloudflare API credentials for provisioning, read from `cloudflare` (or a
|
|
42
50
|
* user-named bundle). Fuzzy-matches key names so it works across bundle layouts. */
|
|
43
51
|
export declare function readCloudflareCreds(bundle?: string, override?: {
|
package/dist/lib/share/config.js
CHANGED
|
@@ -56,6 +56,14 @@ export function storeWriteToken(token) {
|
|
|
56
56
|
bundle = {
|
|
57
57
|
name: SHARE_BUNDLE,
|
|
58
58
|
description: 'agents share — write token for the R2 share endpoint',
|
|
59
|
+
// A NEW share bundle defaults to the `never` tier (no biometry ACL). The R2
|
|
60
|
+
// write token is low-sensitivity automation infra that is auto-read on EVERY
|
|
61
|
+
// `agents run` (shareRuntimeEnv) — a biometry ACL there is what produced the
|
|
62
|
+
// per-run Touch ID storm. `never` stores it no-ACL so auto-share is silent
|
|
63
|
+
// and needs no unlock. An EXISTING bundle keeps its tier (we never silently
|
|
64
|
+
// downgrade one the user already made); change it explicitly with
|
|
65
|
+
// `agents secrets policy share <tier>`.
|
|
66
|
+
policy: 'never',
|
|
59
67
|
vars: {},
|
|
60
68
|
};
|
|
61
69
|
}
|
|
@@ -82,10 +90,20 @@ export function readWriteTokenFromBundle() {
|
|
|
82
90
|
export function readWriteToken() {
|
|
83
91
|
return readWriteTokenEnv() ?? readWriteTokenFromBundle();
|
|
84
92
|
}
|
|
85
|
-
/** Best-effort runtime env for spawned agents. Never throws
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
|
|
93
|
+
/** Best-effort runtime env for spawned agents. Never throws AND never prompts.
|
|
94
|
+
*
|
|
95
|
+
* Auto-injecting the share write token on every `agents run` is a background
|
|
96
|
+
* convenience, NOT a user-initiated secret access — so it MUST NOT raise a Touch
|
|
97
|
+
* ID sheet (SEC-13: an agent launch never pops biometry on its own). This was the
|
|
98
|
+
* per-run prompt storm: `share` is a keychain bundle that is rarely broker-held,
|
|
99
|
+
* so an interactive read here spawned the helper and popped Touch ID on EVERY
|
|
100
|
+
* launch. The read is now always `agentOnly` — it resolves the token only from the
|
|
101
|
+
* injected env or an already-held / no-ACL bundle, and silently returns undefined
|
|
102
|
+
* otherwise (the caller runs without auto-share; the agent can still publish via
|
|
103
|
+
* its own `agents share`). To get zero-friction auto-share with no prompt: unlock
|
|
104
|
+
* once (`agents secrets unlock share`) or make it no-ACL (`agents secrets policy
|
|
105
|
+
* share never`). */
|
|
106
|
+
export function shareRuntimeEnv() {
|
|
89
107
|
if (!readShareConfig())
|
|
90
108
|
return undefined;
|
|
91
109
|
const fromEnv = readWriteTokenEnv();
|
|
@@ -97,7 +115,7 @@ export function shareRuntimeEnv(opts = {}) {
|
|
|
97
115
|
const { env } = readAndResolveBundleEnv(SHARE_BUNDLE, {
|
|
98
116
|
caller: 'share',
|
|
99
117
|
keys: [SHARE_TOKEN_KEY],
|
|
100
|
-
agentOnly:
|
|
118
|
+
agentOnly: true, // never raise a Touch ID sheet on an agent launch (SEC-13)
|
|
101
119
|
});
|
|
102
120
|
const token = env[SHARE_TOKEN_KEY];
|
|
103
121
|
return token ? { [SHARE_TOKEN_ENV_KEY]: token } : undefined;
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -764,6 +764,16 @@ export interface Meta {
|
|
|
764
764
|
*/
|
|
765
765
|
isolatedAgents?: Partial<Record<AgentId, string>>;
|
|
766
766
|
run?: RunConfig;
|
|
767
|
+
/**
|
|
768
|
+
* Daemon watchdog config. `rotate` (default `on`) lets the watchdog rotate a
|
|
769
|
+
* rate-limited session IN PLACE onto a healthy account/harness via
|
|
770
|
+
* `agents run auto` — see lib/watchdog/rotate.ts. Set `off` to keep the
|
|
771
|
+
* nudge-only behavior (the Factory `agents.watchdog.autoRotate: false`
|
|
772
|
+
* migration writes `off` here).
|
|
773
|
+
*/
|
|
774
|
+
watchdog?: {
|
|
775
|
+
rotate?: 'on' | 'off';
|
|
776
|
+
};
|
|
767
777
|
/**
|
|
768
778
|
* `agents run --lease` config. `secretsBundle` names the keychain secrets bundle
|
|
769
779
|
* whose provider token (e.g. `HCLOUD_TOKEN`) crabbox uses to reach the cloud API.
|