@phnx-labs/agents-cli 1.22.25 → 1.22.27
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 +427 -0
- package/README.md +35 -3
- package/dist/bin/agents +0 -0
- package/dist/browser.js +14 -4
- package/dist/commands/apply.js +52 -8
- package/dist/commands/bench.d.ts +2 -0
- package/dist/commands/bench.js +101 -0
- package/dist/commands/browser.js +35 -0
- package/dist/commands/doctor.js +32 -10
- package/dist/commands/exec.js +17 -16
- package/dist/commands/insights.d.ts +25 -19
- package/dist/commands/insights.js +146 -38
- package/dist/commands/output.js +100 -25
- package/dist/commands/reconnect.d.ts +46 -0
- package/dist/commands/reconnect.js +109 -0
- package/dist/commands/resume.d.ts +11 -0
- package/dist/commands/resume.js +51 -0
- package/dist/commands/routines.js +2 -2
- package/dist/commands/secrets.d.ts +2 -8
- package/dist/commands/secrets.js +29 -105
- package/dist/commands/sessions-picker.js +12 -7
- package/dist/commands/sessions.d.ts +47 -1
- package/dist/commands/sessions.js +349 -37
- package/dist/commands/setup-secrets.d.ts +1 -0
- package/dist/commands/setup-secrets.js +1 -1
- package/dist/commands/setup.d.ts +26 -3
- package/dist/commands/setup.js +105 -46
- package/dist/commands/ssh.js +184 -11
- package/dist/commands/teams.d.ts +6 -0
- package/dist/commands/teams.js +46 -3
- package/dist/commands/trends.d.ts +8 -0
- package/dist/commands/trends.js +10 -156
- package/dist/index.js +17 -21
- package/dist/lib/agents.d.ts +11 -0
- package/dist/lib/agents.js +29 -2
- package/dist/lib/analytics/dashboard.d.ts +10 -6
- package/dist/lib/analytics/dashboard.js +6 -4
- package/dist/lib/analytics/mix-commands.d.ts +53 -0
- package/dist/lib/analytics/mix-commands.js +229 -0
- package/dist/lib/analytics/recipes.d.ts +19 -14
- package/dist/lib/analytics/recipes.js +4 -2
- package/dist/lib/auth-health.d.ts +47 -3
- package/dist/lib/auth-health.js +74 -15
- package/dist/lib/bench/index.d.ts +4 -0
- package/dist/lib/bench/index.js +4 -0
- package/dist/lib/bench/runner.d.ts +16 -0
- package/dist/lib/bench/runner.js +111 -0
- package/dist/lib/bench/schema.d.ts +5 -0
- package/dist/lib/bench/schema.js +91 -0
- package/dist/lib/bench/storage.d.ts +5 -0
- package/dist/lib/bench/storage.js +32 -0
- package/dist/lib/bench/types.d.ts +40 -0
- package/dist/lib/bench/types.js +1 -0
- package/dist/lib/browser/ipc.d.ts +26 -0
- package/dist/lib/browser/ipc.js +139 -24
- package/dist/lib/browser/profiles.d.ts +11 -0
- package/dist/lib/browser/profiles.js +1 -1
- package/dist/lib/browser/stream.d.ts +14 -0
- package/dist/lib/browser/stream.js +71 -0
- package/dist/lib/channels/owner-sink.d.ts +27 -0
- package/dist/lib/channels/owner-sink.js +93 -0
- package/dist/lib/claude-account-token.d.ts +2 -0
- package/dist/lib/claude-account-token.js +27 -1
- package/dist/lib/crabbox/cli.d.ts +2 -0
- package/dist/lib/crabbox/cli.js +2 -0
- package/dist/lib/crabbox/lease.js +7 -1
- package/dist/lib/daemon.js +20 -0
- package/dist/lib/devices/connect.d.ts +2 -0
- package/dist/lib/devices/connect.js +7 -0
- package/dist/lib/devices/doctor-findings.d.ts +10 -1
- package/dist/lib/devices/doctor-findings.js +47 -1
- package/dist/lib/devices/harness-inventory.d.ts +97 -0
- package/dist/lib/devices/harness-inventory.js +0 -0
- package/dist/lib/devices/registry.d.ts +2 -0
- package/dist/lib/devices/ssh-config.js +3 -0
- package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
- package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
- package/dist/lib/exec.bench.d.ts +1 -0
- package/dist/lib/exec.bench.js +186 -0
- package/dist/lib/exec.js +18 -8
- package/dist/lib/fleet/apply.d.ts +59 -3
- package/dist/lib/fleet/apply.js +191 -12
- package/dist/lib/fleet/remote-login.d.ts +4 -3
- package/dist/lib/fleet/remote-login.js +11 -9
- package/dist/lib/fleet/types.d.ts +21 -2
- package/dist/lib/gemini-settings.d.ts +0 -1
- package/dist/lib/gemini-settings.js +12 -7
- package/dist/lib/hooks/cache.js +15 -0
- package/dist/lib/hooks.d.ts +8 -5
- package/dist/lib/hooks.js +10 -6
- package/dist/lib/hosts/dispatch.js +7 -3
- package/dist/lib/hosts/passthrough.d.ts +45 -0
- package/dist/lib/hosts/passthrough.js +59 -8
- package/dist/lib/hosts/progress.d.ts +2 -1
- package/dist/lib/hosts/progress.js +4 -3
- package/dist/lib/hosts/providers/devices.js +1 -0
- package/dist/lib/hosts/ready.d.ts +6 -2
- package/dist/lib/hosts/ready.js +33 -8
- package/dist/lib/hosts/reconcile.d.ts +1 -1
- package/dist/lib/hosts/reconcile.js +16 -7
- package/dist/lib/hosts/reconnect.d.ts +56 -21
- package/dist/lib/hosts/reconnect.js +128 -71
- package/dist/lib/hosts/registry.d.ts +2 -2
- package/dist/lib/hosts/registry.js +3 -5
- package/dist/lib/hosts/remote-cmd.d.ts +17 -0
- package/dist/lib/hosts/remote-cmd.js +29 -0
- package/dist/lib/hosts/tasks.d.ts +2 -0
- package/dist/lib/hosts/types.d.ts +1 -0
- package/dist/lib/hosts/types.js +3 -0
- 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.d.ts +9 -6
- package/dist/lib/menubar/install-menubar.js +20 -9
- package/dist/lib/models.d.ts +30 -1
- package/dist/lib/models.js +42 -144
- package/dist/lib/pricing/cost.d.ts +9 -0
- package/dist/lib/pricing/cost.js +24 -0
- package/dist/lib/pricing/index.d.ts +1 -1
- package/dist/lib/pricing/index.js +1 -1
- package/dist/lib/redact.js +8 -3
- package/dist/lib/remote-agents-json.d.ts +32 -0
- package/dist/lib/remote-agents-json.js +47 -16
- package/dist/lib/resource-profiles.js +1 -2
- package/dist/lib/routine-notify-owner.d.ts +102 -0
- package/dist/lib/routine-notify-owner.js +232 -0
- package/dist/lib/routines.d.ts +10 -0
- package/dist/lib/routines.js +14 -2
- package/dist/lib/runner.d.ts +9 -4
- package/dist/lib/runner.js +182 -30
- package/dist/lib/sandbox.d.ts +0 -2
- package/dist/lib/sandbox.js +2 -19
- 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/agent.js +3 -2
- package/dist/lib/secrets/lease.d.ts +25 -0
- package/dist/lib/secrets/lease.js +44 -0
- package/dist/lib/secrets/push.d.ts +94 -0
- package/dist/lib/secrets/push.js +145 -0
- package/dist/lib/secrets/reaper.d.ts +15 -1
- package/dist/lib/secrets/reaper.js +30 -3
- package/dist/lib/self-update.d.ts +20 -5
- package/dist/lib/self-update.js +93 -16
- package/dist/lib/session/db.d.ts +38 -3
- package/dist/lib/session/db.js +273 -16
- package/dist/lib/session/discover.d.ts +22 -1
- package/dist/lib/session/discover.js +239 -51
- package/dist/lib/session/insights.d.ts +37 -0
- package/dist/lib/session/insights.js +219 -9
- package/dist/lib/session/recovery.d.ts +17 -1
- package/dist/lib/session/recovery.js +111 -4
- package/dist/lib/session/remote-list.d.ts +10 -9
- package/dist/lib/session/remote-list.js +25 -23
- package/dist/lib/session/resume-owner.d.ts +55 -0
- package/dist/lib/session/resume-owner.js +69 -0
- package/dist/lib/session/team-filter.d.ts +65 -0
- package/dist/lib/session/team-filter.js +98 -3
- package/dist/lib/session/tool-index.js +133 -22
- package/dist/lib/session/tool-store.d.ts +26 -2
- package/dist/lib/session/tool-store.js +36 -17
- package/dist/lib/session/types.d.ts +33 -0
- package/dist/lib/smart-launch.d.ts +31 -5
- package/dist/lib/smart-launch.js +43 -7
- package/dist/lib/ssh-exec.d.ts +27 -0
- package/dist/lib/ssh-exec.js +42 -3
- package/dist/lib/ssh-tunnel.d.ts +3 -2
- package/dist/lib/ssh-tunnel.js +25 -16
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +6 -0
- package/dist/lib/teams/agents.d.ts +13 -0
- package/dist/lib/teams/agents.js +75 -7
- package/dist/lib/teams/placement-probe.d.ts +21 -0
- package/dist/lib/teams/placement-probe.js +135 -0
- package/dist/lib/teams/scheduler.d.ts +74 -1
- package/dist/lib/teams/scheduler.js +187 -10
- package/dist/lib/tmux/session.d.ts +8 -0
- package/dist/lib/tmux/session.js +22 -0
- package/dist/lib/types.d.ts +2 -0
- package/package.json +1 -1
package/dist/lib/browser/ipc.js
CHANGED
|
@@ -64,6 +64,92 @@ async function waitForSocket(_socketPath, timeoutMs) {
|
|
|
64
64
|
}
|
|
65
65
|
throw new Error('Timeout waiting for browser daemon socket');
|
|
66
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* One long-lived connection to the existing browser daemon. Requests are
|
|
69
|
+
* serialized so the daemon's newline-delimited responses always map to the
|
|
70
|
+
* caller that produced them, while the process and socket stay warm between
|
|
71
|
+
* actions.
|
|
72
|
+
*/
|
|
73
|
+
export class BrowserIPCConnection {
|
|
74
|
+
socket;
|
|
75
|
+
buffer = '';
|
|
76
|
+
pending;
|
|
77
|
+
tail = Promise.resolve();
|
|
78
|
+
closed = false;
|
|
79
|
+
constructor(socket) {
|
|
80
|
+
this.socket = socket;
|
|
81
|
+
socket.on('data', (data) => this.handleData(data));
|
|
82
|
+
socket.on('error', (error) => this.fail(error));
|
|
83
|
+
socket.on('close', () => {
|
|
84
|
+
this.fail(new Error('Browser daemon connection closed'));
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
request(request) {
|
|
88
|
+
const result = this.tail.then(() => this.requestOnce(request));
|
|
89
|
+
this.tail = result.then(() => undefined, () => undefined);
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
async close() {
|
|
93
|
+
await this.tail;
|
|
94
|
+
if (this.closed || this.socket.destroyed)
|
|
95
|
+
return;
|
|
96
|
+
await new Promise((resolve) => {
|
|
97
|
+
this.socket.once('close', resolve);
|
|
98
|
+
this.socket.end();
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
requestOnce(request) {
|
|
102
|
+
if (this.closed || this.socket.destroyed) {
|
|
103
|
+
return Promise.reject(new Error('Browser daemon connection is closed'));
|
|
104
|
+
}
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
this.pending = { resolve, reject };
|
|
107
|
+
this.socket.write(`${JSON.stringify(request)}\n`, (error) => {
|
|
108
|
+
if (!error)
|
|
109
|
+
return;
|
|
110
|
+
if (this.pending?.reject === reject)
|
|
111
|
+
this.pending = undefined;
|
|
112
|
+
reject(error);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
handleData(data) {
|
|
117
|
+
this.buffer += data.toString();
|
|
118
|
+
while (true) {
|
|
119
|
+
const index = this.buffer.indexOf('\n');
|
|
120
|
+
if (index === -1)
|
|
121
|
+
return;
|
|
122
|
+
const line = this.buffer.slice(0, index);
|
|
123
|
+
this.buffer = this.buffer.slice(index + 1);
|
|
124
|
+
if (!line.trim())
|
|
125
|
+
continue;
|
|
126
|
+
const pending = this.pending;
|
|
127
|
+
if (!pending) {
|
|
128
|
+
this.socket.destroy();
|
|
129
|
+
this.fail(new Error('Browser daemon sent an unexpected response'));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
this.pending = undefined;
|
|
133
|
+
try {
|
|
134
|
+
pending.resolve(JSON.parse(line));
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
const error = new Error('Browser daemon returned invalid JSON');
|
|
138
|
+
pending.reject(error);
|
|
139
|
+
this.socket.destroy();
|
|
140
|
+
this.fail(error);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
fail(error) {
|
|
145
|
+
if (this.closed)
|
|
146
|
+
return;
|
|
147
|
+
this.closed = true;
|
|
148
|
+
const pending = this.pending;
|
|
149
|
+
this.pending = undefined;
|
|
150
|
+
pending?.reject(error);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
67
153
|
export class BrowserIPCServer {
|
|
68
154
|
server = null;
|
|
69
155
|
service;
|
|
@@ -527,33 +613,35 @@ async function reconcileDaemonVersion(socketPath) {
|
|
|
527
613
|
export async function sendIPCRequest(request, opts = {}) {
|
|
528
614
|
return sendRawIPCRequest(request, opts);
|
|
529
615
|
}
|
|
616
|
+
/**
|
|
617
|
+
* Open one connection that can serve many browser operations. This uses the
|
|
618
|
+
* same daemon readiness and version-reconciliation path as sendIPCRequest;
|
|
619
|
+
* only the client process and IPC socket lifetime differ.
|
|
620
|
+
*/
|
|
621
|
+
export async function connectBrowserIPC(opts = {}) {
|
|
622
|
+
const autoStartDaemon = opts.autoStartDaemon ?? true;
|
|
623
|
+
await prepareIPC('status', opts);
|
|
624
|
+
return new Promise((resolve, reject) => {
|
|
625
|
+
const socket = net.createConnection(getIpcEndpoint());
|
|
626
|
+
const onError = (error) => {
|
|
627
|
+
socket.destroy();
|
|
628
|
+
if (!autoStartDaemon && (error.code === 'ENOENT' || error.code === 'ECONNREFUSED')) {
|
|
629
|
+
reject(new BrowserDaemonNotRunningError());
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
reject(new Error(`IPC error: ${error.message}`));
|
|
633
|
+
};
|
|
634
|
+
socket.once('error', onError);
|
|
635
|
+
socket.once('connect', () => {
|
|
636
|
+
socket.off('error', onError);
|
|
637
|
+
resolve(new BrowserIPCConnection(socket));
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
}
|
|
530
641
|
async function sendRawIPCRequest(request, opts = {}) {
|
|
531
|
-
const socketPath = getSocketPath();
|
|
532
642
|
const endpoint = getIpcEndpoint();
|
|
533
643
|
const autoStartDaemon = opts.autoStartDaemon ?? true;
|
|
534
|
-
|
|
535
|
-
if (!autoStartDaemon) {
|
|
536
|
-
throw new BrowserDaemonNotRunningError();
|
|
537
|
-
}
|
|
538
|
-
if (!IS_WINDOWS) {
|
|
539
|
-
await fs.promises.mkdir(path.dirname(socketPath), { recursive: true, mode: 0o700 });
|
|
540
|
-
await fs.promises.chmod(path.dirname(socketPath), 0o700);
|
|
541
|
-
}
|
|
542
|
-
startDaemon();
|
|
543
|
-
if (!(await isDaemonReachable())) {
|
|
544
|
-
await waitForSocket(socketPath, 6000);
|
|
545
|
-
}
|
|
546
|
-
if (!(await isDaemonReachable())) {
|
|
547
|
-
throw new Error('Failed to start browser daemon');
|
|
548
|
-
}
|
|
549
|
-
await new Promise((r) => setTimeout(r, 300));
|
|
550
|
-
}
|
|
551
|
-
// Before serving a real request, make sure the daemon isn't running stale
|
|
552
|
-
// code. Skips the internal `version` probe (avoids recursion) and callers
|
|
553
|
-
// that opt out of auto-start. No-ops once reconciled or when versions match.
|
|
554
|
-
if (request.action !== 'version' && autoStartDaemon) {
|
|
555
|
-
await reconcileDaemonVersion(socketPath);
|
|
556
|
-
}
|
|
644
|
+
await prepareIPC(request.action, opts);
|
|
557
645
|
return new Promise((resolve, reject) => {
|
|
558
646
|
const socket = net.createConnection(endpoint);
|
|
559
647
|
let buffer = '';
|
|
@@ -583,3 +671,30 @@ async function sendRawIPCRequest(request, opts = {}) {
|
|
|
583
671
|
});
|
|
584
672
|
});
|
|
585
673
|
}
|
|
674
|
+
async function prepareIPC(action, opts) {
|
|
675
|
+
const socketPath = getSocketPath();
|
|
676
|
+
const autoStartDaemon = opts.autoStartDaemon ?? true;
|
|
677
|
+
if (!(await isDaemonReachable())) {
|
|
678
|
+
if (!autoStartDaemon) {
|
|
679
|
+
throw new BrowserDaemonNotRunningError();
|
|
680
|
+
}
|
|
681
|
+
if (!IS_WINDOWS) {
|
|
682
|
+
await fs.promises.mkdir(path.dirname(socketPath), { recursive: true, mode: 0o700 });
|
|
683
|
+
await fs.promises.chmod(path.dirname(socketPath), 0o700);
|
|
684
|
+
}
|
|
685
|
+
startDaemon();
|
|
686
|
+
if (!(await isDaemonReachable())) {
|
|
687
|
+
await waitForSocket(socketPath, 6000);
|
|
688
|
+
}
|
|
689
|
+
if (!(await isDaemonReachable())) {
|
|
690
|
+
throw new Error('Failed to start browser daemon');
|
|
691
|
+
}
|
|
692
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
693
|
+
}
|
|
694
|
+
// Before serving a real request, make sure the daemon isn't running stale
|
|
695
|
+
// code. Skips the internal `version` probe (avoids recursion) and callers
|
|
696
|
+
// that opt out of auto-start. No-ops once reconciled or when versions match.
|
|
697
|
+
if (action !== 'version' && autoStartDaemon) {
|
|
698
|
+
await reconcileDaemonVersion(socketPath);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
@@ -26,6 +26,17 @@ export declare function getProfileDownloadsDir(name: string): string;
|
|
|
26
26
|
export declare function getProfileSessionsDir(name: string, task: string): string;
|
|
27
27
|
export declare function listProfiles(): Promise<BrowserProfile[]>;
|
|
28
28
|
export declare function getProfile(name: string): Promise<BrowserProfile | null>;
|
|
29
|
+
/**
|
|
30
|
+
* True when `profile` can actually launch or attach on THIS machine.
|
|
31
|
+
*
|
|
32
|
+
* A remote (`ssh://`) profile runs its browser on the far host, so a local
|
|
33
|
+
* binary check doesn't apply — assume launchable. A local profile must have its
|
|
34
|
+
* configured browser/binary present on disk here; a `default` profile
|
|
35
|
+
* auto-created on a different OS (e.g. a macOS Chrome path reused on Linux — the
|
|
36
|
+
* "Custom binary not found" failure) fails this check so the caller re-detects
|
|
37
|
+
* for the current machine.
|
|
38
|
+
*/
|
|
39
|
+
export declare function isProfileLaunchableHere(profile: BrowserProfile): boolean;
|
|
29
40
|
/**
|
|
30
41
|
* Resolve the profile `agents browser start` uses when no `--profile` is given.
|
|
31
42
|
*
|
|
@@ -105,7 +105,7 @@ export async function getProfile(name) {
|
|
|
105
105
|
* "Custom binary not found" failure) fails this check so the caller re-detects
|
|
106
106
|
* for the current machine.
|
|
107
107
|
*/
|
|
108
|
-
function isProfileLaunchableHere(profile) {
|
|
108
|
+
export function isProfileLaunchableHere(profile) {
|
|
109
109
|
const remote = Object.values(getEndpointPresets(profile)).some((preset) => preset.target.startsWith('ssh://'));
|
|
110
110
|
if (remote)
|
|
111
111
|
return true;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Readable, Writable } from 'stream';
|
|
2
|
+
export interface BrowserIPCStreamOptions {
|
|
3
|
+
input: Readable;
|
|
4
|
+
output: Writable;
|
|
5
|
+
task?: string;
|
|
6
|
+
actor: string;
|
|
7
|
+
launchId?: string;
|
|
8
|
+
autoStartDaemon?: boolean;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Read browser IPC requests as NDJSON and write one NDJSON response per line.
|
|
12
|
+
* The Node process and daemon connection stay alive until input closes.
|
|
13
|
+
*/
|
|
14
|
+
export declare function runBrowserIPCStream(options: BrowserIPCStreamOptions): Promise<void>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createInterface } from 'readline';
|
|
2
|
+
import { connectBrowserIPC } from './ipc.js';
|
|
3
|
+
import { assertRemoteControlAllowed } from './remote-control.js';
|
|
4
|
+
function parseRequest(line) {
|
|
5
|
+
const parsed = JSON.parse(line);
|
|
6
|
+
if (!parsed ||
|
|
7
|
+
typeof parsed !== 'object' ||
|
|
8
|
+
Array.isArray(parsed) ||
|
|
9
|
+
typeof parsed.action !== 'string') {
|
|
10
|
+
throw new Error('Each input line must be a JSON object with an action');
|
|
11
|
+
}
|
|
12
|
+
return parsed;
|
|
13
|
+
}
|
|
14
|
+
function writeResponse(output, response) {
|
|
15
|
+
output.write(`${JSON.stringify(response)}\n`);
|
|
16
|
+
}
|
|
17
|
+
function writeErrorResponse(output, error) {
|
|
18
|
+
writeResponse(output, {
|
|
19
|
+
ok: false,
|
|
20
|
+
error: error instanceof Error ? error.message : String(error),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Read browser IPC requests as NDJSON and write one NDJSON response per line.
|
|
25
|
+
* The Node process and daemon connection stay alive until input closes.
|
|
26
|
+
*/
|
|
27
|
+
export async function runBrowserIPCStream(options) {
|
|
28
|
+
const client = await connectBrowserIPC({ autoStartDaemon: options.autoStartDaemon });
|
|
29
|
+
const lines = createInterface({ input: options.input, crlfDelay: Infinity });
|
|
30
|
+
let defaultTask = options.task;
|
|
31
|
+
try {
|
|
32
|
+
for await (const line of lines) {
|
|
33
|
+
if (!line.trim())
|
|
34
|
+
continue;
|
|
35
|
+
let request;
|
|
36
|
+
try {
|
|
37
|
+
request = parseRequest(line);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
writeErrorResponse(options.output, error);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (request.action === 'start') {
|
|
44
|
+
try {
|
|
45
|
+
assertRemoteControlAllowed();
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
writeErrorResponse(options.output, error);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
request = {
|
|
52
|
+
...request,
|
|
53
|
+
taskName: request.taskName ?? defaultTask,
|
|
54
|
+
actor: request.actor ?? options.actor,
|
|
55
|
+
launchId: request.launchId ?? options.launchId,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
else if (!request.task && defaultTask) {
|
|
59
|
+
request = { ...request, task: defaultTask };
|
|
60
|
+
}
|
|
61
|
+
const response = await client.request(request);
|
|
62
|
+
if (response.task)
|
|
63
|
+
defaultTask = response.task;
|
|
64
|
+
writeResponse(options.output, response);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
lines.close();
|
|
69
|
+
await client.close();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Meta } from '../types.js';
|
|
2
|
+
/** Why the owner sink cannot deliver from this box. */
|
|
3
|
+
export type OwnerSinkReason = 'rush-not-on-path' | 'rush-signed-out';
|
|
4
|
+
export interface OwnerSinkStatus {
|
|
5
|
+
/** Owner delivery is configured for this fleet (humans.yaml / notify.owner).
|
|
6
|
+
* When false, no finding is emitted — an un-opted-in box is not "broken". */
|
|
7
|
+
configured: boolean;
|
|
8
|
+
/** This box can actually deliver an owner notification right now. */
|
|
9
|
+
reachable: boolean;
|
|
10
|
+
/** Resolved owner channel (e.g. `imessage`). */
|
|
11
|
+
channel?: string;
|
|
12
|
+
/** Resolved transport after `notify.transports` mapping (usually === channel). */
|
|
13
|
+
transport?: string;
|
|
14
|
+
/** Set only when `reachable` is false. */
|
|
15
|
+
reason?: OwnerSinkReason;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Probe whether THIS box can deliver an owner notification right now. Returns
|
|
19
|
+
* `configured:false` (and the caller emits no finding) when the fleet has no
|
|
20
|
+
* owner channel configured — an un-opted-in box is not broken. When configured,
|
|
21
|
+
* reports whether the resolved transport can actually deliver from here.
|
|
22
|
+
*
|
|
23
|
+
* Only the concrete rush-backed failure the lane hits is reported as unreachable;
|
|
24
|
+
* non-rush transports (desktop / mailbox / …) deliver locally and are treated as
|
|
25
|
+
* reachable rather than probed, so this never invents a critical it cannot back.
|
|
26
|
+
*/
|
|
27
|
+
export declare function probeOwnerSink(meta: Meta): Promise<OwnerSinkStatus>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner-delivery-sink reachability probe (RUSH-2262).
|
|
3
|
+
*
|
|
4
|
+
* The feed/notify owner-delivery lane (`agents notify`, `agents feed post
|
|
5
|
+
* --level important` / `--blocked`) reaches the owner over the rush-backed owner
|
|
6
|
+
* channel (iMessage via `rush message send`). That transport can only deliver
|
|
7
|
+
* from a context that BOTH finds the `rush` CLI on PATH and can read its
|
|
8
|
+
* keychain-bound session. So a headless Linux fleet box (no rush) or a non-GUI
|
|
9
|
+
* SSH session on a mac (login keychain locked) structurally cannot escalate —
|
|
10
|
+
* and the failure is silent until a block is filed, surfacing only as the
|
|
11
|
+
* after-the-fact `owner failed: …` line. `agents doctor` had no signal for it,
|
|
12
|
+
* which is exactly the gap RUSH-2258 / RUSH-2262 flagged.
|
|
13
|
+
*
|
|
14
|
+
* This probes the SAME transport the lane uses, from the SAME context doctor runs
|
|
15
|
+
* in, so `agents doctor` can fail loud when this box cannot reach the owner. It is
|
|
16
|
+
* deliberately honest about context: `rush whoami` is what tells a real signed-in
|
|
17
|
+
* session apart from a keychain that is present but unreadable HERE. The session
|
|
18
|
+
* token is a keychain item, NOT `~/.rush/user.yaml` (RUSH-2262), so this never
|
|
19
|
+
* reads that file — checking it is the mistake that made a signed-in box look
|
|
20
|
+
* signed out.
|
|
21
|
+
*
|
|
22
|
+
* `agents notify --dry-run` is NOT this probe: it short-circuits before the
|
|
23
|
+
* `which rush` preflight (`providers/rush.ts`), so it reports `ok:true` on a box
|
|
24
|
+
* with no rush at all. Resolvability (does the envelope build?) and reachability
|
|
25
|
+
* (can this box actually deliver?) are different questions; this answers the
|
|
26
|
+
* second.
|
|
27
|
+
*/
|
|
28
|
+
import { execFile } from 'child_process';
|
|
29
|
+
import { promisify } from 'util';
|
|
30
|
+
import { readOwnerDest } from './send.js';
|
|
31
|
+
import { RUSH_CHANNELS } from './providers/rush.js';
|
|
32
|
+
const execFileAsync = promisify(execFile);
|
|
33
|
+
async function rushOnPath() {
|
|
34
|
+
try {
|
|
35
|
+
await execFileAsync('which', ['rush'], { timeout: 4000 });
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Signed-in state from `rush whoami`, read with THIS context's keychain access.
|
|
43
|
+
* 'unknown' (a timeout or output we can't classify) is treated as reachable by
|
|
44
|
+
* the caller — a slow or unexpected probe must not cry wolf and paint a false
|
|
45
|
+
* critical. Only a definitive "not logged in" is reported as signed out. */
|
|
46
|
+
async function rushSignedIn() {
|
|
47
|
+
const classify = (s) => {
|
|
48
|
+
const out = s.toLowerCase();
|
|
49
|
+
if (/not (logged in|signed in)/.test(out))
|
|
50
|
+
return 'no';
|
|
51
|
+
if (/logged in as|session:\s*valid/.test(out))
|
|
52
|
+
return 'yes';
|
|
53
|
+
return 'unknown';
|
|
54
|
+
};
|
|
55
|
+
try {
|
|
56
|
+
const { stdout, stderr } = await execFileAsync('rush', ['whoami'], { timeout: 5000 });
|
|
57
|
+
return classify(`${stdout}\n${stderr}`);
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
const e = err;
|
|
61
|
+
if (e.killed)
|
|
62
|
+
return 'unknown'; // timed out — do not conclude signed-out
|
|
63
|
+
// A signed-out rush commonly exits non-zero; trust an explicit message only.
|
|
64
|
+
return classify(`${e.stdout ?? ''}\n${e.stderr ?? ''}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Probe whether THIS box can deliver an owner notification right now. Returns
|
|
69
|
+
* `configured:false` (and the caller emits no finding) when the fleet has no
|
|
70
|
+
* owner channel configured — an un-opted-in box is not broken. When configured,
|
|
71
|
+
* reports whether the resolved transport can actually deliver from here.
|
|
72
|
+
*
|
|
73
|
+
* Only the concrete rush-backed failure the lane hits is reported as unreachable;
|
|
74
|
+
* non-rush transports (desktop / mailbox / …) deliver locally and are treated as
|
|
75
|
+
* reachable rather than probed, so this never invents a critical it cannot back.
|
|
76
|
+
*/
|
|
77
|
+
export async function probeOwnerSink(meta) {
|
|
78
|
+
const dest = readOwnerDest(meta);
|
|
79
|
+
if (!dest)
|
|
80
|
+
return { configured: false, reachable: false };
|
|
81
|
+
const channel = dest.channel;
|
|
82
|
+
const transport = meta.notify?.transports?.[channel] ?? channel;
|
|
83
|
+
if (RUSH_CHANNELS.includes(transport)) {
|
|
84
|
+
if (!(await rushOnPath())) {
|
|
85
|
+
return { configured: true, reachable: false, channel, transport, reason: 'rush-not-on-path' };
|
|
86
|
+
}
|
|
87
|
+
if ((await rushSignedIn()) === 'no') {
|
|
88
|
+
return { configured: true, reachable: false, channel, transport, reason: 'rush-signed-out' };
|
|
89
|
+
}
|
|
90
|
+
return { configured: true, reachable: true, channel, transport };
|
|
91
|
+
}
|
|
92
|
+
return { configured: true, reachable: true, channel, transport };
|
|
93
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** True only for a clean, single-line `sk-ant-oat01-…` token — see {@link SETUP_TOKEN_RE}. */
|
|
2
|
+
export declare function isValidClaudeSetupToken(value: string): boolean;
|
|
1
3
|
/** The per-account key an email maps to inside the `auth` bundle. */
|
|
2
4
|
export declare function claudeAccountTokenKey(account: string): string;
|
|
3
5
|
/** Signed-in account email for a version home, from `.claude.json` (no keychain). */
|
|
@@ -11,6 +11,24 @@ import { bundleBackend, bundleExists, readAndResolveBundleEnv } from './secrets/
|
|
|
11
11
|
* multi-account fleet.
|
|
12
12
|
*/
|
|
13
13
|
const AUTH_BUNDLE = 'auth';
|
|
14
|
+
/**
|
|
15
|
+
* A well-formed Claude OAuth setup-token: the `sk-ant-oat01-` prefix followed by
|
|
16
|
+
* token-safe characters only, on a single line. `claude setup-token` mints exactly
|
|
17
|
+
* this; nothing else is a token.
|
|
18
|
+
*
|
|
19
|
+
* The provisioning capture bug behind #1767 stored the raw `claude setup-token`
|
|
20
|
+
* TTY stream — the welcome banner, ANSI control sequences, box-drawing art, and the
|
|
21
|
+
* token buried inside — under a per-account key instead of the parsed value. That
|
|
22
|
+
* blob is not a token: injected as `Authorization: Bearer <blob>` it made Anthropic
|
|
23
|
+
* reject every request and the run crash. agents-cli only *consumes* these tokens
|
|
24
|
+
* (the mint itself is the Rush Cloud / mint-auth path), so this is the boundary
|
|
25
|
+
* where a corrupt bundle entry must be caught before it reaches the auth header.
|
|
26
|
+
*/
|
|
27
|
+
const SETUP_TOKEN_RE = /^sk-ant-oat01-[A-Za-z0-9_-]+$/;
|
|
28
|
+
/** True only for a clean, single-line `sk-ant-oat01-…` token — see {@link SETUP_TOKEN_RE}. */
|
|
29
|
+
export function isValidClaudeSetupToken(value) {
|
|
30
|
+
return SETUP_TOKEN_RE.test(value);
|
|
31
|
+
}
|
|
14
32
|
/** The per-account key an email maps to inside the `auth` bundle. */
|
|
15
33
|
export function claudeAccountTokenKey(account) {
|
|
16
34
|
const slug = account
|
|
@@ -55,7 +73,15 @@ export function resolveClaudeSetupToken(home) {
|
|
|
55
73
|
return null;
|
|
56
74
|
const { env } = readAndResolveBundleEnv(AUTH_BUNDLE, { caller: 'usage', agentOnly: true });
|
|
57
75
|
const v = (env[claudeAccountTokenKey(email)] ?? '').trim();
|
|
58
|
-
|
|
76
|
+
if (v.length === 0)
|
|
77
|
+
return null;
|
|
78
|
+
// Reject a malformed stored value (e.g. a captured setup-token TTY blob, #1767)
|
|
79
|
+
// rather than let it become an `Authorization: Bearer <blob>` header that
|
|
80
|
+
// Anthropic rejects and crashes the run. A corrupt entry is treated as no
|
|
81
|
+
// usable file-based token, so the caller falls back to the normal login.
|
|
82
|
+
if (!isValidClaudeSetupToken(v))
|
|
83
|
+
return null;
|
|
84
|
+
return v;
|
|
59
85
|
}
|
|
60
86
|
catch {
|
|
61
87
|
return null;
|
|
@@ -186,6 +186,8 @@ export interface CrabboxRunOptions extends CrabboxOptions {
|
|
|
186
186
|
onData?: (chunk: string) => void;
|
|
187
187
|
/** Force a full remote resync before running. */
|
|
188
188
|
fullResync?: boolean;
|
|
189
|
+
/** Refresh an existing lease with this idle window before running. */
|
|
190
|
+
renewIdleTimeoutSecs?: number;
|
|
189
191
|
}
|
|
190
192
|
/**
|
|
191
193
|
* Run `remoteCmd` on the leased box via `crabbox run` (crabbox syncs the dirty
|
package/dist/lib/crabbox/cli.js
CHANGED
|
@@ -485,6 +485,8 @@ export function crabboxRun(slug, remoteCmd, opts = {}) {
|
|
|
485
485
|
export function crabboxRunScript(slug, script, opts = {}) {
|
|
486
486
|
findCrabbox();
|
|
487
487
|
const args = ['run', '--id', slug, '--reclaim'];
|
|
488
|
+
if (opts.renewIdleTimeoutSecs !== undefined)
|
|
489
|
+
args.push('--idle-timeout', `${opts.renewIdleTimeoutSecs}s`);
|
|
488
490
|
if (opts.fullResync)
|
|
489
491
|
args.push('--full-resync');
|
|
490
492
|
args.push('--script-stdin');
|
|
@@ -233,7 +233,9 @@ export function isExpiredPoolStray(box, opts) {
|
|
|
233
233
|
return false;
|
|
234
234
|
if (box.expiresAt === null || box.expiresAt > nowSecs)
|
|
235
235
|
return false; // unexpired ⇒ reusable
|
|
236
|
-
if (box.lastTouchedAt
|
|
236
|
+
if (box.lastTouchedAt === null)
|
|
237
|
+
return false; // unknown age is never reap-safe
|
|
238
|
+
if (nowSecs - box.lastTouchedAt < graceSecs)
|
|
237
239
|
return false; // maybe active
|
|
238
240
|
return true;
|
|
239
241
|
}
|
|
@@ -329,9 +331,13 @@ export async function leaseAndRun(opts) {
|
|
|
329
331
|
let exitCode = null;
|
|
330
332
|
let toreDown = false;
|
|
331
333
|
try {
|
|
334
|
+
const renewIdleTimeoutSecs = reused && !opts.reuseBox && box.idleTimeoutSecs !== null
|
|
335
|
+
? box.idleTimeoutSecs
|
|
336
|
+
: undefined;
|
|
332
337
|
exitCode = await crabboxRunScript(box.slug, script, {
|
|
333
338
|
secretsBundle: opts.secretsBundle,
|
|
334
339
|
onData: opts.onData,
|
|
340
|
+
renewIdleTimeoutSecs,
|
|
335
341
|
});
|
|
336
342
|
}
|
|
337
343
|
finally {
|
package/dist/lib/daemon.js
CHANGED
|
@@ -20,6 +20,7 @@ import { executeJobDetached, monitorRunningJobs } from './runner.js';
|
|
|
20
20
|
import { detectOverdueJobs, notifyOverdue } from './overdue.js';
|
|
21
21
|
import { runCatchup } from './catchup.js';
|
|
22
22
|
import { notifyRoutineStart, notifyRoutineFinish, notifyRoutineStartFailed } from './routine-notify.js';
|
|
23
|
+
import { notifyOwnerRoutineFinish, notifyOwnerRoutineStartFailed } from './routine-notify-owner.js';
|
|
23
24
|
import { BrowserService } from './browser/service.js';
|
|
24
25
|
import { BrowserIPCServer } from './browser/ipc.js';
|
|
25
26
|
import { redactSecrets } from './redact.js';
|
|
@@ -516,6 +517,16 @@ export async function runDaemon() {
|
|
|
516
517
|
notifyRoutineFinish(final);
|
|
517
518
|
}
|
|
518
519
|
catch { /* best-effort */ }
|
|
520
|
+
// RUSH-2288: a failed/timed-out routine also reaches the OWNER's phone
|
|
521
|
+
// (in-process owner channel stack), not just the local desktop. Green
|
|
522
|
+
// runs are silent — the builder returns early. Async + swallowed so a
|
|
523
|
+
// delivery hiccup never blocks the finish path.
|
|
524
|
+
void notifyOwnerRoutineFinish(final)
|
|
525
|
+
.then((r) => {
|
|
526
|
+
if (r.attempts.length && !r.delivered)
|
|
527
|
+
log('WARN', `Owner failure-notify for '${config.name}' reached no channel (tried: ${r.attempts.map((a) => a.channel).join(', ')})`);
|
|
528
|
+
})
|
|
529
|
+
.catch(() => { });
|
|
519
530
|
},
|
|
520
531
|
});
|
|
521
532
|
log('INFO', `Job '${config.name}' spawned (run: ${meta.runId}, PID: ${meta.pid})`);
|
|
@@ -531,6 +542,15 @@ export async function runDaemon() {
|
|
|
531
542
|
notifyRoutineStartFailed(config, message);
|
|
532
543
|
}
|
|
533
544
|
catch { /* best-effort */ }
|
|
545
|
+
// RUSH-2288: the pre-spawn failure (e.g. auth_failed) is exactly the one the
|
|
546
|
+
// per-routine `agents notify` prompt can never send — its agent never ran —
|
|
547
|
+
// so the daemon reaches the owner directly.
|
|
548
|
+
void notifyOwnerRoutineStartFailed(config, message)
|
|
549
|
+
.then((r) => {
|
|
550
|
+
if (r.attempts.length && !r.delivered)
|
|
551
|
+
log('WARN', `Owner start-failure notify for '${config.name}' reached no channel (tried: ${r.attempts.map((a) => a.channel).join(', ')})`);
|
|
552
|
+
})
|
|
553
|
+
.catch(() => { });
|
|
534
554
|
}
|
|
535
555
|
};
|
|
536
556
|
let scheduler = null;
|
|
@@ -50,6 +50,8 @@ export interface SshHostKeyOptions {
|
|
|
50
50
|
/** Managed known_hosts path override (tests). Defaults to the CLI-managed store. */
|
|
51
51
|
knownHostsFile?: string;
|
|
52
52
|
}
|
|
53
|
+
/** OpenSSH argv that makes an explicit device key authoritative. */
|
|
54
|
+
export declare function deviceIdentityArgs(device: DeviceProfile): string[];
|
|
53
55
|
/**
|
|
54
56
|
* Build the argv (after the `ssh` program name) and the environment overlay
|
|
55
57
|
* for connecting to a device. For password auth this points `SSH_ASKPASS` at
|
|
@@ -82,6 +82,12 @@ export function wrapRemoteCommand(device, cmd) {
|
|
|
82
82
|
}
|
|
83
83
|
return joined;
|
|
84
84
|
}
|
|
85
|
+
/** OpenSSH argv that makes an explicit device key authoritative. */
|
|
86
|
+
export function deviceIdentityArgs(device) {
|
|
87
|
+
return device.auth?.method === 'key' && device.auth.identityFile
|
|
88
|
+
? ['-i', device.auth.identityFile, '-o', 'IdentitiesOnly=yes']
|
|
89
|
+
: [];
|
|
90
|
+
}
|
|
85
91
|
/**
|
|
86
92
|
* Build the argv (after the `ssh` program name) and the environment overlay
|
|
87
93
|
* for connecting to a device. For password auth this points `SSH_ASKPASS` at
|
|
@@ -118,6 +124,7 @@ export function buildSshInvocation(device, cmd, askpassShimPath, hostKey = {}, o
|
|
|
118
124
|
}
|
|
119
125
|
else {
|
|
120
126
|
args.push('-o', 'BatchMode=yes');
|
|
127
|
+
args.push(...deviceIdentityArgs(device));
|
|
121
128
|
}
|
|
122
129
|
// An interactive login (no remote command) needs a real tty.
|
|
123
130
|
if (!remote)
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { AgentId } from '../types.js';
|
|
2
2
|
import type { DuplicateVersionHook } from '../hooks.js';
|
|
3
3
|
import type { RcSecretFinding } from '../secrets/rc-hygiene.js';
|
|
4
|
+
import type { OwnerSinkStatus } from '../channels/owner-sink.js';
|
|
5
|
+
import { type WindowsSshEnrollmentAudit } from './windows-ssh-enrollment.js';
|
|
4
6
|
import type { SyncStatusRow, OrphanRow } from '../drift.js';
|
|
5
7
|
import type { FetchStatusMarker } from '../auto-pull.js';
|
|
6
8
|
import type { VersionResourceReport } from '../doctor-diff.js';
|
|
@@ -10,7 +12,7 @@ export type FindingSeverity = 'critical' | 'warning';
|
|
|
10
12
|
* the JSON consumer group by kind. */
|
|
11
13
|
/** Every finding class. Severity is NOT annotated here — {@link FINDING_SEVERITY}
|
|
12
14
|
* below owns it, and a second copy in these comments is a fourth place to drift. */
|
|
13
|
-
export declare const ALL_FINDING_KINDS: readonly ["logged-out", "logout-unprovable", "missing-hook", "missing-plugin", "unwired-hook", "cli-missing", "missing-resource", "content-drift", "never-synced", "stale", "repo-behind", "repo-drift", "fleet-resource-gap", "host-cli-missing", "host-cli-invalid", "version-skew", "orphan", "duplicate-hook", "duplicate-hook-drift", "rc-secret-export", "env-secret-export", "exec-policy", "stale-cli"];
|
|
15
|
+
export declare const ALL_FINDING_KINDS: readonly ["logged-out", "logout-unprovable", "missing-hook", "missing-plugin", "unwired-hook", "cli-missing", "missing-resource", "content-drift", "never-synced", "stale", "repo-behind", "repo-drift", "fleet-resource-gap", "host-cli-missing", "host-cli-invalid", "version-skew", "orphan", "duplicate-hook", "duplicate-hook-drift", "rc-secret-export", "env-secret-export", "exec-policy", "ssh-key-enrollment", "stale-cli", "owner-sink-unreachable"];
|
|
14
16
|
/**
|
|
15
17
|
* The severity each kind is emitted with - the SINGLE source of truth, read by
|
|
16
18
|
* the builders below and asserted against both prose rubrics by
|
|
@@ -101,11 +103,18 @@ export interface LocalFindingInputs {
|
|
|
101
103
|
platform: NodeJS.Platform;
|
|
102
104
|
policy: string | null;
|
|
103
105
|
};
|
|
106
|
+
/** Read-only Windows OpenSSH AuthorizedKeysFile/content/ACL audit. */
|
|
107
|
+
windowsSshEnrollment?: WindowsSshEnrollmentAudit | null;
|
|
104
108
|
/** `<agent>@<version>` keys whose home is an isolated copy. Their findings are
|
|
105
109
|
* never collapsed across versions: the agent-wide `agents doctor <agent> --fix`
|
|
106
110
|
* sweep deliberately skips isolated copies, so a collapsed row would print a
|
|
107
111
|
* remediation that does not fix them. */
|
|
108
112
|
isolatedVersions?: string[];
|
|
113
|
+
/** Whether the feed/notify owner-delivery lane can reach the owner from this box
|
|
114
|
+
* (RUSH-2262). Collected by `probeOwnerSink` in the command (it spawns the real
|
|
115
|
+
* `rush` transport, so it stays out of this pure module). Absent → no probe ran
|
|
116
|
+
* → no finding. */
|
|
117
|
+
ownerSink?: OwnerSinkStatus;
|
|
109
118
|
}
|
|
110
119
|
/**
|
|
111
120
|
* Fold this machine's signals into findings. Missing hooks/plugins and unwired
|