@phnx-labs/agents-cli 1.20.64 → 1.20.65
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 +39 -3
- package/README.md +37 -2
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.d.ts +12 -0
- package/dist/commands/apply.js +274 -0
- package/dist/commands/browser.js +2 -2
- package/dist/commands/cloud.js +32 -2
- package/dist/commands/doctor.js +4 -1
- package/dist/commands/exec.js +94 -49
- package/dist/commands/feed.js +25 -11
- package/dist/commands/hosts.js +44 -6
- package/dist/commands/mcp.js +55 -5
- package/dist/commands/monitors.d.ts +12 -0
- package/dist/commands/monitors.js +740 -0
- package/dist/commands/output.js +2 -2
- package/dist/commands/routines.js +23 -2
- package/dist/commands/secrets.d.ts +16 -0
- package/dist/commands/secrets.js +215 -64
- package/dist/commands/serve.js +31 -0
- package/dist/commands/sessions-export.js +8 -3
- package/dist/commands/sessions.d.ts +16 -0
- package/dist/commands/sessions.js +36 -6
- package/dist/commands/ssh.js +45 -2
- package/dist/commands/versions.js +7 -3
- package/dist/commands/view.d.ts +26 -0
- package/dist/commands/view.js +32 -9
- package/dist/commands/webhook.js +10 -2
- package/dist/index.js +34 -14
- package/dist/lib/agents.d.ts +18 -0
- package/dist/lib/agents.js +53 -3
- package/dist/lib/auto-dispatch-provider.js +7 -2
- package/dist/lib/auto-dispatch.d.ts +3 -0
- package/dist/lib/auto-dispatch.js +3 -0
- package/dist/lib/browser/chrome.js +2 -2
- package/dist/lib/cloud/antigravity.js +2 -2
- package/dist/lib/cloud/host.d.ts +59 -0
- package/dist/lib/cloud/host.js +224 -0
- package/dist/lib/cloud/registry.js +4 -0
- package/dist/lib/cloud/types.d.ts +6 -4
- package/dist/lib/computer-rpc.js +3 -1
- package/dist/lib/crabbox/cli.js +5 -1
- package/dist/lib/crabbox/runtimes.js +11 -2
- package/dist/lib/daemon.d.ts +20 -4
- package/dist/lib/daemon.js +62 -19
- package/dist/lib/devices/fleet.d.ts +3 -2
- package/dist/lib/devices/fleet.js +9 -0
- package/dist/lib/devices/registry.d.ts +15 -0
- package/dist/lib/devices/registry.js +9 -0
- package/dist/lib/exec.d.ts +19 -2
- package/dist/lib/exec.js +41 -13
- package/dist/lib/fleet/apply.d.ts +63 -0
- package/dist/lib/fleet/apply.js +214 -0
- package/dist/lib/fleet/auth-sync.d.ts +67 -0
- package/dist/lib/fleet/auth-sync.js +142 -0
- package/dist/lib/fleet/manifest.d.ts +29 -0
- package/dist/lib/fleet/manifest.js +127 -0
- package/dist/lib/fleet/types.d.ts +129 -0
- package/dist/lib/fleet/types.js +13 -0
- package/dist/lib/git.d.ts +27 -0
- package/dist/lib/git.js +34 -2
- package/dist/lib/hosts/dispatch.d.ts +29 -8
- package/dist/lib/hosts/dispatch.js +46 -18
- package/dist/lib/hosts/passthrough.js +2 -0
- package/dist/lib/hosts/providers/devices.d.ts +27 -0
- package/dist/lib/hosts/providers/devices.js +98 -0
- package/dist/lib/hosts/registry.d.ts +10 -16
- package/dist/lib/hosts/registry.js +17 -50
- package/dist/lib/hosts/remote-cmd.d.ts +23 -0
- package/dist/lib/hosts/remote-cmd.js +71 -0
- package/dist/lib/hosts/run-target.d.ts +84 -0
- package/dist/lib/hosts/run-target.js +99 -0
- package/dist/lib/hosts/types.d.ts +23 -5
- package/dist/lib/hosts/types.js +22 -4
- package/dist/lib/linear-autoclose.d.ts +30 -0
- package/dist/lib/linear-autoclose.js +22 -0
- package/dist/lib/mcp.d.ts +27 -1
- package/dist/lib/mcp.js +126 -12
- package/dist/lib/monitors/config.d.ts +161 -0
- package/dist/lib/monitors/config.js +372 -0
- package/dist/lib/monitors/dispatch.d.ts +28 -0
- package/dist/lib/monitors/dispatch.js +91 -0
- package/dist/lib/monitors/engine.d.ts +61 -0
- package/dist/lib/monitors/engine.js +201 -0
- package/dist/lib/monitors/sources/command.d.ts +11 -0
- package/dist/lib/monitors/sources/command.js +31 -0
- package/dist/lib/monitors/sources/device.d.ts +13 -0
- package/dist/lib/monitors/sources/device.js +35 -0
- package/dist/lib/monitors/sources/file.d.ts +14 -0
- package/dist/lib/monitors/sources/file.js +57 -0
- package/dist/lib/monitors/sources/http.d.ts +10 -0
- package/dist/lib/monitors/sources/http.js +34 -0
- package/dist/lib/monitors/sources/index.d.ts +14 -0
- package/dist/lib/monitors/sources/index.js +31 -0
- package/dist/lib/monitors/sources/poll.d.ts +9 -0
- package/dist/lib/monitors/sources/poll.js +9 -0
- package/dist/lib/monitors/sources/types.d.ts +18 -0
- package/dist/lib/monitors/sources/types.js +9 -0
- package/dist/lib/monitors/sources/webhook.d.ts +23 -0
- package/dist/lib/monitors/sources/webhook.js +47 -0
- package/dist/lib/monitors/sources/ws.d.ts +14 -0
- package/dist/lib/monitors/sources/ws.js +45 -0
- package/dist/lib/monitors/state.d.ts +69 -0
- package/dist/lib/monitors/state.js +144 -0
- package/dist/lib/platform/exec.d.ts +16 -0
- package/dist/lib/platform/exec.js +17 -0
- package/dist/lib/plugins.js +101 -2
- package/dist/lib/redact.d.ts +14 -1
- package/dist/lib/redact.js +47 -1
- package/dist/lib/remote-agents-json.js +7 -1
- package/dist/lib/rotate.d.ts +6 -3
- package/dist/lib/rotate.js +0 -1
- package/dist/lib/routines.d.ts +16 -0
- package/dist/lib/routines.js +19 -0
- package/dist/lib/runner.d.ts +1 -0
- package/dist/lib/runner.js +102 -9
- package/dist/lib/secrets/agent.d.ts +48 -10
- package/dist/lib/secrets/agent.js +123 -15
- package/dist/lib/secrets/bundles.d.ts +26 -0
- package/dist/lib/secrets/bundles.js +59 -8
- package/dist/lib/secrets/mcp.js +4 -2
- package/dist/lib/secrets/remote.d.ts +17 -0
- package/dist/lib/secrets/remote.js +40 -0
- package/dist/lib/self-update.d.ts +20 -0
- package/dist/lib/self-update.js +54 -1
- package/dist/lib/serve/control.d.ts +95 -0
- package/dist/lib/serve/control.js +260 -0
- package/dist/lib/serve/server.d.ts +35 -1
- package/dist/lib/serve/server.js +106 -76
- package/dist/lib/serve/stream.d.ts +43 -0
- package/dist/lib/serve/stream.js +116 -0
- package/dist/lib/serve/token.d.ts +35 -0
- package/dist/lib/serve/token.js +85 -0
- package/dist/lib/session/bundle.d.ts +14 -0
- package/dist/lib/session/bundle.js +12 -1
- package/dist/lib/session/remote-list.js +5 -1
- package/dist/lib/session/state.d.ts +7 -25
- package/dist/lib/session/state.js +16 -6
- package/dist/lib/session/sync/config.js +8 -2
- package/dist/lib/session/types.d.ts +30 -0
- package/dist/lib/ssh-tunnel.d.ts +19 -1
- package/dist/lib/ssh-tunnel.js +86 -7
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +4 -0
- package/dist/lib/state.d.ts +5 -0
- package/dist/lib/state.js +12 -0
- package/dist/lib/tmux/session.d.ts +7 -0
- package/dist/lib/tmux/session.js +3 -1
- package/dist/lib/triggers/webhook.d.ts +18 -0
- package/dist/lib/triggers/webhook.js +105 -0
- package/dist/lib/types.d.ts +26 -1
- package/dist/lib/usage.js +7 -5
- package/dist/lib/versions.js +14 -11
- package/dist/lib/workflows.d.ts +20 -0
- package/dist/lib/workflows.js +24 -0
- package/package.json +2 -1
|
@@ -21,7 +21,36 @@ import { emit } from '../events.js';
|
|
|
21
21
|
import { sshTargetFor } from '../hosts/types.js';
|
|
22
22
|
import { buildRemoteAgentsInvocation } from '../hosts/remote-cmd.js';
|
|
23
23
|
import { resolveRemoteOsSync } from '../hosts/remote-os.js';
|
|
24
|
+
import { isLoaderOrInterpreterEnv } from './bundles.js';
|
|
24
25
|
const REMOTE_TIMEOUT_MS = 30_000;
|
|
26
|
+
/**
|
|
27
|
+
* Trust boundary for a remote-resolved env map. A peer's `secrets export` output
|
|
28
|
+
* is untrusted input: a compromised or misconfigured host could return keys that
|
|
29
|
+
* silently reshape THIS process's behavior once merged into the agent env
|
|
30
|
+
* (bundles.ts:251 `sanitizeProcessEnv` only strips loader vars from process.env,
|
|
31
|
+
* never the remote bundle). Block the dangerous-override classes here — at the
|
|
32
|
+
* source — so every consumer (`run --secrets b@host`, `secrets exec --host`) is
|
|
33
|
+
* protected, not just one call site:
|
|
34
|
+
* - LD_* / DYLD_* / NODE_OPTIONS and the other loader/interpreter injections
|
|
35
|
+
* (reuses the canonical bundles.ts predicate);
|
|
36
|
+
* - GIT_* — GIT_SSH_COMMAND et al. hijack every git subprocess;
|
|
37
|
+
* - *_PROXY — HTTP(S)_PROXY / ALL_PROXY reroute outbound traffic (MITM);
|
|
38
|
+
* - *_BASE_URL — ANTHROPIC_BASE_URL / OPENAI_BASE_URL redirect the model API.
|
|
39
|
+
* These keys are already rejected on the ADD side (validateEnvKey for loaders),
|
|
40
|
+
* so a legitimate bundle never carries them — only a hostile peer would.
|
|
41
|
+
*/
|
|
42
|
+
export function isDangerousRemoteEnvKey(name) {
|
|
43
|
+
const upper = name.toUpperCase();
|
|
44
|
+
if (isLoaderOrInterpreterEnv(upper))
|
|
45
|
+
return true;
|
|
46
|
+
if (upper.startsWith('GIT_'))
|
|
47
|
+
return true;
|
|
48
|
+
if (upper.endsWith('_PROXY'))
|
|
49
|
+
return true;
|
|
50
|
+
if (upper.endsWith('_BASE_URL'))
|
|
51
|
+
return true;
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
25
54
|
/** Remote OS for a host name or target string. Prefer the original host name
|
|
26
55
|
* because enrolled inline hosts resolve to `user@address`, while the OS
|
|
27
56
|
* registry is keyed by the host name. */
|
|
@@ -153,9 +182,20 @@ export async function remoteResolveEnv(target, bundle, opts = {}) {
|
|
|
153
182
|
throw new Error(`Unexpected payload resolving '${bundle}' on ${target}.`);
|
|
154
183
|
}
|
|
155
184
|
const env = {};
|
|
185
|
+
const blocked = [];
|
|
156
186
|
for (const [k, v] of Object.entries(parsed)) {
|
|
187
|
+
// Drop dangerous-override keys returned by the (untrusted) peer before they
|
|
188
|
+
// can reshape this process — see isDangerousRemoteEnvKey.
|
|
189
|
+
if (isDangerousRemoteEnvKey(k)) {
|
|
190
|
+
blocked.push(k);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
157
193
|
env[k] = typeof v === 'string' ? v : String(v);
|
|
158
194
|
}
|
|
195
|
+
if (blocked.length > 0) {
|
|
196
|
+
process.stderr.write(`[secrets] Dropped ${blocked.length} dangerous key(s) from '${bundle}'@${target} ` +
|
|
197
|
+
`(remote override blocked): ${blocked.join(', ')}\n`);
|
|
198
|
+
}
|
|
159
199
|
// The remote host audits its own `secrets export` read; this emit records the
|
|
160
200
|
// event on the INITIATING host too (values were pulled into this process and
|
|
161
201
|
// injected locally). Covers `secrets exec --host` and `run --secrets b@host`.
|
|
@@ -83,6 +83,26 @@ export declare function installPackageIntoPrefix(spec: string, prefix: string):
|
|
|
83
83
|
* package's postinstall hook.
|
|
84
84
|
*/
|
|
85
85
|
export declare function installPackageWithBun(spec: string): Promise<void>;
|
|
86
|
+
/**
|
|
87
|
+
* Verify a downloaded tarball's bytes against a Subresource Integrity (SRI)
|
|
88
|
+
* string of the form `sha512-<base64>` — npm's `dist.integrity`. Recomputes the
|
|
89
|
+
* digest over the actual bytes with the named algorithm and compares it, in
|
|
90
|
+
* constant time, to the decoded expected digest.
|
|
91
|
+
*
|
|
92
|
+
* Fails closed: a malformed SRI, an algorithm weaker than sha512, or any digest
|
|
93
|
+
* mismatch throws. This is the gate that makes self-update refuse a tampered or
|
|
94
|
+
* corrupted tarball *before* it is ever handed to a package manager to install.
|
|
95
|
+
*/
|
|
96
|
+
export declare function verifyTarballIntegrity(tarball: Buffer, integrity: string): void;
|
|
97
|
+
/**
|
|
98
|
+
* Download the published tarball at `tarballUrl` and prove its bytes match
|
|
99
|
+
* `integrity` before returning a path to it on disk. The returned .tgz is safe
|
|
100
|
+
* to hand to `npm install`/`bun add` — it has been verified byte-for-byte
|
|
101
|
+
* against the registry attestation. Fails closed: a non-200, a download error,
|
|
102
|
+
* or a hash mismatch throws and no file path is returned, so the caller never
|
|
103
|
+
* installs an unverified artifact.
|
|
104
|
+
*/
|
|
105
|
+
export declare function downloadVerifiedTarball(tarballUrl: string, integrity: string, timeoutMs?: number): Promise<string>;
|
|
86
106
|
/** Read the version field of the package.json at `packageRoot`, fresh from disk. */
|
|
87
107
|
export declare function readInstalledVersion(packageRoot: string): string;
|
|
88
108
|
/**
|
package/dist/lib/self-update.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import * as fs from 'fs';
|
|
15
15
|
import * as os from 'os';
|
|
16
16
|
import * as path from 'path';
|
|
17
|
+
import { createHash, timingSafeEqual } from 'crypto';
|
|
17
18
|
import { spawnSync } from 'child_process';
|
|
18
19
|
import { compareVersions } from './versions.js';
|
|
19
20
|
import { needsWindowsShell } from './platform/index.js';
|
|
@@ -164,7 +165,59 @@ export async function installPackageWithBun(spec) {
|
|
|
164
165
|
const { promisify } = await import('util');
|
|
165
166
|
const execFileAsync = promisify(execFile);
|
|
166
167
|
// On Windows `bun` resolves to `bun.exe`/`bun.cmd`; force shell for the .cmd case.
|
|
167
|
-
|
|
168
|
+
// --ignore-scripts: the tarball has already been integrity-verified, but its
|
|
169
|
+
// lifecycle scripts must not run at install time (the caller refreshes shims
|
|
170
|
+
// explicitly via refreshAliasShims()) — same fail-closed posture as the npm path.
|
|
171
|
+
await execFileAsync('bun', ['add', '-g', spec, '--ignore-scripts'], { shell: needsWindowsShell('bun') });
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Verify a downloaded tarball's bytes against a Subresource Integrity (SRI)
|
|
175
|
+
* string of the form `sha512-<base64>` — npm's `dist.integrity`. Recomputes the
|
|
176
|
+
* digest over the actual bytes with the named algorithm and compares it, in
|
|
177
|
+
* constant time, to the decoded expected digest.
|
|
178
|
+
*
|
|
179
|
+
* Fails closed: a malformed SRI, an algorithm weaker than sha512, or any digest
|
|
180
|
+
* mismatch throws. This is the gate that makes self-update refuse a tampered or
|
|
181
|
+
* corrupted tarball *before* it is ever handed to a package manager to install.
|
|
182
|
+
*/
|
|
183
|
+
export function verifyTarballIntegrity(tarball, integrity) {
|
|
184
|
+
const dash = integrity.indexOf('-');
|
|
185
|
+
if (dash <= 0) {
|
|
186
|
+
throw new Error(`malformed integrity string: ${JSON.stringify(integrity)}`);
|
|
187
|
+
}
|
|
188
|
+
const algorithm = integrity.slice(0, dash);
|
|
189
|
+
const expectedBase64 = integrity.slice(dash + 1);
|
|
190
|
+
// npm publishes sha512 SRI; refuse to verify against anything weaker rather
|
|
191
|
+
// than silently accepting a downgraded (e.g. sha1) attestation.
|
|
192
|
+
if (algorithm !== 'sha512') {
|
|
193
|
+
throw new Error(`unsupported integrity algorithm '${algorithm}' (expected sha512)`);
|
|
194
|
+
}
|
|
195
|
+
const expected = Buffer.from(expectedBase64, 'base64');
|
|
196
|
+
const actual = createHash('sha512').update(tarball).digest();
|
|
197
|
+
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
|
|
198
|
+
throw new Error(`integrity check failed: tarball hash sha512-${actual.toString('base64')} ` +
|
|
199
|
+
`does not match expected ${integrity}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Download the published tarball at `tarballUrl` and prove its bytes match
|
|
204
|
+
* `integrity` before returning a path to it on disk. The returned .tgz is safe
|
|
205
|
+
* to hand to `npm install`/`bun add` — it has been verified byte-for-byte
|
|
206
|
+
* against the registry attestation. Fails closed: a non-200, a download error,
|
|
207
|
+
* or a hash mismatch throws and no file path is returned, so the caller never
|
|
208
|
+
* installs an unverified artifact.
|
|
209
|
+
*/
|
|
210
|
+
export async function downloadVerifiedTarball(tarballUrl, integrity, timeoutMs = 60_000) {
|
|
211
|
+
const response = await fetch(tarballUrl, { signal: AbortSignal.timeout(timeoutMs) });
|
|
212
|
+
if (!response.ok) {
|
|
213
|
+
throw new Error(`could not download tarball from ${tarballUrl} (HTTP ${response.status})`);
|
|
214
|
+
}
|
|
215
|
+
const tarball = Buffer.from(await response.arrayBuffer());
|
|
216
|
+
verifyTarballIntegrity(tarball, integrity);
|
|
217
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agents-cli-upgrade-'));
|
|
218
|
+
const file = path.join(dir, path.basename(new URL(tarballUrl).pathname) || 'package.tgz');
|
|
219
|
+
fs.writeFileSync(file, tarball);
|
|
220
|
+
return file;
|
|
168
221
|
}
|
|
169
222
|
/** Read the version field of the package.json at `packageRoot`, fresh from disk. */
|
|
170
223
|
export function readInstalledVersion(packageRoot) {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authenticated `agents serve --control` server — the "anchor" the iOS/iPadOS
|
|
3
|
+
* cockpit talks to (RUSH-1731).
|
|
4
|
+
*
|
|
5
|
+
* It is the read-only {@link handleServeGet} surface plus a small mutation API,
|
|
6
|
+
* gated by a bearer token ({@link verifyControlToken}) on EVERY request because,
|
|
7
|
+
* unlike plain `agents serve`, this variant may bind beyond loopback (a tailnet
|
|
8
|
+
* address) so the phone can reach it. It adds no new execution machinery: a run
|
|
9
|
+
* request re-invokes the same `agents run` the CLI uses (inheriting host
|
|
10
|
+
* offload, secrets, detached dispatch), and a message re-invokes `agents
|
|
11
|
+
* message`. The server generates the run's session id so the run is immediately
|
|
12
|
+
* addressable for streaming (Phase 2) and steering.
|
|
13
|
+
*
|
|
14
|
+
* Both side-effecting operations are behind DI seams ({@link ControlOptions})
|
|
15
|
+
* so tests drive them without spawning real agents — mirroring the `snapshot`
|
|
16
|
+
* seam on the read-only server.
|
|
17
|
+
*/
|
|
18
|
+
import type http from 'http';
|
|
19
|
+
import type { StdioOptions } from 'child_process';
|
|
20
|
+
import { type ServeOptions } from './server.js';
|
|
21
|
+
/** A request to start an agent run on this anchor (local or offloaded). */
|
|
22
|
+
export interface RunRequest {
|
|
23
|
+
/** Agent to run: 'claude' | 'codex' | 'gemini' | … */
|
|
24
|
+
agent: string;
|
|
25
|
+
/** Headless prompt. */
|
|
26
|
+
prompt: string;
|
|
27
|
+
/** Permission mode: plan | edit | auto | skip. Defaults to the CLI default. */
|
|
28
|
+
mode?: string;
|
|
29
|
+
/** Offload onto a registered device / host (the executor). Omit → run on the anchor. */
|
|
30
|
+
host?: string;
|
|
31
|
+
/** Working directory (on the host when `host` is set). */
|
|
32
|
+
cwd?: string;
|
|
33
|
+
}
|
|
34
|
+
/** Identifiers by which the started run can later be streamed / messaged. */
|
|
35
|
+
export interface RunResult {
|
|
36
|
+
/** Addressable session id (a UUID for claude; otherwise equals `name`). */
|
|
37
|
+
sessionId: string;
|
|
38
|
+
/** Durable run name seeded into the session label. */
|
|
39
|
+
name: string;
|
|
40
|
+
}
|
|
41
|
+
/** Starts a run and returns its addressable ids. */
|
|
42
|
+
export type RunDispatcher = (req: RunRequest) => Promise<RunResult>;
|
|
43
|
+
/** Delivers a message to a running/parked agent by id. */
|
|
44
|
+
export type Messenger = (id: string, text: string, from?: string) => Promise<void>;
|
|
45
|
+
export interface ControlOptions extends ServeOptions {
|
|
46
|
+
/** Bearer verifier. Defaults to {@link verifyControlToken}. */
|
|
47
|
+
verifyToken?: (presented: string | undefined) => boolean;
|
|
48
|
+
/** Run starter. Defaults to spawning `agents run …` detached. */
|
|
49
|
+
runner?: RunDispatcher;
|
|
50
|
+
/** Message sender. Defaults to spawning `agents message …`. */
|
|
51
|
+
messenger?: Messenger;
|
|
52
|
+
/** Poll cadence (ms) for the session event stream. Defaults to 300. */
|
|
53
|
+
streamPollMs?: number;
|
|
54
|
+
/** Resolve a session id to its NDJSON capture file. Defaults to {@link streamLogPath}. */
|
|
55
|
+
streamLogPathFor?: (sessionId: string) => string;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* SSE stream of a session's normalized NDJSON events, offset-tailed from its
|
|
59
|
+
* capture file. Resumes from `?offset=<bytes>` or the `Last-Event-ID` header
|
|
60
|
+
* (each event's `id:` is its exact byte offset, so resume neither loses nor
|
|
61
|
+
* duplicates). Closes when a terminal (`result`/`error`) event is seen or the
|
|
62
|
+
* client disconnects. A not-yet-created file simply yields nothing until the
|
|
63
|
+
* run starts writing — the phone can open the stream the instant it dispatches.
|
|
64
|
+
*/
|
|
65
|
+
export declare function startSessionStream(file: string, req: http.IncomingMessage, res: http.ServerResponse, pollMs: number): void;
|
|
66
|
+
/**
|
|
67
|
+
* Spawn a detached child and settle once it has either successfully spawned or
|
|
68
|
+
* failed to. Attaching an `'error'` listener is mandatory: a `ChildProcess` is
|
|
69
|
+
* an `EventEmitter`, so an `'error'` event (e.g. ENOENT from a stale/missing
|
|
70
|
+
* `agents` binary — a real condition `validateDaemonBinary` guards against)
|
|
71
|
+
* with no listener throws and would take down the whole anchor process, not
|
|
72
|
+
* just the one request. Mirrors the `once('spawn') / once('error')` pattern in
|
|
73
|
+
* `teams/agents.ts`. The `'error'` listener registered by `once` survives after
|
|
74
|
+
* a successful spawn, so a later error is absorbed rather than crashing.
|
|
75
|
+
*/
|
|
76
|
+
export declare function spawnDetached(command: string, args: string[], stdio?: StdioOptions): Promise<void>;
|
|
77
|
+
/**
|
|
78
|
+
* Default run dispatcher: re-invoke `agents run` with a server-minted id so the
|
|
79
|
+
* run is addressable, detached so it outlives this request (and the anchor
|
|
80
|
+
* process). Local runs detach here; `--host` runs detach on the executor via
|
|
81
|
+
* the existing dispatch path.
|
|
82
|
+
*/
|
|
83
|
+
export declare const defaultRunner: RunDispatcher;
|
|
84
|
+
/** Default messenger: re-invoke `agents message <id> <text>` and await its exit. */
|
|
85
|
+
export declare const defaultMessenger: Messenger;
|
|
86
|
+
/**
|
|
87
|
+
* Create (but do not start) the authenticated control server. Caller invokes
|
|
88
|
+
* `.listen(port, bind)`.
|
|
89
|
+
*/
|
|
90
|
+
export declare function createControlServer(opts?: ControlOptions): http.Server;
|
|
91
|
+
/** Start the control server on `port`, bound to `bind`. Resolves the bound port. */
|
|
92
|
+
export declare function startControlServer(port: number, bind: string, opts?: ControlOptions): Promise<{
|
|
93
|
+
server: http.Server;
|
|
94
|
+
port: number;
|
|
95
|
+
}>;
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { createServer } from 'http';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
import { randomUUID, randomBytes } from 'crypto';
|
|
5
|
+
import { getAgentsInvocation } from '../daemon.js';
|
|
6
|
+
import { handleServeGet, resolveServeContext } from './server.js';
|
|
7
|
+
import { verifyControlToken } from './token.js';
|
|
8
|
+
import { readNewEvents, streamDir, streamLogPath } from './stream.js';
|
|
9
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
10
|
+
const DEFAULT_STREAM_POLL_MS = 300;
|
|
11
|
+
/** Pull the presented bearer token from either header form. */
|
|
12
|
+
function presentedToken(req) {
|
|
13
|
+
const auth = req.headers['authorization'];
|
|
14
|
+
if (typeof auth === 'string' && auth.toLowerCase().startsWith('bearer ')) {
|
|
15
|
+
return auth.slice(7).trim();
|
|
16
|
+
}
|
|
17
|
+
const x = req.headers['x-agents-token'];
|
|
18
|
+
return typeof x === 'string' ? x.trim() : undefined;
|
|
19
|
+
}
|
|
20
|
+
/** Read a JSON request body with a hard size cap. Rejects on overflow/parse error. */
|
|
21
|
+
function readJsonBody(req) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
let size = 0;
|
|
24
|
+
const chunks = [];
|
|
25
|
+
req.on('data', (c) => {
|
|
26
|
+
size += c.length;
|
|
27
|
+
if (size > MAX_BODY_BYTES) {
|
|
28
|
+
reject(new Error('request body too large'));
|
|
29
|
+
req.destroy();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
chunks.push(c);
|
|
33
|
+
});
|
|
34
|
+
req.on('end', () => {
|
|
35
|
+
const raw = Buffer.concat(chunks).toString('utf-8').trim();
|
|
36
|
+
if (!raw)
|
|
37
|
+
return resolve({});
|
|
38
|
+
try {
|
|
39
|
+
resolve(JSON.parse(raw));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
reject(new Error('invalid JSON body'));
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
req.on('error', reject);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function sendJson(res, code, obj) {
|
|
49
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' });
|
|
50
|
+
res.end(JSON.stringify(obj));
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* SSE stream of a session's normalized NDJSON events, offset-tailed from its
|
|
54
|
+
* capture file. Resumes from `?offset=<bytes>` or the `Last-Event-ID` header
|
|
55
|
+
* (each event's `id:` is its exact byte offset, so resume neither loses nor
|
|
56
|
+
* duplicates). Closes when a terminal (`result`/`error`) event is seen or the
|
|
57
|
+
* client disconnects. A not-yet-created file simply yields nothing until the
|
|
58
|
+
* run starts writing — the phone can open the stream the instant it dispatches.
|
|
59
|
+
*/
|
|
60
|
+
export function startSessionStream(file, req, res, pollMs) {
|
|
61
|
+
res.writeHead(200, {
|
|
62
|
+
'content-type': 'text/event-stream',
|
|
63
|
+
'cache-control': 'no-cache',
|
|
64
|
+
connection: 'keep-alive',
|
|
65
|
+
});
|
|
66
|
+
const params = new URL(req.url ?? '/', 'http://anchor').searchParams;
|
|
67
|
+
const qOffset = Number.parseInt(params.get('offset') ?? '', 10);
|
|
68
|
+
const lastId = Number.parseInt(Array.isArray(req.headers['last-event-id'])
|
|
69
|
+
? req.headers['last-event-id'][0]
|
|
70
|
+
: req.headers['last-event-id'] ?? '', 10);
|
|
71
|
+
// Clamp at the source: a negative/garbage offset (or Last-Event-ID) is
|
|
72
|
+
// meaningless and, worse, `Buffer.subarray(neg)` indexes from the END — so an
|
|
73
|
+
// unclamped negative offset would read nothing and never self-correct. A bad
|
|
74
|
+
// resume value replays from the start, the EventSource-friendly behavior.
|
|
75
|
+
const wanted = Number.isFinite(qOffset) ? qOffset : Number.isFinite(lastId) ? lastId : 0;
|
|
76
|
+
let offset = Math.max(0, wanted);
|
|
77
|
+
let closed = false;
|
|
78
|
+
let timer;
|
|
79
|
+
const stop = () => {
|
|
80
|
+
closed = true;
|
|
81
|
+
if (timer)
|
|
82
|
+
clearInterval(timer);
|
|
83
|
+
};
|
|
84
|
+
req.on('close', stop);
|
|
85
|
+
const tick = () => {
|
|
86
|
+
if (closed)
|
|
87
|
+
return;
|
|
88
|
+
const { events, newOffset, done } = readNewEvents(file, offset);
|
|
89
|
+
for (const { event, offset: at } of events) {
|
|
90
|
+
// `at` is the per-event resume point the CLIENT echoes as Last-Event-ID.
|
|
91
|
+
res.write(`id: ${at}\nevent: ${event.type}\ndata: ${JSON.stringify(event.raw)}\n\n`);
|
|
92
|
+
}
|
|
93
|
+
// The SERVER cursor always adopts newOffset — past every complete line,
|
|
94
|
+
// including trailing blank/non-JSON lines after the last event — so the
|
|
95
|
+
// stream always makes progress (even on an empty read) and never re-scans.
|
|
96
|
+
offset = newOffset;
|
|
97
|
+
if (done && !closed) {
|
|
98
|
+
res.write('event: end\ndata: {"ok":true}\n\n');
|
|
99
|
+
stop();
|
|
100
|
+
res.end();
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
tick(); // immediate catch-up from the resume offset
|
|
104
|
+
if (!closed)
|
|
105
|
+
timer = setInterval(tick, pollMs);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Spawn a detached child and settle once it has either successfully spawned or
|
|
109
|
+
* failed to. Attaching an `'error'` listener is mandatory: a `ChildProcess` is
|
|
110
|
+
* an `EventEmitter`, so an `'error'` event (e.g. ENOENT from a stale/missing
|
|
111
|
+
* `agents` binary — a real condition `validateDaemonBinary` guards against)
|
|
112
|
+
* with no listener throws and would take down the whole anchor process, not
|
|
113
|
+
* just the one request. Mirrors the `once('spawn') / once('error')` pattern in
|
|
114
|
+
* `teams/agents.ts`. The `'error'` listener registered by `once` survives after
|
|
115
|
+
* a successful spawn, so a later error is absorbed rather than crashing.
|
|
116
|
+
*/
|
|
117
|
+
export function spawnDetached(command, args, stdio = 'ignore') {
|
|
118
|
+
return new Promise((resolve, reject) => {
|
|
119
|
+
const child = spawn(command, args, { detached: true, stdio, env: process.env });
|
|
120
|
+
child.once('spawn', () => {
|
|
121
|
+
child.unref();
|
|
122
|
+
resolve();
|
|
123
|
+
});
|
|
124
|
+
child.once('error', (err) => reject(err instanceof Error ? err : new Error(String(err))));
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Default run dispatcher: re-invoke `agents run` with a server-minted id so the
|
|
129
|
+
* run is addressable, detached so it outlives this request (and the anchor
|
|
130
|
+
* process). Local runs detach here; `--host` runs detach on the executor via
|
|
131
|
+
* the existing dispatch path.
|
|
132
|
+
*/
|
|
133
|
+
export const defaultRunner = async (req) => {
|
|
134
|
+
if (!req.agent?.trim())
|
|
135
|
+
throw new Error('agent is required');
|
|
136
|
+
if (!req.prompt?.trim())
|
|
137
|
+
throw new Error('prompt is required');
|
|
138
|
+
const name = `ios-${randomBytes(3).toString('hex')}`;
|
|
139
|
+
// claude accepts an explicit session UUID; other agents resolve by --name.
|
|
140
|
+
const sessionId = req.agent === 'claude' ? randomUUID() : name;
|
|
141
|
+
const argv = ['run', req.agent, req.prompt, '--json', '--headless', '--quiet', '--name', name];
|
|
142
|
+
if (req.agent === 'claude')
|
|
143
|
+
argv.push('--session-id', sessionId);
|
|
144
|
+
if (req.mode)
|
|
145
|
+
argv.push('--mode', req.mode);
|
|
146
|
+
if (req.host)
|
|
147
|
+
argv.push('--host', req.host);
|
|
148
|
+
if (req.cwd)
|
|
149
|
+
argv.push('--cwd', req.cwd);
|
|
150
|
+
const inv = getAgentsInvocation(argv);
|
|
151
|
+
// Await spawn/error so a failed launch becomes a clean 400, never an
|
|
152
|
+
// unhandled 'error' that crashes the anchor for every other session.
|
|
153
|
+
//
|
|
154
|
+
// For an anchor-local run, capture the `--json` NDJSON to a per-session file
|
|
155
|
+
// so `GET /api/session/:id/stream` can offset-tail it. A `--host` run emits
|
|
156
|
+
// its NDJSON on the remote box (streaming that reuses pullRemoteLogDelta — a
|
|
157
|
+
// follow-up), so we don't capture the local dispatcher's output.
|
|
158
|
+
if (req.host) {
|
|
159
|
+
await spawnDetached(inv.command, inv.args);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
fs.mkdirSync(streamDir(), { recursive: true });
|
|
163
|
+
const fd = fs.openSync(streamLogPath(sessionId), 'a');
|
|
164
|
+
try {
|
|
165
|
+
await spawnDetached(inv.command, inv.args, ['ignore', fd, fd]);
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
// The child inherited the fd; the parent's copy is no longer needed.
|
|
169
|
+
fs.closeSync(fd);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return { sessionId, name };
|
|
173
|
+
};
|
|
174
|
+
/** Default messenger: re-invoke `agents message <id> <text>` and await its exit. */
|
|
175
|
+
export const defaultMessenger = async (id, text, from) => {
|
|
176
|
+
const argv = ['message', id, text];
|
|
177
|
+
if (from)
|
|
178
|
+
argv.push('--from', from);
|
|
179
|
+
const inv = getAgentsInvocation(argv);
|
|
180
|
+
const code = await new Promise((resolve) => {
|
|
181
|
+
const child = spawn(inv.command, inv.args, { stdio: 'ignore', env: process.env });
|
|
182
|
+
child.on('exit', (c) => resolve(c ?? 1));
|
|
183
|
+
child.on('error', () => resolve(1));
|
|
184
|
+
});
|
|
185
|
+
if (code !== 0)
|
|
186
|
+
throw new Error(`agents message exited with code ${code}`);
|
|
187
|
+
};
|
|
188
|
+
/**
|
|
189
|
+
* Create (but do not start) the authenticated control server. Caller invokes
|
|
190
|
+
* `.listen(port, bind)`.
|
|
191
|
+
*/
|
|
192
|
+
export function createControlServer(opts = {}) {
|
|
193
|
+
const ctx = resolveServeContext(opts);
|
|
194
|
+
const verify = opts.verifyToken ?? verifyControlToken;
|
|
195
|
+
const runner = opts.runner ?? defaultRunner;
|
|
196
|
+
const messenger = opts.messenger ?? defaultMessenger;
|
|
197
|
+
const streamPollMs = opts.streamPollMs ?? DEFAULT_STREAM_POLL_MS;
|
|
198
|
+
const streamLogPathFor = opts.streamLogPathFor ?? streamLogPath;
|
|
199
|
+
return createServer(async (req, res) => {
|
|
200
|
+
// Auth gates EVERY request — this server may be reachable off-box.
|
|
201
|
+
if (!verify(presentedToken(req))) {
|
|
202
|
+
res.writeHead(401, { 'content-type': 'application/json', 'www-authenticate': 'Bearer' });
|
|
203
|
+
res.end(JSON.stringify({ error: 'unauthorized' }));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const method = req.method ?? 'GET';
|
|
207
|
+
const url = (req.url || '/').split('?')[0];
|
|
208
|
+
if (method === 'GET') {
|
|
209
|
+
const streamMatch = /^\/api\/session\/([^/]+)\/stream$/.exec(url);
|
|
210
|
+
if (streamMatch) {
|
|
211
|
+
const id = decodeURIComponent(streamMatch[1]);
|
|
212
|
+
startSessionStream(streamLogPathFor(id), req, res, streamPollMs);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const handled = await handleServeGet(url, req, res, ctx);
|
|
216
|
+
if (!handled)
|
|
217
|
+
sendJson(res, 404, { error: 'not found' });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (method === 'POST' && url === '/api/run') {
|
|
221
|
+
try {
|
|
222
|
+
const body = (await readJsonBody(req));
|
|
223
|
+
const result = await runner(body);
|
|
224
|
+
sendJson(res, 200, result);
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
sendJson(res, 400, { error: String(err?.message ?? err) });
|
|
228
|
+
}
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const msgMatch = method === 'POST' && /^\/api\/session\/([^/]+)\/message$/.exec(url);
|
|
232
|
+
if (msgMatch) {
|
|
233
|
+
const id = decodeURIComponent(msgMatch[1]);
|
|
234
|
+
try {
|
|
235
|
+
const body = (await readJsonBody(req));
|
|
236
|
+
if (!body.text?.trim())
|
|
237
|
+
throw new Error('text is required');
|
|
238
|
+
await messenger(id, body.text, body.from);
|
|
239
|
+
sendJson(res, 200, { ok: true, id });
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
sendJson(res, 400, { error: String(err?.message ?? err) });
|
|
243
|
+
}
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
sendJson(res, 404, { error: 'not found' });
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
/** Start the control server on `port`, bound to `bind`. Resolves the bound port. */
|
|
250
|
+
export function startControlServer(port, bind, opts = {}) {
|
|
251
|
+
const server = createControlServer(opts);
|
|
252
|
+
return new Promise((resolve, reject) => {
|
|
253
|
+
server.once('error', reject);
|
|
254
|
+
server.listen(port, bind, () => {
|
|
255
|
+
const addr = server.address();
|
|
256
|
+
const boundPort = typeof addr === 'object' && addr ? addr.port : port;
|
|
257
|
+
resolve({ server, port: boundPort });
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
}
|
|
@@ -9,11 +9,26 @@
|
|
|
9
9
|
*
|
|
10
10
|
* The whole thing is a viewer over data other commands already own
|
|
11
11
|
* ({@link assembleState}); it writes nothing.
|
|
12
|
+
*
|
|
13
|
+
* The GET routing is exported as {@link handleServeGet} so the authenticated
|
|
14
|
+
* `--control` variant (see control.ts) can reuse it verbatim rather than
|
|
15
|
+
* duplicate the state/SSE surface.
|
|
12
16
|
*/
|
|
13
|
-
import http from 'http';
|
|
17
|
+
import type http from 'http';
|
|
14
18
|
import type { ServeState } from './data.js';
|
|
15
19
|
/** Loopback address — the server binds here and nowhere else. */
|
|
16
20
|
export declare const SERVE_HOST = "127.0.0.1";
|
|
21
|
+
/**
|
|
22
|
+
* DNS-rebinding guard. The socket binds loopback, but binding alone does not
|
|
23
|
+
* stop a remote page: an attacker can point a hostname's DNS at 127.0.0.1 and
|
|
24
|
+
* drive the victim's browser to `GET /api/state`, which exposes uncommitted
|
|
25
|
+
* `git diff HEAD` of every worktree plus routine + cloud config. A browser
|
|
26
|
+
* always sends a `Host` header naming the site it *thinks* it reached, so we
|
|
27
|
+
* trust only a Host that is itself loopback. A missing Host (HTTP/1.0 or a raw
|
|
28
|
+
* non-browser client like curl-without-Host) is not a rebind vector and is
|
|
29
|
+
* allowed; any explicit non-loopback Host is rejected.
|
|
30
|
+
*/
|
|
31
|
+
export declare function isAllowedServeHost(hostHeader: string | undefined): boolean;
|
|
17
32
|
/** Default port for `agents serve`. */
|
|
18
33
|
export declare const DEFAULT_SERVE_PORT = 4477;
|
|
19
34
|
/** Default SSE push cadence. */
|
|
@@ -31,6 +46,25 @@ export interface ServeOptions {
|
|
|
31
46
|
*/
|
|
32
47
|
snapshot?: () => Promise<ServeState>;
|
|
33
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Resolved GET-route context: the snapshot source and SSE cadence, with all
|
|
51
|
+
* defaults applied. Built once by {@link resolveServeContext}.
|
|
52
|
+
*/
|
|
53
|
+
export interface ServeContext {
|
|
54
|
+
snapshot: () => Promise<ServeState>;
|
|
55
|
+
intervalMs: number;
|
|
56
|
+
}
|
|
57
|
+
/** Apply defaults to {@link ServeOptions}, producing a {@link ServeContext}. */
|
|
58
|
+
export declare function resolveServeContext(opts?: ServeOptions): ServeContext;
|
|
59
|
+
/**
|
|
60
|
+
* Handle the shared read-only GET surface (`/`, `/api/state`, `/events`).
|
|
61
|
+
* Returns `true` when the route was recognized and a response was written (or
|
|
62
|
+
* begun, for SSE); `false` when the path is unknown so the caller can 404 or
|
|
63
|
+
* try its own routes first. Extracted so the `--control` server reuses the
|
|
64
|
+
* exact same state/SSE behavior — including the load-bearing SSE close/timer
|
|
65
|
+
* ordering below — without duplicating it.
|
|
66
|
+
*/
|
|
67
|
+
export declare function handleServeGet(url: string, req: http.IncomingMessage, res: http.ServerResponse, ctx: ServeContext): Promise<boolean>;
|
|
34
68
|
/**
|
|
35
69
|
* Create (but do not start) the read-only serve server. Caller invokes
|
|
36
70
|
* `.listen(port, SERVE_HOST)`. Returned so tests can bind an ephemeral port.
|