@phnx-labs/agents-cli 1.20.55 → 1.20.57
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 +11 -1
- package/README.md +5 -2
- package/dist/bin/agents +0 -0
- package/dist/commands/message.js +7 -5
- package/dist/commands/profiles.js +4 -3
- package/dist/commands/routines.js +144 -14
- package/dist/commands/secrets.d.ts +3 -2
- package/dist/commands/secrets.js +7 -6
- package/dist/commands/teams.d.ts +20 -1
- package/dist/commands/teams.js +105 -2
- package/dist/index.js +1 -1
- package/dist/lib/daemon.d.ts +19 -1
- package/dist/lib/daemon.js +64 -9
- package/dist/lib/hosts/passthrough.js +8 -4
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/migrate.d.ts +9 -0
- package/dist/lib/migrate.js +48 -0
- package/dist/lib/routines.d.ts +36 -13
- package/dist/lib/routines.js +61 -11
- package/dist/lib/runner.js +10 -1
- package/dist/lib/secrets/agent.d.ts +27 -0
- package/dist/lib/secrets/agent.js +144 -1
- package/dist/lib/teams/agents.d.ts +16 -0
- package/dist/lib/teams/agents.js +126 -33
- package/package.json +1 -1
package/dist/lib/daemon.js
CHANGED
|
@@ -306,6 +306,28 @@ export async function runDaemon() {
|
|
|
306
306
|
catch (err) {
|
|
307
307
|
log('ERROR', `Stray daemon reaper failed: ${err.message}`);
|
|
308
308
|
}
|
|
309
|
+
// #416: host the secrets broker socket-first — before the scheduler and the
|
|
310
|
+
// heavy browser/session-sync services — so `agents secrets` resolves within
|
|
311
|
+
// ms of daemon start. Only host when no broker is already reachable, so we
|
|
312
|
+
// never orphan a live standalone broker's clients (that broker stays the
|
|
313
|
+
// server until it idle-exits or the daemon restarts). Best-effort: a failure
|
|
314
|
+
// here must not stop the daemon. Retiring the standalone launchd service is
|
|
315
|
+
// the follow-on (#416 step 2 / #417).
|
|
316
|
+
let hostedBroker = null;
|
|
317
|
+
try {
|
|
318
|
+
const { agentPing, startHostedBroker } = await import('./secrets/agent.js');
|
|
319
|
+
if ((await agentPing()).reachable) {
|
|
320
|
+
log('INFO', 'Secrets broker already running (standalone); daemon not hosting it');
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
hostedBroker = await startHostedBroker();
|
|
324
|
+
if (hostedBroker)
|
|
325
|
+
log('INFO', 'Secrets broker hosted in daemon (socket-first)');
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
log('WARN', `Secrets broker host skipped: ${err.message}`);
|
|
330
|
+
}
|
|
309
331
|
const scheduler = new JobScheduler(async (config) => {
|
|
310
332
|
log('INFO', `Triggering job '${config.name}' (agent: ${config.agent})`);
|
|
311
333
|
try {
|
|
@@ -594,6 +616,7 @@ export async function runDaemon() {
|
|
|
594
616
|
clearTimeout(tmuxReconcileKickoff);
|
|
595
617
|
clearInterval(launchHealthInterval);
|
|
596
618
|
clearTimeout(launchHealthKickoff);
|
|
619
|
+
hostedBroker?.close();
|
|
597
620
|
removeDaemonPid();
|
|
598
621
|
removeHeartbeat();
|
|
599
622
|
process.exit(0);
|
|
@@ -712,28 +735,39 @@ Environment=PATH=/usr/local/bin:/usr/bin:/bin:${os.homedir()}/.nvm/versions/node
|
|
|
712
735
|
[Install]
|
|
713
736
|
WantedBy=default.target`;
|
|
714
737
|
}
|
|
715
|
-
|
|
738
|
+
const BUN_VIRTUAL_ROOT = /[/\\]\$bunfs[/\\]root[/\\]/;
|
|
739
|
+
function resolveBunStandaloneEntry(entry, execPath) {
|
|
740
|
+
if (!BUN_VIRTUAL_ROOT.test(entry))
|
|
741
|
+
return entry;
|
|
742
|
+
if (!execPath || BUN_VIRTUAL_ROOT.test(execPath) || !fs.existsSync(execPath)) {
|
|
743
|
+
throw new Error(`Cannot resolve agents CLI: Bun standalone executable not found at ${execPath || '(empty path)'}`);
|
|
744
|
+
}
|
|
745
|
+
return execPath;
|
|
746
|
+
}
|
|
747
|
+
export function getAgentsBinPath(argv1 = process.argv[1], execPath = process.execPath) {
|
|
716
748
|
// Prefer the binary actively executing this code. `which agents` returns
|
|
717
749
|
// whatever happens to be first on PATH, which means a side-by-side dev
|
|
718
750
|
// build at ~/.local/bin would silently spawn the registry-installed
|
|
719
|
-
// daemon and run stale code. process.argv[1] is the
|
|
720
|
-
//
|
|
721
|
-
|
|
722
|
-
|
|
751
|
+
// daemon and run stale code. For a JS install, process.argv[1] is the
|
|
752
|
+
// absolute entrypoint the user actually invoked. A Bun standalone instead
|
|
753
|
+
// exposes its embedded /$bunfs/root entry at argv[1] and its physical signed
|
|
754
|
+
// executable at process.execPath; Bun reports both as existing paths.
|
|
755
|
+
const runningEntry = argv1 ? resolveBunStandaloneEntry(argv1, execPath) : undefined;
|
|
756
|
+
if (runningEntry && fs.existsSync(runningEntry)) {
|
|
723
757
|
// The package's browser/computer entrypoints are sibling shims without a
|
|
724
758
|
// `daemon` command. A daemon started as their IPC side effect must launch
|
|
725
759
|
// through the main agents entrypoint instead of replaying the shim path.
|
|
726
|
-
const entryName = path.basename(
|
|
760
|
+
const entryName = path.basename(runningEntry);
|
|
727
761
|
const compiledShim = /^(browser|computer)\.(c|m)?js$/.test(entryName);
|
|
728
762
|
const installedShim = /^(browser|computer)$/.test(entryName);
|
|
729
763
|
if (compiledShim || installedShim) {
|
|
730
|
-
const agentsEntry = path.join(path.dirname(
|
|
764
|
+
const agentsEntry = path.join(path.dirname(runningEntry), compiledShim ? 'index.js' : 'agents');
|
|
731
765
|
if (!fs.existsSync(agentsEntry)) {
|
|
732
766
|
throw new Error(`Cannot start agents daemon: main CLI entry not found at ${agentsEntry}`);
|
|
733
767
|
}
|
|
734
768
|
return agentsEntry;
|
|
735
769
|
}
|
|
736
|
-
return
|
|
770
|
+
return runningEntry;
|
|
737
771
|
}
|
|
738
772
|
try {
|
|
739
773
|
return execFileSync('which', ['agents'], { encoding: 'utf-8' }).trim();
|
|
@@ -903,9 +937,30 @@ export function getDaemonLaunch(agentsBin = getAgentsBinPath()) {
|
|
|
903
937
|
}
|
|
904
938
|
return { command: agentsBin, args: ['daemon', '_run'] };
|
|
905
939
|
}
|
|
940
|
+
/**
|
|
941
|
+
* Build the argv to relaunch the `agents` CLI with the given subcommand args.
|
|
942
|
+
*
|
|
943
|
+
* Resolves the real on-disk binary via getAgentsBinPath(), then dispatches: a
|
|
944
|
+
* `.js` entry runs under node (`node <entry> …`), a native/compiled binary runs
|
|
945
|
+
* directly (`<bin> …`).
|
|
946
|
+
*
|
|
947
|
+
* Callers MUST route self-spawns through this rather than hand-rolling
|
|
948
|
+
* `[process.execPath, process.argv[1], …]`: under the compiled standalone binary
|
|
949
|
+
* (#315) `process.argv[1]` is the bun virtual entry `/$bunfs/root/agents`, so the
|
|
950
|
+
* hand-rolled form becomes `agents /$bunfs/root/agents …` → the CLI receives the
|
|
951
|
+
* bunfs path as a subcommand and dies with "unknown command '/$bunfs/root/agents'".
|
|
952
|
+
* getAgentsBinPath() resolves that virtual entry to the physical process.execPath.
|
|
953
|
+
*/
|
|
954
|
+
export function getAgentsInvocation(subArgs, agentsBin = getAgentsBinPath()) {
|
|
955
|
+
const resolvedBin = resolveBunStandaloneEntry(agentsBin, process.execPath);
|
|
956
|
+
if (/\.(c|m)?js$/.test(resolvedBin)) {
|
|
957
|
+
return { command: process.execPath, args: [resolvedBin, ...subArgs] };
|
|
958
|
+
}
|
|
959
|
+
return { command: resolvedBin, args: subArgs };
|
|
960
|
+
}
|
|
906
961
|
export function validateDaemonBinary(binPath) {
|
|
907
962
|
const warnings = [];
|
|
908
|
-
if (
|
|
963
|
+
if (BUN_VIRTUAL_ROOT.test(binPath)) {
|
|
909
964
|
throw new Error(`Refusing to supervise daemon: resolved binary is a bun virtual path (${binPath}). ` +
|
|
910
965
|
`Install agents globally (npm i -g @phnx-labs/agents-cli) and restart.`);
|
|
911
966
|
}
|
|
@@ -34,6 +34,7 @@ const REMOTE_PASSTHROUGH = {
|
|
|
34
34
|
sync: { nonInteractive: ['--yes'] },
|
|
35
35
|
teams: {},
|
|
36
36
|
message: {},
|
|
37
|
+
routines: {},
|
|
37
38
|
};
|
|
38
39
|
/** `--no-tty` is stripped like the routing flags but carries no value. */
|
|
39
40
|
const STRIP_SPECS = [...HOST_ROUTING_SPECS, { long: 'no-tty', takesValue: false }];
|
|
@@ -115,10 +116,13 @@ export async function maybeRunOnHost(command, allArgs) {
|
|
|
115
116
|
process.exitCode = 1;
|
|
116
117
|
return true;
|
|
117
118
|
}
|
|
118
|
-
// `--
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
// `--hosts` is always a generic fleet flag — bail for every command so the
|
|
120
|
+
// local aggregator handles it. `--devices` is fan-out on most commands but
|
|
121
|
+
// a placement flag on `routines` (which devices may run the routine), so
|
|
122
|
+
// only exempt routines from the bail.
|
|
123
|
+
if (allArgs.includes('--hosts'))
|
|
124
|
+
return false;
|
|
125
|
+
if (allArgs.includes('--devices') && command !== 'routines')
|
|
122
126
|
return false;
|
|
123
127
|
const hostName = hostFlag ?? deviceFlag;
|
|
124
128
|
if (!hostName)
|
|
Binary file
|
package/dist/lib/migrate.d.ts
CHANGED
|
@@ -96,5 +96,14 @@ export declare function repairSelfReferentialBinShims(versionsRoot?: string, shi
|
|
|
96
96
|
* the new name.
|
|
97
97
|
*/
|
|
98
98
|
export declare function migrateExtrasExtrasToAgentsExtras(historyDir?: string): void;
|
|
99
|
+
/**
|
|
100
|
+
* Rewrite every routine YAML that carries the legacy singular `device: <value>`
|
|
101
|
+
* field to the new plural `devices: [<value>]` format. Preserves all other
|
|
102
|
+
* fields. Idempotent: a routine that already has `devices:` (or neither field)
|
|
103
|
+
* is left untouched.
|
|
104
|
+
*
|
|
105
|
+
* Params default to the real routines dir; injectable for tests.
|
|
106
|
+
*/
|
|
107
|
+
export declare function migrateRoutineDeviceToDevices(routinesDir?: string): void;
|
|
99
108
|
/** Run all idempotent migrations. Safe to call multiple times. */
|
|
100
109
|
export declare function runMigration(): Promise<void>;
|
package/dist/lib/migrate.js
CHANGED
|
@@ -1885,6 +1885,52 @@ export function migrateExtrasExtrasToAgentsExtras(historyDir = HISTORY_DIR) {
|
|
|
1885
1885
|
console.error(`Renamed extras-extras → agents-extras (dirs: ${renamedDirs}, known_marketplaces: ${rewroteKnown}, settings: ${rewroteSettings})`);
|
|
1886
1886
|
}
|
|
1887
1887
|
}
|
|
1888
|
+
/**
|
|
1889
|
+
* Rewrite every routine YAML that carries the legacy singular `device: <value>`
|
|
1890
|
+
* field to the new plural `devices: [<value>]` format. Preserves all other
|
|
1891
|
+
* fields. Idempotent: a routine that already has `devices:` (or neither field)
|
|
1892
|
+
* is left untouched.
|
|
1893
|
+
*
|
|
1894
|
+
* Params default to the real routines dir; injectable for tests.
|
|
1895
|
+
*/
|
|
1896
|
+
export function migrateRoutineDeviceToDevices(routinesDir) {
|
|
1897
|
+
const dir = routinesDir ?? path.join(USER_DIR, 'routines');
|
|
1898
|
+
if (!fs.existsSync(dir))
|
|
1899
|
+
return;
|
|
1900
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
|
|
1901
|
+
let migrated = 0;
|
|
1902
|
+
for (const file of files) {
|
|
1903
|
+
const filePath = path.join(dir, file);
|
|
1904
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
1905
|
+
let doc;
|
|
1906
|
+
try {
|
|
1907
|
+
doc = yaml.parse(raw);
|
|
1908
|
+
if (!doc || typeof doc !== 'object')
|
|
1909
|
+
continue;
|
|
1910
|
+
}
|
|
1911
|
+
catch {
|
|
1912
|
+
continue;
|
|
1913
|
+
}
|
|
1914
|
+
if (!('device' in doc))
|
|
1915
|
+
continue;
|
|
1916
|
+
if ('devices' in doc) {
|
|
1917
|
+
delete doc.device;
|
|
1918
|
+
atomicWriteFileSync(filePath, yaml.stringify(doc));
|
|
1919
|
+
continue;
|
|
1920
|
+
}
|
|
1921
|
+
const val = doc.device;
|
|
1922
|
+
if (typeof val !== 'string' || !val.trim()) {
|
|
1923
|
+
throw new Error(`${file}: legacy 'device' field is not a valid device name — repair the file and retry`);
|
|
1924
|
+
}
|
|
1925
|
+
delete doc.device;
|
|
1926
|
+
doc.devices = [val.trim()];
|
|
1927
|
+
atomicWriteFileSync(filePath, yaml.stringify(doc));
|
|
1928
|
+
migrated++;
|
|
1929
|
+
}
|
|
1930
|
+
if (migrated > 0) {
|
|
1931
|
+
console.error(`Migrated ${migrated} routine${migrated === 1 ? '' : 's'}: device → devices`);
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1888
1934
|
/** Run all idempotent migrations. Safe to call multiple times. */
|
|
1889
1935
|
export async function runMigration() {
|
|
1890
1936
|
// MUST run first: every other migrator reads SYSTEM_DIR (the new path).
|
|
@@ -1937,6 +1983,8 @@ export async function runMigration() {
|
|
|
1937
1983
|
// installed version-home. Runs after migrateRuntimeToHistory so the version
|
|
1938
1984
|
// homes are at their canonical HISTORY_DIR location.
|
|
1939
1985
|
migrateExtrasExtrasToAgentsExtras();
|
|
1986
|
+
// Rewrite routine YAML files: singular `device:` -> plural `devices: []`.
|
|
1987
|
+
migrateRoutineDeviceToDevices();
|
|
1940
1988
|
// Symlink repair runs LAST so it can find the post-move version homes.
|
|
1941
1989
|
repairAgentConfigSymlinks();
|
|
1942
1990
|
// Repair self-referential node_modules/.bin/<cli> symlinks (the droid
|
package/dist/lib/routines.d.ts
CHANGED
|
@@ -61,14 +61,13 @@ export interface JobConfig {
|
|
|
61
61
|
timezone?: string;
|
|
62
62
|
repo?: string;
|
|
63
63
|
/**
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* `
|
|
68
|
-
*
|
|
69
|
-
* inert and `run` refuses with an `agents ssh` pointer.
|
|
64
|
+
* Fleet allowlist — restrict this routine to specific devices. When omitted
|
|
65
|
+
* or empty, the routine is unrestricted and fires on every device running the
|
|
66
|
+
* scheduler. When set, only devices whose `machineId()` matches any entry
|
|
67
|
+
* (via `normalizeHost`) schedule, fire, catch up, or count this job as
|
|
68
|
+
* overdue; everywhere else it is inert and `run` refuses with a pointer.
|
|
70
69
|
*/
|
|
71
|
-
|
|
70
|
+
devices?: string[];
|
|
72
71
|
variables?: Record<string, string>;
|
|
73
72
|
sandbox?: boolean;
|
|
74
73
|
allow?: JobAllowConfig;
|
|
@@ -94,12 +93,31 @@ export interface RunMeta {
|
|
|
94
93
|
exitCode: number | null;
|
|
95
94
|
}
|
|
96
95
|
/**
|
|
97
|
-
* True when the job may execute on this machine: no `
|
|
98
|
-
*
|
|
99
|
-
* `yosemite-s0.tailnet.ts.net`, and
|
|
100
|
-
* (cron scheduler, webhook,
|
|
96
|
+
* True when the job may execute on this machine: no `devices` allowlist (or
|
|
97
|
+
* empty), or the allowlist includes this device. Both sides go through
|
|
98
|
+
* `normalizeHost` so `Yosemite-S0`, `yosemite-s0.tailnet.ts.net`, and
|
|
99
|
+
* `yosemite-s0` all agree. Every fire path (cron scheduler, webhook,
|
|
100
|
+
* catchup/overdue, manual run) gates on this.
|
|
101
101
|
*/
|
|
102
|
-
export declare function jobRunsOnThisDevice(config: Pick<JobConfig, '
|
|
102
|
+
export declare function jobRunsOnThisDevice(config: Pick<JobConfig, 'devices'>): boolean;
|
|
103
|
+
/** Human presentation of a device-affinity mismatch for commands and runner. */
|
|
104
|
+
export interface JobEligibilityResult {
|
|
105
|
+
/** Full human message, e.g. "Job 'NAME' can only run on: a, b". */
|
|
106
|
+
message: string;
|
|
107
|
+
/** One-line copy-paste suggestion, e.g. "agents routines run NAME --host a". */
|
|
108
|
+
suggestion: string;
|
|
109
|
+
/** Comma-separated allowed devices label, e.g. "a, b". */
|
|
110
|
+
allowedLabel: string;
|
|
111
|
+
/** First allowed device (normalized), useful for the suggested host. */
|
|
112
|
+
firstHost: string;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Return null when the job may run here; otherwise return a structured,
|
|
116
|
+
* human-friendly eligibility failure. Centralizes the message/suggestion
|
|
117
|
+
* construction so manual run, executeJob, and executeJobDetached stay in
|
|
118
|
+
* sync. Scheduler/webhook/overdue paths continue to use jobRunsOnThisDevice.
|
|
119
|
+
*/
|
|
120
|
+
export declare function checkJobDeviceEligibility(config: Pick<JobConfig, 'name' | 'devices'>): JobEligibilityResult | null;
|
|
103
121
|
/**
|
|
104
122
|
* List all job configs, scanning project > user routine dirs.
|
|
105
123
|
* Project routines (`<project>/.agents/routines/`) shadow user routines of the
|
|
@@ -113,7 +131,12 @@ export declare function listJobs(cwd?: string): JobConfig[];
|
|
|
113
131
|
* only resolve user routines.
|
|
114
132
|
*/
|
|
115
133
|
export declare function readJob(name: string, cwd?: string): JobConfig | null;
|
|
116
|
-
/** Write a job config to disk, omitting fields that match defaults.
|
|
134
|
+
/** Write a job config to disk, omitting fields that match defaults.
|
|
135
|
+
*
|
|
136
|
+
* Updates the one existing supported extension (.yml or .yaml) atomically.
|
|
137
|
+
* New routines are written as .yml. If both extensions exist for the same
|
|
138
|
+
* name, the write fails explicitly so we never choose or drop a sibling.
|
|
139
|
+
*/
|
|
117
140
|
export declare function writeJob(config: JobConfig): void;
|
|
118
141
|
/** Delete a job config file by name. Returns true if the file existed. */
|
|
119
142
|
export declare function deleteJob(name: string): boolean;
|
package/dist/lib/routines.js
CHANGED
|
@@ -12,6 +12,7 @@ import * as yaml from 'yaml';
|
|
|
12
12
|
import { Cron } from 'croner';
|
|
13
13
|
import { getRoutinesDir, getRunsDir, ensureAgentsDir, getProjectRoutinesDir } from './state.js';
|
|
14
14
|
import { safeJoin } from './paths.js';
|
|
15
|
+
import { atomicWriteFileSync } from './fs-atomic.js';
|
|
15
16
|
import { ALL_AGENT_IDS } from './agents.js';
|
|
16
17
|
import { machineId, normalizeHost } from './machine-id.js';
|
|
17
18
|
/** Canonical set of accepted GitHub trigger events — single source for validation. */
|
|
@@ -42,15 +43,33 @@ export function normalizeTriggerEvent(input) {
|
|
|
42
43
|
return aliases[key] ?? null;
|
|
43
44
|
}
|
|
44
45
|
/**
|
|
45
|
-
* True when the job may execute on this machine: no `
|
|
46
|
-
*
|
|
47
|
-
* `yosemite-s0.tailnet.ts.net`, and
|
|
48
|
-
* (cron scheduler, webhook,
|
|
46
|
+
* True when the job may execute on this machine: no `devices` allowlist (or
|
|
47
|
+
* empty), or the allowlist includes this device. Both sides go through
|
|
48
|
+
* `normalizeHost` so `Yosemite-S0`, `yosemite-s0.tailnet.ts.net`, and
|
|
49
|
+
* `yosemite-s0` all agree. Every fire path (cron scheduler, webhook,
|
|
50
|
+
* catchup/overdue, manual run) gates on this.
|
|
49
51
|
*/
|
|
50
52
|
export function jobRunsOnThisDevice(config) {
|
|
51
|
-
if (!config.
|
|
53
|
+
if (!config.devices || config.devices.length === 0)
|
|
52
54
|
return true;
|
|
53
|
-
|
|
55
|
+
const self = machineId();
|
|
56
|
+
return config.devices.some((d) => normalizeHost(d) === self);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Return null when the job may run here; otherwise return a structured,
|
|
60
|
+
* human-friendly eligibility failure. Centralizes the message/suggestion
|
|
61
|
+
* construction so manual run, executeJob, and executeJobDetached stay in
|
|
62
|
+
* sync. Scheduler/webhook/overdue paths continue to use jobRunsOnThisDevice.
|
|
63
|
+
*/
|
|
64
|
+
export function checkJobDeviceEligibility(config) {
|
|
65
|
+
if (jobRunsOnThisDevice(config))
|
|
66
|
+
return null;
|
|
67
|
+
const allowed = (config.devices ?? []).map((d) => normalizeHost(d));
|
|
68
|
+
const allowedLabel = allowed.join(', ');
|
|
69
|
+
const firstHost = allowed[0] ?? 'HOST';
|
|
70
|
+
const message = `Job '${config.name}' can only run on: ${allowedLabel}`;
|
|
71
|
+
const suggestion = `agents routines run ${config.name} --host ${firstHost}`;
|
|
72
|
+
return { message, suggestion, allowedLabel, firstHost };
|
|
54
73
|
}
|
|
55
74
|
/** Default values applied to every job config when fields are omitted. */
|
|
56
75
|
const JOB_DEFAULTS = {
|
|
@@ -122,6 +141,11 @@ function readJobFile(filePath) {
|
|
|
122
141
|
const parsed = yaml.parse(content);
|
|
123
142
|
if (!parsed || typeof parsed !== 'object')
|
|
124
143
|
return null;
|
|
144
|
+
// Fail closed on the legacy singular `device` key. A routine that still
|
|
145
|
+
// carries it after v12 startup migration is unmigrated state and must be
|
|
146
|
+
// treated as unavailable/inert rather than unrestricted.
|
|
147
|
+
if (Object.prototype.hasOwnProperty.call(parsed, 'device'))
|
|
148
|
+
return null;
|
|
125
149
|
return {
|
|
126
150
|
...JOB_DEFAULTS,
|
|
127
151
|
...parsed,
|
|
@@ -132,11 +156,23 @@ function readJobFile(filePath) {
|
|
|
132
156
|
return null;
|
|
133
157
|
}
|
|
134
158
|
}
|
|
135
|
-
/** Write a job config to disk, omitting fields that match defaults.
|
|
159
|
+
/** Write a job config to disk, omitting fields that match defaults.
|
|
160
|
+
*
|
|
161
|
+
* Updates the one existing supported extension (.yml or .yaml) atomically.
|
|
162
|
+
* New routines are written as .yml. If both extensions exist for the same
|
|
163
|
+
* name, the write fails explicitly so we never choose or drop a sibling.
|
|
164
|
+
*/
|
|
136
165
|
export function writeJob(config) {
|
|
137
166
|
ensureAgentsDir();
|
|
138
167
|
const jobsDir = getRoutinesDir();
|
|
139
|
-
const
|
|
168
|
+
const ymlPath = safeJoin(jobsDir, config.name + '.yml');
|
|
169
|
+
const yamlPath = safeJoin(jobsDir, config.name + '.yaml');
|
|
170
|
+
const ymlExists = fs.existsSync(ymlPath);
|
|
171
|
+
const yamlExists = fs.existsSync(yamlPath);
|
|
172
|
+
if (ymlExists && yamlExists) {
|
|
173
|
+
throw new Error(`Routine '${config.name}' has both .yml and .yaml files; resolve the ambiguity before editing.`);
|
|
174
|
+
}
|
|
175
|
+
const filePath = ymlExists ? ymlPath : yamlExists ? yamlPath : ymlPath;
|
|
140
176
|
const output = { ...config };
|
|
141
177
|
if (output.mode === 'auto')
|
|
142
178
|
delete output.mode;
|
|
@@ -148,7 +184,10 @@ export function writeJob(config) {
|
|
|
148
184
|
delete output.enabled;
|
|
149
185
|
if (output.runOnce === false || output.runOnce === undefined)
|
|
150
186
|
delete output.runOnce;
|
|
151
|
-
|
|
187
|
+
const devArr = output.devices;
|
|
188
|
+
if (!devArr || devArr.length === 0)
|
|
189
|
+
delete output.devices;
|
|
190
|
+
atomicWriteFileSync(filePath, yaml.stringify(output));
|
|
152
191
|
}
|
|
153
192
|
/** Delete a job config file by name. Returns true if the file existed. */
|
|
154
193
|
export function deleteJob(name) {
|
|
@@ -232,8 +271,19 @@ export function validateJob(config) {
|
|
|
232
271
|
}
|
|
233
272
|
}
|
|
234
273
|
if (config.device !== undefined) {
|
|
235
|
-
|
|
236
|
-
|
|
274
|
+
errors.push('singular "device" key is no longer supported — replace with devices: [<name>] (an array)');
|
|
275
|
+
}
|
|
276
|
+
if (config.devices !== undefined) {
|
|
277
|
+
if (!Array.isArray(config.devices)) {
|
|
278
|
+
errors.push('devices must be an array of device names (as shown by `agents devices`)');
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
for (const d of config.devices) {
|
|
282
|
+
if (typeof d !== 'string' || d.trim() === '') {
|
|
283
|
+
errors.push('each entry in devices must be a non-empty device name');
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
237
287
|
}
|
|
238
288
|
}
|
|
239
289
|
return errors;
|
package/dist/lib/runner.js
CHANGED
|
@@ -17,7 +17,7 @@ import { spawn, execFileSync } from 'child_process';
|
|
|
17
17
|
import * as fs from 'fs';
|
|
18
18
|
import * as path from 'path';
|
|
19
19
|
import * as os from 'os';
|
|
20
|
-
import { resolveJobPrompt, parseTimeout, writeRunMeta, getRunDir, } from './routines.js';
|
|
20
|
+
import { resolveJobPrompt, parseTimeout, writeRunMeta, getRunDir, checkJobDeviceEligibility, } from './routines.js';
|
|
21
21
|
import { getRunsDir } from './state.js';
|
|
22
22
|
import { prepareJobHome, buildSpawnEnv } from './sandbox.js';
|
|
23
23
|
import { resolveModel, buildReasoningFlags } from './models.js';
|
|
@@ -378,6 +378,10 @@ function spawnJobAttempt(cmd, env, attemptLogPath, timeoutMs, combinedLogPath) {
|
|
|
378
378
|
* failover across healthy same-agent accounts (RUSH-1016).
|
|
379
379
|
*/
|
|
380
380
|
export async function executeJob(config, deps) {
|
|
381
|
+
const eligibility = checkJobDeviceEligibility(config);
|
|
382
|
+
if (eligibility) {
|
|
383
|
+
throw new Error(eligibility.message);
|
|
384
|
+
}
|
|
381
385
|
maybeRotate();
|
|
382
386
|
const launch = await resolveRoutineLaunch(config);
|
|
383
387
|
const primaryVersion = launch.chain[0]?.version ?? config.version;
|
|
@@ -537,6 +541,11 @@ export async function executeJob(config, deps) {
|
|
|
537
541
|
}
|
|
538
542
|
/** Spawn a job as a detached process and return immediately with run metadata. */
|
|
539
543
|
export async function executeJobDetached(config) {
|
|
544
|
+
const eligibility = checkJobDeviceEligibility(config);
|
|
545
|
+
if (eligibility) {
|
|
546
|
+
process.stderr.write(`[agents] daemon: skipping '${config.name}' — ${eligibility.message}\n`);
|
|
547
|
+
throw new Error(eligibility.message);
|
|
548
|
+
}
|
|
540
549
|
// Pre-flight: pick a healthy version/account so the daemon does not launch
|
|
541
550
|
// into a credit-exhausted install. Detached cannot mid-run failover (no exit
|
|
542
551
|
// wait); the next schedule tick re-selects if this attempt still fails.
|
|
@@ -172,6 +172,27 @@ export declare function shouldWipeOnWatchEvent(chunk: string): boolean;
|
|
|
172
172
|
export declare function runSecretsAgent(opts?: {
|
|
173
173
|
service?: boolean;
|
|
174
174
|
}): Promise<void>;
|
|
175
|
+
/**
|
|
176
|
+
* Host the secrets broker inside the always-on daemon (#416).
|
|
177
|
+
*
|
|
178
|
+
* Serves the SAME socket and wire protocol as the standalone `runSecretsAgent`
|
|
179
|
+
* — so every existing client (`agentGetSync`, `agentPing`, `agentAutoLoadSync`)
|
|
180
|
+
* keeps working unchanged, no PROTOCOL_VERSION bump — but it is daemon-safe:
|
|
181
|
+
*
|
|
182
|
+
* - no pid-file single-instance guard (the daemon owns the instance);
|
|
183
|
+
* - no `process.exit`, no SIGTERM/SIGINT handlers, no self-heal/idle-exit
|
|
184
|
+
* (those would kill the daemon — the daemon is the always-on backbone and
|
|
185
|
+
* manages its own version/lifecycle). The sweep only TTL-evicts.
|
|
186
|
+
*
|
|
187
|
+
* The caller (`runDaemon`) must only invoke this when NO broker is already
|
|
188
|
+
* reachable (ping first) — this function clears a stale socket before binding,
|
|
189
|
+
* so calling it while a live standalone broker holds the socket would orphan
|
|
190
|
+
* that broker's clients. Returns a handle the daemon closes on shutdown, or
|
|
191
|
+
* null off-darwin (nothing to broker without biometry).
|
|
192
|
+
*/
|
|
193
|
+
export declare function startHostedBroker(): Promise<{
|
|
194
|
+
close(): void;
|
|
195
|
+
} | null>;
|
|
175
196
|
/** True if a broker socket exists at all. Cheap; gates the sync read so the
|
|
176
197
|
* never-unlocked path stays a single stat. */
|
|
177
198
|
export declare function agentSocketExists(): boolean;
|
|
@@ -246,6 +267,12 @@ export declare function agentLock(name?: string): Promise<number>;
|
|
|
246
267
|
* predates the server-side exclusion, so this keeps the internal entry from
|
|
247
268
|
* surfacing in `agents secrets status` in that skew window. */
|
|
248
269
|
export declare function agentStatus(): Promise<AgentStatusEntry[]>;
|
|
270
|
+
/** Ping result: whether a broker is reachable + speaking our protocol, and the
|
|
271
|
+
* version of the code it's running (for staleness detection). */
|
|
272
|
+
export declare function agentPing(): Promise<{
|
|
273
|
+
reachable: boolean;
|
|
274
|
+
cliVersion?: string;
|
|
275
|
+
}>;
|
|
249
276
|
/**
|
|
250
277
|
* Ensure a broker is running and reachable. Returns true once the socket answers
|
|
251
278
|
* a ping. macOS only.
|
|
@@ -500,6 +500,132 @@ export async function runSecretsAgent(opts = {}) {
|
|
|
500
500
|
watcher = null;
|
|
501
501
|
}
|
|
502
502
|
}
|
|
503
|
+
/**
|
|
504
|
+
* Host the secrets broker inside the always-on daemon (#416).
|
|
505
|
+
*
|
|
506
|
+
* Serves the SAME socket and wire protocol as the standalone `runSecretsAgent`
|
|
507
|
+
* — so every existing client (`agentGetSync`, `agentPing`, `agentAutoLoadSync`)
|
|
508
|
+
* keeps working unchanged, no PROTOCOL_VERSION bump — but it is daemon-safe:
|
|
509
|
+
*
|
|
510
|
+
* - no pid-file single-instance guard (the daemon owns the instance);
|
|
511
|
+
* - no `process.exit`, no SIGTERM/SIGINT handlers, no self-heal/idle-exit
|
|
512
|
+
* (those would kill the daemon — the daemon is the always-on backbone and
|
|
513
|
+
* manages its own version/lifecycle). The sweep only TTL-evicts.
|
|
514
|
+
*
|
|
515
|
+
* The caller (`runDaemon`) must only invoke this when NO broker is already
|
|
516
|
+
* reachable (ping first) — this function clears a stale socket before binding,
|
|
517
|
+
* so calling it while a live standalone broker holds the socket would orphan
|
|
518
|
+
* that broker's clients. Returns a handle the daemon closes on shutdown, or
|
|
519
|
+
* null off-darwin (nothing to broker without biometry).
|
|
520
|
+
*/
|
|
521
|
+
export async function startHostedBroker() {
|
|
522
|
+
if (!onDarwin())
|
|
523
|
+
return null;
|
|
524
|
+
const store = new Map();
|
|
525
|
+
const sock = socketPath(); // agentDir() creates the 0700 dir as a side effect
|
|
526
|
+
const handle = (req) => handleAgentRequest(store, req);
|
|
527
|
+
const onConn = (conn) => {
|
|
528
|
+
conn.setEncoding('utf-8');
|
|
529
|
+
let buf = '';
|
|
530
|
+
conn.on('data', (chunk) => {
|
|
531
|
+
buf += chunk;
|
|
532
|
+
let nl;
|
|
533
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
534
|
+
const line = buf.slice(0, nl);
|
|
535
|
+
buf = buf.slice(nl + 1);
|
|
536
|
+
if (!line.trim())
|
|
537
|
+
continue;
|
|
538
|
+
let resp;
|
|
539
|
+
try {
|
|
540
|
+
resp = handle(JSON.parse(line));
|
|
541
|
+
}
|
|
542
|
+
catch (err) {
|
|
543
|
+
resp = { ok: false, error: err.message };
|
|
544
|
+
}
|
|
545
|
+
conn.write(JSON.stringify(resp) + '\n');
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
conn.on('error', () => { });
|
|
549
|
+
};
|
|
550
|
+
// Bind race-safely: never clobber a LIVE standalone broker. Try to listen; if
|
|
551
|
+
// the socket is already bound, a broker raced us after runDaemon's ping — if
|
|
552
|
+
// it answers, back off and let it serve (return null); if it's a stale socket
|
|
553
|
+
// file with no live listener, reclaim it and retry once. (Unconditionally
|
|
554
|
+
// unlinking before bind — as an earlier draft did — could remove a live
|
|
555
|
+
// broker's socket in the sub-ms window after runDaemon's reachability check.)
|
|
556
|
+
const listenOnce = () => new Promise((resolve, reject) => {
|
|
557
|
+
const s = net.createServer(onConn);
|
|
558
|
+
s.once('error', (err) => {
|
|
559
|
+
if (err.code === 'EADDRINUSE')
|
|
560
|
+
resolve('inuse');
|
|
561
|
+
else
|
|
562
|
+
reject(err);
|
|
563
|
+
});
|
|
564
|
+
s.listen(sock, () => {
|
|
565
|
+
try {
|
|
566
|
+
fs.chmodSync(sock, 0o600);
|
|
567
|
+
}
|
|
568
|
+
catch { /* dir 0700 already gates it */ }
|
|
569
|
+
resolve(s);
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
let bound = await listenOnce();
|
|
573
|
+
if (bound === 'inuse') {
|
|
574
|
+
if ((await agentPing()).reachable)
|
|
575
|
+
return null; // a live broker holds it — don't clobber
|
|
576
|
+
try {
|
|
577
|
+
fs.unlinkSync(sock);
|
|
578
|
+
}
|
|
579
|
+
catch { /* gone */ }
|
|
580
|
+
bound = await listenOnce();
|
|
581
|
+
if (bound === 'inuse')
|
|
582
|
+
return null; // still contended — the standalone fallback covers it
|
|
583
|
+
}
|
|
584
|
+
const server = bound;
|
|
585
|
+
// TTL eviction ONLY. Unlike the standalone broker's sweep, there is no
|
|
586
|
+
// self-heal-exit or idle-exit here — the daemon is always-on and owns the
|
|
587
|
+
// upgrade/lifecycle path; a broker that called process.exit() would take the
|
|
588
|
+
// whole daemon down with it.
|
|
589
|
+
const sweepTimer = setInterval(() => {
|
|
590
|
+
const now = Date.now();
|
|
591
|
+
for (const [name, e] of store)
|
|
592
|
+
if (now >= e.expiresAt)
|
|
593
|
+
store.delete(name);
|
|
594
|
+
}, SWEEP_INTERVAL_MS);
|
|
595
|
+
// Auto-lock on sleep, same as the standalone broker: the signed helper emits
|
|
596
|
+
// LOCK/SLEEP lines; wipe the in-memory store on a wipe-worthy event.
|
|
597
|
+
let watcher = null;
|
|
598
|
+
try {
|
|
599
|
+
watcher = spawn(getKeychainHelperPath(), ['watch-lock'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
600
|
+
watcher.stdout?.setEncoding('utf-8');
|
|
601
|
+
watcher.stdout?.on('data', (chunk) => {
|
|
602
|
+
if (shouldWipeOnWatchEvent(chunk))
|
|
603
|
+
store.clear();
|
|
604
|
+
});
|
|
605
|
+
watcher.on('error', () => { watcher = null; });
|
|
606
|
+
}
|
|
607
|
+
catch {
|
|
608
|
+
watcher = null;
|
|
609
|
+
}
|
|
610
|
+
return {
|
|
611
|
+
close() {
|
|
612
|
+
store.clear();
|
|
613
|
+
clearInterval(sweepTimer);
|
|
614
|
+
try {
|
|
615
|
+
watcher?.kill();
|
|
616
|
+
}
|
|
617
|
+
catch { /* already gone */ }
|
|
618
|
+
try {
|
|
619
|
+
server.close();
|
|
620
|
+
}
|
|
621
|
+
catch { /* not listening */ }
|
|
622
|
+
try {
|
|
623
|
+
fs.unlinkSync(sock);
|
|
624
|
+
}
|
|
625
|
+
catch { /* gone */ }
|
|
626
|
+
},
|
|
627
|
+
};
|
|
628
|
+
}
|
|
503
629
|
// ─── Client ──────────────────────────────────────────────────────────────────
|
|
504
630
|
/** Open the socket, send one request, resolve the one response. Async path —
|
|
505
631
|
* used by the unlock/lock/status commands, which already run in async actions. */
|
|
@@ -766,7 +892,7 @@ export async function agentStatus() {
|
|
|
766
892
|
}
|
|
767
893
|
/** Ping result: whether a broker is reachable + speaking our protocol, and the
|
|
768
894
|
* version of the code it's running (for staleness detection). */
|
|
769
|
-
async function agentPing() {
|
|
895
|
+
export async function agentPing() {
|
|
770
896
|
if (!agentSocketExists())
|
|
771
897
|
return { reachable: false };
|
|
772
898
|
const r = await request({ cmd: 'ping' });
|
|
@@ -802,6 +928,23 @@ export async function ensureAgentRunning(timeoutMs = 5000) {
|
|
|
802
928
|
return true;
|
|
803
929
|
await teardownStaleBroker();
|
|
804
930
|
}
|
|
931
|
+
// Path 0 (#416): prefer the always-on daemon — it hosts the broker socket
|
|
932
|
+
// (one supervised backbone rather than a separate launchd service). If
|
|
933
|
+
// bringing the daemon up makes the broker answer, we're done. Fall through to
|
|
934
|
+
// the standalone-service paths below when the daemon path isn't available
|
|
935
|
+
// (kept as a fallback until the standalone service is retired, #416 step 2).
|
|
936
|
+
try {
|
|
937
|
+
const { ensureDaemonStarted } = await import('../daemon.js');
|
|
938
|
+
if (ensureDaemonStarted()) {
|
|
939
|
+
const d0 = Date.now() + timeoutMs;
|
|
940
|
+
while (Date.now() < d0) {
|
|
941
|
+
if ((await agentPing()).reachable)
|
|
942
|
+
return true;
|
|
943
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
catch { /* daemon path unavailable — fall through to the standalone service */ }
|
|
805
948
|
// Path 1: the persistent service. installSecretsAgentService is idempotent and
|
|
806
949
|
// waits for the socket; for an already-installed service we kickstart and wait.
|
|
807
950
|
try {
|