@phnx-labs/agents-cli 1.20.77 → 1.20.78
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 +102 -0
- package/README.md +2 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/doctor.d.ts +9 -0
- package/dist/commands/doctor.js +143 -9
- package/dist/commands/exec.js +29 -3
- package/dist/commands/repo.js +8 -0
- package/dist/commands/routines.js +4 -2
- package/dist/commands/secrets.js +8 -2
- package/dist/commands/sessions-browser.d.ts +45 -3
- package/dist/commands/sessions-browser.js +118 -12
- package/dist/commands/sessions-export.d.ts +3 -0
- package/dist/commands/sessions-export.js +13 -10
- package/dist/commands/sessions-picker.d.ts +15 -1
- package/dist/commands/sessions-picker.js +58 -4
- package/dist/commands/sessions.d.ts +95 -1
- package/dist/commands/sessions.js +224 -26
- package/dist/commands/ssh.js +9 -1
- package/dist/index.js +3 -5
- package/dist/lib/agents.d.ts +0 -21
- package/dist/lib/agents.js +0 -37
- package/dist/lib/auto-pull.d.ts +16 -8
- package/dist/lib/auto-pull.js +23 -28
- package/dist/lib/daemon.d.ts +9 -41
- package/dist/lib/daemon.js +16 -117
- package/dist/lib/devices/fleet-divergence.d.ts +101 -0
- package/dist/lib/devices/fleet-divergence.js +188 -0
- package/dist/lib/devices/fleet-inventory.d.ts +19 -0
- package/dist/lib/devices/fleet-inventory.js +57 -0
- package/dist/lib/devices/health-report.d.ts +10 -2
- package/dist/lib/devices/health-report.js +32 -1
- package/dist/lib/git.d.ts +13 -0
- package/dist/lib/git.js +102 -4
- package/dist/lib/hosts/remote-cmd.js +2 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
- package/dist/lib/runner.js +29 -26
- package/dist/lib/sandbox.js +0 -16
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
- package/dist/lib/secrets/agent.d.ts +2 -0
- package/dist/lib/secrets/agent.js +26 -14
- package/dist/lib/secrets/bundles.d.ts +1 -1
- package/dist/lib/secrets/bundles.js +16 -8
- package/dist/lib/secrets/scope.d.ts +26 -0
- package/dist/lib/secrets/scope.js +29 -0
- package/dist/lib/secrets/session-store.d.ts +10 -0
- package/dist/lib/secrets/session-store.js +64 -11
- package/dist/lib/session/active.d.ts +7 -0
- package/dist/lib/session/active.js +38 -3
- package/dist/lib/session/db.d.ts +37 -0
- package/dist/lib/session/db.js +112 -4
- package/dist/lib/session/digest.js +126 -21
- package/dist/lib/session/discover.d.ts +15 -2
- package/dist/lib/session/discover.js +19 -11
- package/dist/lib/session/origin-machine.d.ts +18 -0
- package/dist/lib/session/origin-machine.js +34 -0
- package/dist/lib/session/state.d.ts +1 -0
- package/dist/lib/session/state.js +1 -1
- package/dist/lib/session/sync/config.js +2 -2
- package/dist/lib/smart-launch.d.ts +86 -0
- package/dist/lib/smart-launch.js +172 -0
- package/package.json +1 -1
- package/dist/lib/secrets/account-token.d.ts +0 -20
- package/dist/lib/secrets/account-token.js +0 -64
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type DeviceStats } from './health.js';
|
|
2
2
|
import { type HostAuthSummary } from '../auth-health.js';
|
|
3
3
|
import type { OnlineState } from './reachability.js';
|
|
4
|
+
import { type FleetInventory } from './fleet-divergence.js';
|
|
4
5
|
export interface FleetCliStatus {
|
|
5
6
|
installed: boolean;
|
|
6
7
|
path: string | null;
|
|
@@ -40,9 +41,14 @@ export interface FleetHealthRow {
|
|
|
40
41
|
* an offline row. Sourced from the registry's tailscale snapshot / reachability
|
|
41
42
|
* verdict. Undefined when never recorded. */
|
|
42
43
|
lastSeen?: string;
|
|
44
|
+
/** This host's self-reported harness inventory (resources / agent versions /
|
|
45
|
+
* repo state) from its `doctor --json` `fleet` field, for cross-device
|
|
46
|
+
* divergence detection (RUSH-2027). Undefined for an unreachable box or an
|
|
47
|
+
* older CLI that doesn't emit it. */
|
|
48
|
+
inventory?: FleetInventory;
|
|
43
49
|
}
|
|
44
50
|
export interface FleetWarning {
|
|
45
|
-
kind: 'unreachable' | 'drift' | 'cli' | 'version-skew';
|
|
51
|
+
kind: 'unreachable' | 'drift' | 'cli' | 'version-skew' | 'divergence';
|
|
46
52
|
devices: string[];
|
|
47
53
|
message: string;
|
|
48
54
|
}
|
|
@@ -53,7 +59,9 @@ export interface FleetHealthReport {
|
|
|
53
59
|
hasWarnings: boolean;
|
|
54
60
|
hasDrift: boolean;
|
|
55
61
|
}
|
|
56
|
-
export declare function buildFleetHealthReport(rows: FleetHealthRow[], now?: Date
|
|
62
|
+
export declare function buildFleetHealthReport(rows: FleetHealthRow[], now?: Date, opts?: {
|
|
63
|
+
self?: string;
|
|
64
|
+
}): FleetHealthReport;
|
|
57
65
|
export declare function renderFleetWarnings(report: FleetHealthReport): string[];
|
|
58
66
|
export declare function renderFleetMatrix(report: FleetHealthReport): string[];
|
|
59
67
|
/**
|
|
@@ -2,6 +2,7 @@ import chalk from 'chalk';
|
|
|
2
2
|
import { padToWidth, stringWidth, terminalWidth, truncateToWidth } from '../session/width.js';
|
|
3
3
|
import { fmtBytes, headroom } from './health.js';
|
|
4
4
|
import { formatCheckedAge } from '../auth-health.js';
|
|
5
|
+
import { compareFleetInventories } from './fleet-divergence.js';
|
|
5
6
|
function driftRows(row) {
|
|
6
7
|
return row.sync.filter((s) => s.status !== 'fresh');
|
|
7
8
|
}
|
|
@@ -12,7 +13,7 @@ function installedCliCount(row) {
|
|
|
12
13
|
total: statuses.length,
|
|
13
14
|
};
|
|
14
15
|
}
|
|
15
|
-
export function buildFleetHealthReport(rows, now = new Date()) {
|
|
16
|
+
export function buildFleetHealthReport(rows, now = new Date(), opts = {}) {
|
|
16
17
|
const warnings = [];
|
|
17
18
|
const unreachable = rows
|
|
18
19
|
.filter((r) => r.error || r.skipped)
|
|
@@ -60,6 +61,25 @@ export function buildFleetHealthReport(rows, now = new Date()) {
|
|
|
60
61
|
message: `agents-cli version skew: ${Array.from(versions.keys()).sort().join(', ')}`,
|
|
61
62
|
});
|
|
62
63
|
}
|
|
64
|
+
// Cross-device harness divergence (RUSH-2027): compare every device's
|
|
65
|
+
// self-reported inventory against the local baseline and roll the findings up
|
|
66
|
+
// into one warning per affected device. Only runs when a baseline (`self`) is
|
|
67
|
+
// named and at least one device carries an inventory — an older-CLI fleet with
|
|
68
|
+
// no `fleet` field simply produces no divergence warning.
|
|
69
|
+
if (opts.self && rows.some((r) => r.inventory)) {
|
|
70
|
+
const divergence = compareFleetInventories(rows.map((r) => ({ name: r.name, inventory: r.inventory ?? null })), opts.self);
|
|
71
|
+
const byDevice = new Map();
|
|
72
|
+
for (const d of divergence.divergences)
|
|
73
|
+
byDevice.set(d.device, (byDevice.get(d.device) ?? 0) + 1);
|
|
74
|
+
for (const device of Array.from(byDevice.keys()).sort()) {
|
|
75
|
+
const n = byDevice.get(device);
|
|
76
|
+
warnings.push({
|
|
77
|
+
kind: 'divergence',
|
|
78
|
+
devices: [device],
|
|
79
|
+
message: `${device} diverges from ${opts.self} (${n} resource/version/repo gap${n === 1 ? '' : 's'})`,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
63
83
|
return {
|
|
64
84
|
generatedAt: now.toISOString(),
|
|
65
85
|
devices: rows,
|
|
@@ -326,6 +346,17 @@ export function buildFleetAttentionItems(report, now = Date.now()) {
|
|
|
326
346
|
.join(' · ');
|
|
327
347
|
items.push({ glyph: 'warn', subject: 'version skew', detail: summary, fix: 'agents upgrade --fleet' });
|
|
328
348
|
}
|
|
349
|
+
// 4) Cross-device harness divergence — one item per diverged box (RUSH-2027).
|
|
350
|
+
for (const w of report.warnings) {
|
|
351
|
+
if (w.kind !== 'divergence')
|
|
352
|
+
continue;
|
|
353
|
+
items.push({
|
|
354
|
+
glyph: 'warn',
|
|
355
|
+
subject: w.devices[0] ?? 'fleet',
|
|
356
|
+
detail: w.message.replace(`${w.devices[0]} `, ''),
|
|
357
|
+
fix: `agents apply ${w.devices[0] ?? ''}`.trim(),
|
|
358
|
+
});
|
|
359
|
+
}
|
|
329
360
|
return items;
|
|
330
361
|
}
|
|
331
362
|
/** Right-aligned `<content>` on the same line as `left`, clamped so it never
|
package/dist/lib/git.d.ts
CHANGED
|
@@ -86,6 +86,19 @@ export declare function clonePackage(source: string): Promise<{
|
|
|
86
86
|
}>;
|
|
87
87
|
/** Get the short commit hash (8 chars) of the latest commit in a repo. */
|
|
88
88
|
export declare function getRepoCommit(repoPath: string): Promise<string>;
|
|
89
|
+
/** Compact, self-contained state of a git repo — branch, short HEAD, and a
|
|
90
|
+
* dirty flag — for cross-device comparison (RUSH-2027). Synchronous and
|
|
91
|
+
* best-effort: a non-repo or unreadable path yields `null` fields, never a
|
|
92
|
+
* throw, so a device's `doctor --json` payload always serializes. */
|
|
93
|
+
export interface RepoStateSnapshot {
|
|
94
|
+
branch: string | null;
|
|
95
|
+
head: string | null;
|
|
96
|
+
dirty: boolean;
|
|
97
|
+
}
|
|
98
|
+
/** Read {@link RepoStateSnapshot} for `repoPath` using plumbing commands so the
|
|
99
|
+
* result is stable across git versions and never mutates the tree. Returns null
|
|
100
|
+
* when the path is not a git worktree. */
|
|
101
|
+
export declare function readRepoState(repoPath: string): RepoStateSnapshot | null;
|
|
89
102
|
/**
|
|
90
103
|
* Get the current GitHub username using gh CLI.
|
|
91
104
|
* Returns null if gh is not installed or user is not authenticated.
|
package/dist/lib/git.js
CHANGED
|
@@ -334,6 +334,34 @@ export async function getRepoCommit(repoPath) {
|
|
|
334
334
|
return 'unknown';
|
|
335
335
|
}
|
|
336
336
|
}
|
|
337
|
+
/** Read {@link RepoStateSnapshot} for `repoPath` using plumbing commands so the
|
|
338
|
+
* result is stable across git versions and never mutates the tree. Returns null
|
|
339
|
+
* when the path is not a git worktree. */
|
|
340
|
+
export function readRepoState(repoPath) {
|
|
341
|
+
const runGit = (args) => {
|
|
342
|
+
try {
|
|
343
|
+
return execFileSync('git', ['-C', repoPath, ...args], {
|
|
344
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
345
|
+
})
|
|
346
|
+
.toString()
|
|
347
|
+
.trim();
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
// Gate on a real worktree first; `rev-parse --is-inside-work-tree` prints
|
|
354
|
+
// `true` only inside one, and returns non-zero (→ null) otherwise.
|
|
355
|
+
if (runGit(['rev-parse', '--is-inside-work-tree']) !== 'true')
|
|
356
|
+
return null;
|
|
357
|
+
const branchRaw = runGit(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
358
|
+
const branch = branchRaw && branchRaw !== 'HEAD' ? branchRaw : null; // detached → null
|
|
359
|
+
const headRaw = runGit(['rev-parse', 'HEAD']);
|
|
360
|
+
const head = headRaw ? headRaw.slice(0, 8) : null;
|
|
361
|
+
const porcelain = runGit(['status', '--porcelain']);
|
|
362
|
+
const dirty = porcelain != null && porcelain.length > 0;
|
|
363
|
+
return { branch, head, dirty };
|
|
364
|
+
}
|
|
337
365
|
/**
|
|
338
366
|
* Get the current GitHub username using gh CLI.
|
|
339
367
|
* Returns null if gh is not installed or user is not authenticated.
|
|
@@ -813,6 +841,39 @@ export async function commitOwnDeviceMeta(dir, metaAbs = getDeviceMetaPath()) {
|
|
|
813
841
|
export async function pullRepo(dir) {
|
|
814
842
|
try {
|
|
815
843
|
const git = simpleGit(dir);
|
|
844
|
+
// A rebase left in progress by an earlier run must be reported as itself.
|
|
845
|
+
// Without this the dirty-tree guard below claims "Blocked by local changes",
|
|
846
|
+
// which is both wrong and actively harmful advice mid-rebase on a detached
|
|
847
|
+
// HEAD. Checked BEFORE commitOwnDeviceMeta so we never commit into one.
|
|
848
|
+
// Ask git where the state dirs live rather than assuming `<dir>/.git/` is a
|
|
849
|
+
// directory. In a worktree `.git` is a FILE containing `gitdir: <path>`, so
|
|
850
|
+
// path.join(dir, '.git', 'rebase-merge') can never exist and the check would
|
|
851
|
+
// silently never fire. `rev-parse --git-path` resolves both layouts.
|
|
852
|
+
const gitPath = async (name) => {
|
|
853
|
+
try {
|
|
854
|
+
const raw = (await git.raw(['rev-parse', '--git-path', name])).trim();
|
|
855
|
+
return path.isAbsolute(raw) ? raw : path.join(dir, raw);
|
|
856
|
+
}
|
|
857
|
+
catch {
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
};
|
|
861
|
+
const [rebaseMerge, rebaseApply] = await Promise.all([
|
|
862
|
+
gitPath('rebase-merge'),
|
|
863
|
+
gitPath('rebase-apply'),
|
|
864
|
+
]);
|
|
865
|
+
const rebaseInProgress = (rebaseMerge !== null && fs.existsSync(rebaseMerge)) ||
|
|
866
|
+
(rebaseApply !== null && fs.existsSync(rebaseApply));
|
|
867
|
+
if (rebaseInProgress) {
|
|
868
|
+
return {
|
|
869
|
+
success: false,
|
|
870
|
+
commit: '',
|
|
871
|
+
error: `A previous rebase is still in progress — finish or abort it, then pull again.\n\n` +
|
|
872
|
+
` cd ${displayHomePath(dir)} && git status\n` +
|
|
873
|
+
` git rebase --continue # after resolving\n` +
|
|
874
|
+
` git rebase --abort # to discard the attempt`,
|
|
875
|
+
};
|
|
876
|
+
}
|
|
816
877
|
// Commit this machine's own device-meta first so a per-machine pin change
|
|
817
878
|
// never wedges the pull. Genuine edits elsewhere still block below.
|
|
818
879
|
await commitOwnDeviceMeta(dir);
|
|
@@ -825,12 +886,15 @@ export async function pullRepo(dir) {
|
|
|
825
886
|
};
|
|
826
887
|
}
|
|
827
888
|
const branch = status.current || 'main';
|
|
828
|
-
await git.fetch('origin');
|
|
829
889
|
// Resolve the upstream ref to fast-forward against. Prefer the local
|
|
830
890
|
// branch's tracking config; otherwise ask origin for its default branch.
|
|
831
891
|
let tracking = status.tracking;
|
|
832
892
|
if (!tracking) {
|
|
893
|
+
// No tracking config: fetch origin so its HEAD is known, then ask which
|
|
894
|
+
// branch it points at. This path only ever concerns origin — a branch with
|
|
895
|
+
// no upstream has no other remote to consult.
|
|
833
896
|
try {
|
|
897
|
+
await git.fetch('origin');
|
|
834
898
|
await git.raw(['remote', 'set-head', 'origin', '--auto']);
|
|
835
899
|
const sym = await git.raw(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
|
|
836
900
|
tracking = sym.trim();
|
|
@@ -839,6 +903,22 @@ export async function pullRepo(dir) {
|
|
|
839
903
|
tracking = `origin/${branch}`;
|
|
840
904
|
}
|
|
841
905
|
}
|
|
906
|
+
// Split the remote-tracking ref (<remote>/<branch>) into its parts and pull
|
|
907
|
+
// THOSE. Hardcoding 'origin' while comparing against `tracking` is how a
|
|
908
|
+
// branch tracking e.g. upstream/main silently 'succeeded': revparse saw a
|
|
909
|
+
// difference, the pull fetched origin/main (already current), and pullRepo
|
|
910
|
+
// returned success having moved nothing — the same "reported ok, pulled
|
|
911
|
+
// nothing" failure this change exists to remove. Branch names may contain
|
|
912
|
+
// slashes, so split on the FIRST separator only.
|
|
913
|
+
const sep = tracking.indexOf('/');
|
|
914
|
+
const remoteName = sep > 0 ? tracking.slice(0, sep) : 'origin';
|
|
915
|
+
const remoteBranch = sep > 0 ? tracking.slice(sep + 1) : branch;
|
|
916
|
+
// Bare fetch: updates every remote, so the revparse below sees a fresh ref
|
|
917
|
+
// whichever one the branch tracks. Deliberately argument-less — simple-git's
|
|
918
|
+
// fetchTask only forwards a remote when BOTH remote and branch are passed,
|
|
919
|
+
// so `fetch(remoteName)` would silently drop the argument and do exactly
|
|
920
|
+
// this anyway. Saying so beats an inert argument that reads as targeted.
|
|
921
|
+
await git.fetch();
|
|
842
922
|
const localRef = await git.revparse(['HEAD']);
|
|
843
923
|
const remoteRef = await git.revparse([tracking]).catch(() => null);
|
|
844
924
|
if (!remoteRef) {
|
|
@@ -853,13 +933,31 @@ export async function pullRepo(dir) {
|
|
|
853
933
|
};
|
|
854
934
|
}
|
|
855
935
|
try {
|
|
856
|
-
|
|
936
|
+
// Rebase, not --ff-only. Fast-forward refuses ANY divergence, conflict or
|
|
937
|
+
// not, so a single local commit — including the one commitOwnDeviceMeta
|
|
938
|
+
// makes just above — permanently wedged the pull with nothing actually in
|
|
939
|
+
// conflict. Every device carries its own devices/<host>/ path, so those
|
|
940
|
+
// replay cleanly. Matches syncRepoGit (below) and this function's own doc.
|
|
941
|
+
//
|
|
942
|
+
// Pull the RESOLVED tracking ref, not `branch`: when the local branch has
|
|
943
|
+
// no tracking config the block above falls back to origin's default head,
|
|
944
|
+
// which may be named differently (local `main` vs origin `master`). Using
|
|
945
|
+
// `branch` there asks origin for a ref it does not have.
|
|
946
|
+
assertValidBranchName(remoteBranch);
|
|
947
|
+
await git.pull(remoteName, remoteBranch, { '--rebase': 'true' });
|
|
857
948
|
}
|
|
858
|
-
catch {
|
|
949
|
+
catch (err) {
|
|
950
|
+
// Abort so the tree is restored, matching the atomicity --ff-only gave us.
|
|
951
|
+
// Without this a conflict leaves the repo detached, mid-rebase, with
|
|
952
|
+
// conflict markers written into live config (this repo is ~/.agents —
|
|
953
|
+
// agents.yaml and AGENTS.md are in it), and every later pull misreports
|
|
954
|
+
// the cause. `agents sync` reaches this path unattended across the fleet,
|
|
955
|
+
// so a wedged checkout would be worse than the bug this fixes.
|
|
956
|
+
await git.raw(['rebase', '--abort']).catch(() => { });
|
|
859
957
|
return {
|
|
860
958
|
success: false,
|
|
861
959
|
commit: '',
|
|
862
|
-
error: `
|
|
960
|
+
error: `Rebase onto ${tracking} hit a conflict — the pull was rolled back, nothing changed.\n\nResolve the divergence, then pull again:\n\n cd ${displayHomePath(dir)} && git log --oneline HEAD...${tracking}\n\n${err.message}`,
|
|
863
961
|
};
|
|
864
962
|
}
|
|
865
963
|
installGithooksSymlinks(dir);
|
|
@@ -116,6 +116,8 @@ export const RUN_OPTION_FORWARDING = {
|
|
|
116
116
|
tailscale: 'local-only', // --tailscale/--no-tailscale gate the lease net mode; never forwarded
|
|
117
117
|
copyCreds: 'local-only', // copies creds TO the host before dispatch — local concern only
|
|
118
118
|
authCheck: 'local-only', // --no-auth-check gates the local interactive login preflight; --host runs skip that preflight entirely
|
|
119
|
+
// Deprecated alias for --device auto; resolved on the launching box before SSH.
|
|
120
|
+
smart: 'local-only',
|
|
119
121
|
};
|
|
120
122
|
/** Actionable messages for value-aware rejections, keyed by attribute name. */
|
|
121
123
|
export const RUN_OPTION_REJECT_MESSAGES = {
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
<string>com.phnx-labs.agents-menubar</string>
|
|
9
9
|
<key>CFBundleName</key>
|
|
10
10
|
<string>Agents Menu Bar</string>
|
|
11
|
+
<key>CFBundleIconFile</key>
|
|
12
|
+
<string>AppIcon</string>
|
|
11
13
|
<key>CFBundlePackageType</key>
|
|
12
14
|
<string>APPL</string>
|
|
13
15
|
<key>CFBundleShortVersionString</key>
|
|
Binary file
|
|
@@ -3,9 +3,22 @@
|
|
|
3
3
|
<plist version="1.0">
|
|
4
4
|
<dict>
|
|
5
5
|
<key>files</key>
|
|
6
|
-
<dict
|
|
6
|
+
<dict>
|
|
7
|
+
<key>Resources/AppIcon.icns</key>
|
|
8
|
+
<data>
|
|
9
|
+
Vd5nfogg74zSRNspluv4uvDecUA=
|
|
10
|
+
</data>
|
|
11
|
+
</dict>
|
|
7
12
|
<key>files2</key>
|
|
8
|
-
<dict
|
|
13
|
+
<dict>
|
|
14
|
+
<key>Resources/AppIcon.icns</key>
|
|
15
|
+
<dict>
|
|
16
|
+
<key>hash2</key>
|
|
17
|
+
<data>
|
|
18
|
+
DznVe0VgYOux7+B/aiHNgGYLCvSJKzgXDTPJn2jNkNg=
|
|
19
|
+
</data>
|
|
20
|
+
</dict>
|
|
21
|
+
</dict>
|
|
9
22
|
<key>rules</key>
|
|
10
23
|
<dict>
|
|
11
24
|
<key>^Resources/</key>
|
package/dist/lib/runner.js
CHANGED
|
@@ -27,9 +27,7 @@ import { loadTask as loadHostTask } from './hosts/tasks.js';
|
|
|
27
27
|
import { reconcileTask as reconcileHostTask } from './hosts/reconcile.js';
|
|
28
28
|
import { backgroundSpawnOptions } from './platform/process.js';
|
|
29
29
|
import { walkForFiles } from './fs-walk.js';
|
|
30
|
-
import { getBinaryPath,
|
|
31
|
-
import { claudeHomeHasOwnCredential } from './agents.js';
|
|
32
|
-
import { resolveAccountSetupToken } from './secrets/account-token.js';
|
|
30
|
+
import { getBinaryPath, isVersionInstalled, resolveVersion } from './versions.js';
|
|
33
31
|
import { getConfiguredRunStrategy, resolveRunVersion, resolveAccountVersion, rotationFailoverChain, readinessFromCandidate, } from './rotate.js';
|
|
34
32
|
import { readAuthHealth, isDeadVerdict } from './auth-health.js';
|
|
35
33
|
import { machineId } from './machine-id.js';
|
|
@@ -400,29 +398,12 @@ export function buildRoutineSpawnEnv(baseEnv, agent, version, timezone) {
|
|
|
400
398
|
if (v !== undefined)
|
|
401
399
|
out[k] = v;
|
|
402
400
|
}
|
|
403
|
-
// A
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
// CLAUDE_CODE_OAUTH_TOKEN_<slug> (from a headless-readable no-ACL bundle), use it;
|
|
410
|
-
// that also works on macOS, where the drop path below is inert
|
|
411
|
-
// (claudeHomeHasOwnCredential is false on darwin, agents.ts).
|
|
412
|
-
//
|
|
413
|
-
// Fallback (RUSH-1979): with no per-account token, keep the prior behavior — drop
|
|
414
|
-
// the single ambient CLAUDE_CODE_OAUTH_TOKEN when the pinned account has its own
|
|
415
|
-
// on-disk credential (Linux) so it isn't shadowed; keep it otherwise.
|
|
416
|
-
if (agent === 'claude' && version && out.CLAUDE_CONFIG_DIR) {
|
|
417
|
-
const home = getVersionHomePath('claude', version);
|
|
418
|
-
const accountToken = resolveAccountSetupToken(baseEnv, home);
|
|
419
|
-
if (accountToken) {
|
|
420
|
-
out.CLAUDE_CODE_OAUTH_TOKEN = accountToken;
|
|
421
|
-
}
|
|
422
|
-
else if (claudeHomeHasOwnCredential(home)) {
|
|
423
|
-
delete out.CLAUDE_CODE_OAUTH_TOKEN;
|
|
424
|
-
}
|
|
425
|
-
}
|
|
401
|
+
// No Claude token is injected here. A routine authenticates through the pinned
|
|
402
|
+
// account's own CLAUDE_CONFIG_DIR login (buildExecEnv points it at the
|
|
403
|
+
// per-account version home), identical to interactive `agents run`. Claude
|
|
404
|
+
// Code's interactive session refreshes itself per-device; keeping the daemon
|
|
405
|
+
// out of the credential entirely is what avoids the fleet-wide rotation logout
|
|
406
|
+
// — a shared/rotating token was the cause, not the fix.
|
|
426
407
|
if (timezone)
|
|
427
408
|
out.TZ = timezone;
|
|
428
409
|
return out;
|
|
@@ -1063,6 +1044,28 @@ export async function executeJobDetached(config, hooks) {
|
|
|
1063
1044
|
completedAt: null,
|
|
1064
1045
|
exitCode: null,
|
|
1065
1046
|
};
|
|
1047
|
+
// Auth preflight (mirrors executeJob): with no injected token, a daemon-fired
|
|
1048
|
+
// Claude routine authenticates via the pinned account's own CLAUDE_CONFIG_DIR
|
|
1049
|
+
// login. If the last live probe rejected that (agent, version)'s token, the run
|
|
1050
|
+
// is guaranteed to 401 — fail fast with a re-login hint instead of spawning a
|
|
1051
|
+
// doomed run + poisoned report. Cache-only; fails OPEN on any non-dead/missing
|
|
1052
|
+
// verdict, and agents with no live probe (codex/gemini/grok) are never blocked.
|
|
1053
|
+
const preflightVersion = launch.chain[0]?.version;
|
|
1054
|
+
if (preflightVersion) {
|
|
1055
|
+
const health = readAuthHealth(machineId(), effectiveAgent, preflightVersion);
|
|
1056
|
+
if (health && isDeadVerdict(health.verdict)) {
|
|
1057
|
+
const reason = `auth_preflight: ${health.verdict}`;
|
|
1058
|
+
process.stderr.write(`[agents] routine ${config.name}: ${effectiveAgent}@${preflightVersion} token ${health.verdict} — skipping run (re-login required)\n`);
|
|
1059
|
+
try {
|
|
1060
|
+
fs.closeSync(stdoutFd);
|
|
1061
|
+
}
|
|
1062
|
+
catch { /* already closed */ }
|
|
1063
|
+
finalizeRunMeta(meta, 'failed', 1, { errorMessage: reason });
|
|
1064
|
+
writeRunMeta(meta);
|
|
1065
|
+
archiveRoutineTranscripts(meta, runDir, overlayHome);
|
|
1066
|
+
return meta;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1066
1069
|
const child = spawn(cmd[0], cmd.slice(1), {
|
|
1067
1070
|
stdio: ['ignore', stdoutFd, stdoutFd],
|
|
1068
1071
|
...backgroundSpawnOptions({ fdStdio: true }),
|
package/dist/lib/sandbox.js
CHANGED
|
@@ -44,12 +44,6 @@ const ENV_ALLOWLIST = [
|
|
|
44
44
|
'VISUAL',
|
|
45
45
|
'NO_COLOR',
|
|
46
46
|
'FORCE_COLOR',
|
|
47
|
-
// Headless Claude auth: the routines daemon injects CLAUDE_CODE_OAUTH_TOKEN
|
|
48
|
-
// from the `claude` secrets bundle (see daemon.ts). Without this allowlist
|
|
49
|
-
// entry, sandboxed routine spawns drop the token and look "unconfigured".
|
|
50
|
-
// Intentionally NOT ANTHROPIC_API_KEY / other provider secrets — those stay
|
|
51
|
-
// stripped (see tests/sandbox.test.ts "does not include sensitive env vars").
|
|
52
|
-
'CLAUDE_CODE_OAUTH_TOKEN',
|
|
53
47
|
];
|
|
54
48
|
/** Tools safe to grant as wildcards (no filesystem access). */
|
|
55
49
|
const SAFE_TOOLS = {
|
|
@@ -75,16 +69,6 @@ export function buildSpawnEnv(overlayHome, extraEnv) {
|
|
|
75
69
|
env[key] = process.env[key];
|
|
76
70
|
}
|
|
77
71
|
}
|
|
78
|
-
// Per-account Claude setup-tokens: the daemon injects a CLAUDE_CODE_OAUTH_TOKEN_<slug>
|
|
79
|
-
// for each account so a routine authenticates its rotation-pinned account via a
|
|
80
|
-
// long-lived, non-rotating token (see runner.ts buildRoutineSpawnEnv). Forward every
|
|
81
|
-
// such key by prefix — same trust tier as CLAUDE_CODE_OAUTH_TOKEN above; still no API
|
|
82
|
-
// keys or other provider secrets.
|
|
83
|
-
for (const key of Object.keys(process.env)) {
|
|
84
|
-
if (key.startsWith('CLAUDE_CODE_OAUTH_TOKEN_') && process.env[key]) {
|
|
85
|
-
env[key] = process.env[key];
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
72
|
if (extraEnv) {
|
|
89
73
|
Object.assign(env, extraEnv);
|
|
90
74
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -3,9 +3,21 @@
|
|
|
3
3
|
<plist version="1.0">
|
|
4
4
|
<dict>
|
|
5
5
|
<key>files</key>
|
|
6
|
-
<dict
|
|
6
|
+
<dict>
|
|
7
|
+
<key>Resources/AppIcon.icns</key>
|
|
8
|
+
<data>
|
|
9
|
+
Vd5nfogg74zSRNspluv4uvDecUA=
|
|
10
|
+
</data>
|
|
11
|
+
</dict>
|
|
7
12
|
<key>files2</key>
|
|
8
13
|
<dict>
|
|
14
|
+
<key>Resources/AppIcon.icns</key>
|
|
15
|
+
<dict>
|
|
16
|
+
<key>hash2</key>
|
|
17
|
+
<data>
|
|
18
|
+
DznVe0VgYOux7+B/aiHNgGYLCvSJKzgXDTPJn2jNkNg=
|
|
19
|
+
</data>
|
|
20
|
+
</dict>
|
|
9
21
|
<key>embedded.provisionprofile</key>
|
|
10
22
|
<dict>
|
|
11
23
|
<key>hash2</key>
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import * as net from 'net';
|
|
27
27
|
import type { SecretsBundle } from './bundles.js';
|
|
28
|
+
import { GLOBAL_HARNESS, bundleScopeChain } from './scope.js';
|
|
29
|
+
export { GLOBAL_HARNESS, bundleScopeChain };
|
|
28
30
|
/** Default lifetime of an unlocked bundle when `--ttl` is not given. */
|
|
29
31
|
export declare const DEFAULT_TTL_MS: number;
|
|
30
32
|
/**
|
|
@@ -34,8 +34,14 @@ 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 { GLOBAL_HARNESS, bundleScopeChain } from './scope.js';
|
|
37
38
|
import { rehydrateSessions, pruneSessionsOnSleep } from './session-store.js';
|
|
38
39
|
import { SYNC_GET_CMD, SYNC_PING_CMD, SYNC_LOCK_CMD } from './sync-commands.js';
|
|
40
|
+
// Re-exported so callers already reaching for agent.js keep one obvious home for
|
|
41
|
+
// the scope vocabulary; the definitions live in the leaf module scope.ts because
|
|
42
|
+
// agent.ts and session-store.ts import each other and a cyclic `const` read can
|
|
43
|
+
// hit the ESM temporal dead zone at runtime.
|
|
44
|
+
export { GLOBAL_HARNESS, bundleScopeChain };
|
|
39
45
|
/** Bumped when the wire protocol changes; a client that pings a mismatched
|
|
40
46
|
* server kills and respawns it rather than talking a stale dialect. */
|
|
41
47
|
const PROTOCOL_VERSION = 2;
|
|
@@ -133,7 +139,7 @@ function onDarwin() {
|
|
|
133
139
|
function rehydrateStore(now = Date.now()) {
|
|
134
140
|
const store = new Map();
|
|
135
141
|
for (const { name, entry } of rehydrateSessions(now)) {
|
|
136
|
-
const harness = entry.harness ||
|
|
142
|
+
const harness = entry.harness || GLOBAL_HARNESS;
|
|
137
143
|
store.set(scopedBundleKey(name, harness), { bundle: entry.bundle, env: entry.env, expiresAt: entry.expiresAt, harness });
|
|
138
144
|
}
|
|
139
145
|
return store;
|
|
@@ -309,17 +315,23 @@ export function handleAgentRequest(store, req, now = Date.now()) {
|
|
|
309
315
|
// the broker is running pre-upgrade code and should be restarted.
|
|
310
316
|
return { ok: true, cmd: 'ping', version: PROTOCOL_VERSION, cliVersion: getCliVersion() };
|
|
311
317
|
case 'get': {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
+
// Walk own-harness → global so a `--for` grant wins over a global one and
|
|
319
|
+
// an unscoped unlock serves every harness (bundleScopeChain).
|
|
320
|
+
for (const scope of bundleScopeChain(req.harness)) {
|
|
321
|
+
const key = scopedBundleKey(req.name, scope);
|
|
322
|
+
const e = store.get(key);
|
|
323
|
+
if (!e)
|
|
324
|
+
continue;
|
|
325
|
+
if (now >= e.expiresAt) {
|
|
326
|
+
store.delete(key);
|
|
327
|
+
continue;
|
|
328
|
+
} // drop expired on read
|
|
329
|
+
return { ok: true, cmd: 'get', hit: true, bundle: e.bundle, env: e.env };
|
|
318
330
|
}
|
|
319
|
-
return { ok: true, cmd: 'get', hit:
|
|
331
|
+
return { ok: true, cmd: 'get', hit: false };
|
|
320
332
|
}
|
|
321
333
|
case 'load':
|
|
322
|
-
const harness = req.harness ||
|
|
334
|
+
const harness = req.harness || GLOBAL_HARNESS;
|
|
323
335
|
store.set(scopedBundleKey(req.name, harness), { bundle: req.bundle, env: req.env, expiresAt: now + req.ttlMs, harness });
|
|
324
336
|
return { ok: true, cmd: 'load' };
|
|
325
337
|
case 'lock': {
|
|
@@ -834,7 +846,7 @@ function syncClient(sub, timeout) {
|
|
|
834
846
|
* null if the agent isn't running / doesn't hold this bundle / anything fails
|
|
835
847
|
* (soft — caller falls through to the real keychain). macOS only.
|
|
836
848
|
*/
|
|
837
|
-
export function agentGetSync(name, harness =
|
|
849
|
+
export function agentGetSync(name, harness = GLOBAL_HARNESS) {
|
|
838
850
|
if (!agentSocketExists())
|
|
839
851
|
return null;
|
|
840
852
|
const r = syncClient([SYNC_GET_CMD, name, harness], SYNC_GET_TIMEOUT_MS);
|
|
@@ -914,7 +926,7 @@ export function agentEvictSync(name) {
|
|
|
914
926
|
// so a cache hit never runs checkForUpdates() or forks a detached sync.
|
|
915
927
|
/** Body of `__secrets-get <name>`. Prints `{bundle, env}` as JSON on a
|
|
916
928
|
* cache hit. Exit 0 = hit, 3 = miss or broker down. */
|
|
917
|
-
export async function runAgentGetSync(name, harness =
|
|
929
|
+
export async function runAgentGetSync(name, harness = GLOBAL_HARNESS) {
|
|
918
930
|
const r = await request({ cmd: 'get', name, harness }, SOCKET_GET_TIMEOUT_MS);
|
|
919
931
|
if (r?.ok === true && r.cmd === 'get' && r.hit) {
|
|
920
932
|
// Trailing newline: the parent reads the LAST line (see lastLine), so the
|
|
@@ -1066,7 +1078,7 @@ export function clampHoldMs(v) {
|
|
|
1066
1078
|
* The worker reuses the robust `ensureAgentRunning` path (spawn-then-ping) rather
|
|
1067
1079
|
* than a tight inline retry loop. Best-effort; never throws. macOS only.
|
|
1068
1080
|
*/
|
|
1069
|
-
export function agentAutoLoadSync(name, bundle, env, ttlMs, harness =
|
|
1081
|
+
export function agentAutoLoadSync(name, bundle, env, ttlMs, harness = GLOBAL_HARNESS) {
|
|
1070
1082
|
if (!onDarwin())
|
|
1071
1083
|
return;
|
|
1072
1084
|
const payload = JSON.stringify({ name, bundle, env, ttlMs, harness });
|
|
@@ -1136,12 +1148,12 @@ export async function runAgentLoadFromStdin() {
|
|
|
1136
1148
|
process.exitCode = 1; // broker couldn't be brought up — did NOT load
|
|
1137
1149
|
return;
|
|
1138
1150
|
}
|
|
1139
|
-
const loaded = await agentLoad(payload.name, payload.bundle, payload.env, payload.ttlMs ?? DEFAULT_TTL_MS, payload.harness ??
|
|
1151
|
+
const loaded = await agentLoad(payload.name, payload.bundle, payload.env, payload.ttlMs ?? DEFAULT_TTL_MS, payload.harness ?? GLOBAL_HARNESS);
|
|
1140
1152
|
if (!loaded)
|
|
1141
1153
|
process.exitCode = 1; // transport failed — did NOT load
|
|
1142
1154
|
}
|
|
1143
1155
|
/** Store a resolved bundle in the broker. Returns false on transport failure. */
|
|
1144
|
-
export async function agentLoad(name, bundle, env, ttlMs, harness =
|
|
1156
|
+
export async function agentLoad(name, bundle, env, ttlMs, harness = GLOBAL_HARNESS) {
|
|
1145
1157
|
const r = await request({ cmd: 'load', name, bundle, env, ttlMs, harness });
|
|
1146
1158
|
return r?.ok === true && r.cmd === 'load';
|
|
1147
1159
|
}
|
|
@@ -301,7 +301,7 @@ export declare function resolveBundleEnv(bundle: SecretsBundle, _opts?: ResolveB
|
|
|
301
301
|
*
|
|
302
302
|
* A read in a macOS headless context resolves broker-only (agentOnly) and fails
|
|
303
303
|
* fast with an actionable error instead of hijacking Touch ID. This generalizes
|
|
304
|
-
* the per-caller pattern
|
|
304
|
+
* the per-caller broker-only pattern used across the headless secrets readers.
|
|
305
305
|
*/
|
|
306
306
|
export declare function isHeadlessSecretsContext(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, tty?: {
|
|
307
307
|
stdin?: boolean;
|