@peakflo/agent-harness 0.0.0-stage → 0.2.2-rc.1
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/LICENSE +21 -0
- package/README.md +68 -2
- package/dist/adapters/acp-adapter.d.ts +177 -0
- package/dist/adapters/acp-adapter.js +1732 -0
- package/dist/adapters/claude-code-adapter.d.ts +171 -0
- package/dist/adapters/claude-code-adapter.js +1918 -0
- package/dist/adapters/codex-adapter.d.ts +67 -0
- package/dist/adapters/codex-adapter.js +681 -0
- package/dist/adapters/codex-app-server-adapter.d.ts +105 -0
- package/dist/adapters/codex-app-server-adapter.js +1309 -0
- package/dist/adapters/coding-agent-adapter.d.ts +289 -0
- package/dist/adapters/coding-agent-adapter.js +31 -0
- package/dist/adapters/index.d.ts +17 -0
- package/dist/adapters/index.js +35 -0
- package/dist/adapters/model-gateway.d.ts +84 -0
- package/dist/adapters/model-gateway.js +270 -0
- package/dist/adapters/opencode-adapter.d.ts +403 -0
- package/dist/adapters/opencode-adapter.js +2710 -0
- package/dist/adapters/opencode-config.d.ts +41 -0
- package/dist/adapters/opencode-config.js +147 -0
- package/dist/adapters/opencode-server-policy.d.ts +82 -0
- package/dist/adapters/opencode-server-policy.js +148 -0
- package/dist/adapters/pi-adapter.d.ts +168 -0
- package/dist/adapters/pi-adapter.js +1468 -0
- package/dist/capabilities.d.ts +58 -0
- package/dist/capabilities.js +161 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +231 -0
- package/dist/contract.d.ts +273 -0
- package/dist/contract.js +357 -0
- package/dist/control-plane.d.ts +178 -0
- package/dist/control-plane.js +431 -0
- package/dist/credential-store.d.ts +110 -0
- package/dist/credential-store.js +181 -0
- package/dist/enrol/browser-enrol.d.ts +85 -0
- package/dist/enrol/browser-enrol.js +387 -0
- package/dist/github-command-guards.d.ts +20 -0
- package/dist/github-command-guards.js +330 -0
- package/dist/github-proxy-certificates.d.ts +25 -0
- package/dist/github-proxy-certificates.js +145 -0
- package/dist/github-proxy.d.ts +37 -0
- package/dist/github-proxy.js +476 -0
- package/dist/github-workspace.d.ts +55 -0
- package/dist/github-workspace.js +537 -0
- package/dist/harness.d.ts +333 -0
- package/dist/harness.js +1414 -0
- package/dist/health.d.ts +45 -0
- package/dist/health.js +116 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +123 -0
- package/dist/model-probe.d.ts +96 -0
- package/dist/model-probe.js +195 -0
- package/dist/options.d.ts +15 -0
- package/dist/options.js +212 -0
- package/dist/ports-default.d.ts +212 -0
- package/dist/ports-default.js +333 -0
- package/dist/ports.d.ts +436 -0
- package/dist/ports.js +27 -0
- package/dist/runtime/attachments.d.ts +92 -0
- package/dist/runtime/attachments.js +377 -0
- package/dist/runtime/child-stream-guards.d.ts +45 -0
- package/dist/runtime/child-stream-guards.js +111 -0
- package/dist/runtime/conversation.d.ts +87 -0
- package/dist/runtime/conversation.js +156 -0
- package/dist/runtime/lease-renewal.d.ts +169 -0
- package/dist/runtime/lease-renewal.js +433 -0
- package/dist/runtime/mcp-auth-proxy.d.ts +82 -0
- package/dist/runtime/mcp-auth-proxy.js +300 -0
- package/dist/runtime/permission-mode.d.ts +116 -0
- package/dist/runtime/permission-mode.js +146 -0
- package/dist/runtime/projection.d.ts +71 -0
- package/dist/runtime/projection.js +211 -0
- package/dist/runtime/session-runner.d.ts +520 -0
- package/dist/runtime/session-runner.js +2122 -0
- package/dist/shared/reasoning-effort.d.ts +11 -0
- package/dist/shared/reasoning-effort.js +20 -0
- package/dist/store/conversation-store.d.ts +100 -0
- package/dist/store/conversation-store.js +305 -0
- package/dist/store/sqlite-store.d.ts +120 -0
- package/dist/store/sqlite-store.js +346 -0
- package/dist/types.d.ts +952 -0
- package/dist/types.js +55 -0
- package/dist/version.d.ts +32 -0
- package/dist/version.js +77 -0
- package/package.json +53 -4
package/dist/options.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OptionsError = void 0;
|
|
4
|
+
exports.parseOptions = parseOptions;
|
|
5
|
+
const node_os_1 = require("node:os");
|
|
6
|
+
const node_path_1 = require("node:path");
|
|
7
|
+
const DEFAULT_HEALTH_PORT = 7777;
|
|
8
|
+
const DEFAULT_GITHUB_PROXY_PORT = 7780;
|
|
9
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
|
|
10
|
+
class OptionsError extends Error {
|
|
11
|
+
}
|
|
12
|
+
exports.OptionsError = OptionsError;
|
|
13
|
+
/** Keep in step with `LOOPBACK_HOSTS` in `agentSandbox/bin/pf-bootstrap`. */
|
|
14
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|
15
|
+
function readFlag(argv, name) {
|
|
16
|
+
const withEquals = argv.find((arg) => arg.startsWith(`--${name}=`));
|
|
17
|
+
if (withEquals) {
|
|
18
|
+
return withEquals.slice(name.length + 3);
|
|
19
|
+
}
|
|
20
|
+
const index = argv.indexOf(`--${name}`);
|
|
21
|
+
if (index !== -1 && argv[index + 1] && !argv[index + 1].startsWith('--')) {
|
|
22
|
+
return argv[index + 1];
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
function readBoolFlag(argv, name) {
|
|
27
|
+
return argv.includes(`--${name}`);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A bad interval must fail fast, not quietly.
|
|
31
|
+
*
|
|
32
|
+
* `Number.parseInt('abc')` is NaN, and `setTimeout(fn, NaN)` is clamped to
|
|
33
|
+
* about 1 ms — so a typo in the environment turned the heartbeat into a tight
|
|
34
|
+
* loop hammering /harness/heartbeat rather than an obvious start-up error.
|
|
35
|
+
* The floor also stops a well-meaning `--heartbeat-interval-ms 10`.
|
|
36
|
+
*/
|
|
37
|
+
const MIN_HEARTBEAT_INTERVAL_MS = 1_000;
|
|
38
|
+
const MAX_HEARTBEAT_INTERVAL_MS = 3_600_000;
|
|
39
|
+
function parseInterval(raw, fallback) {
|
|
40
|
+
if (raw === undefined || raw === '') {
|
|
41
|
+
return fallback;
|
|
42
|
+
}
|
|
43
|
+
const value = Number.parseInt(raw, 10);
|
|
44
|
+
if (!Number.isInteger(value) ||
|
|
45
|
+
value < MIN_HEARTBEAT_INTERVAL_MS ||
|
|
46
|
+
value > MAX_HEARTBEAT_INTERVAL_MS) {
|
|
47
|
+
throw new OptionsError(`Invalid heartbeat interval: '${raw}'. Expected an integer between ` +
|
|
48
|
+
`${MIN_HEARTBEAT_INTERVAL_MS} and ${MAX_HEARTBEAT_INTERVAL_MS} ms.`);
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
function parsePort(raw, fallback) {
|
|
53
|
+
if (!raw) {
|
|
54
|
+
return fallback;
|
|
55
|
+
}
|
|
56
|
+
const value = Number.parseInt(raw, 10);
|
|
57
|
+
if (!Number.isInteger(value) || value < 1 || value > 65535) {
|
|
58
|
+
throw new OptionsError(`Invalid port: ${raw}`);
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* WHERE SESSION WORKSPACES LIVE — ONE ANSWER, NOT ONE PER RUNTIME.
|
|
64
|
+
*
|
|
65
|
+
* Under the user's own home, always. This is the shape 20x has always had:
|
|
66
|
+
* `join(app.getPath('userData'), 'workspaces', taskId)` in `database.ts` —
|
|
67
|
+
* a directory the process owns by definition, with no per-runtime branch,
|
|
68
|
+
* because a path the program GUESSES is a path it can guess wrong.
|
|
69
|
+
*
|
|
70
|
+
* IT USED TO BRANCH, and returned `/var/pf/workspaces` for `runtime=sandbox`.
|
|
71
|
+
* That is the managed image's path, created there as pfharness:0700 at build
|
|
72
|
+
* time — and it is not writable anywhere else. Running a sandbox-runtime
|
|
73
|
+
* harness on a laptop therefore died with
|
|
74
|
+
* `EACCES: permission denied, mkdir '/var/pf'` AFTER registering and burning
|
|
75
|
+
* its single-use enrolment token, so retrying meant enrolling again.
|
|
76
|
+
*
|
|
77
|
+
* THE SANDBOX DOES NOT NEED THE GUESS. It is TOLD: the control plane passes
|
|
78
|
+
* `PF_WORKSPACE_ROOT` in the Daytona sandbox's `envVars` at creation, and the
|
|
79
|
+
* image sets the same variable as a backstop. An explicit `--workspace-root`
|
|
80
|
+
* or `PF_WORKSPACE_ROOT` still wins over this default, which is exactly how
|
|
81
|
+
* that value arrives. So the sandbox path is now a PARAMETER the control plane
|
|
82
|
+
* owns rather than a constant duplicated in the harness and the Dockerfile with
|
|
83
|
+
* nothing keeping the two in step.
|
|
84
|
+
*/
|
|
85
|
+
function defaultWorkspaceRoot() {
|
|
86
|
+
return (0, node_path_1.join)((0, node_os_1.homedir)(), '.pf-agent-harness', 'workspaces');
|
|
87
|
+
}
|
|
88
|
+
const RUNTIMES = new Set(['sandbox', 'self-hosted', 'desktop']);
|
|
89
|
+
/**
|
|
90
|
+
* Defaults to 'desktop'.
|
|
91
|
+
*
|
|
92
|
+
* Somebody who runs `pf-agent-harness start` by hand is, essentially always,
|
|
93
|
+
* sitting at the machine — that is what the browser sign-in assumes too. The
|
|
94
|
+
* previous default of `self-hosted` was chosen to fail towards caution, but it
|
|
95
|
+
* failed towards CONFUSION instead: a laptop runner announced itself as
|
|
96
|
+
* self-hosted, the gateway became the only permitted model route, and a turn
|
|
97
|
+
* naming one of the machine's own opencode providers was refused with a
|
|
98
|
+
* message about credential borrowing on a host we do not own — the host being
|
|
99
|
+
* the user's own laptop.
|
|
100
|
+
*
|
|
101
|
+
* A missing value must still never be read as "managed sandbox": that is the
|
|
102
|
+
* trusted mode, provisioned by us, and defaulting into it would be a
|
|
103
|
+
* permission granted by an absent environment variable. pf-bootstrap always
|
|
104
|
+
* sets PF_RUNNER_RUNTIME=sandbox explicitly, so nothing relies on the default.
|
|
105
|
+
*/
|
|
106
|
+
function parseRuntime(raw) {
|
|
107
|
+
if (!raw) {
|
|
108
|
+
return 'desktop';
|
|
109
|
+
}
|
|
110
|
+
if (!RUNTIMES.has(raw)) {
|
|
111
|
+
throw new OptionsError(`Invalid runtime: '${raw}'. Expected sandbox, self-hosted or desktop.`);
|
|
112
|
+
}
|
|
113
|
+
return raw;
|
|
114
|
+
}
|
|
115
|
+
const LOCAL_RUNNER_DEFAULT_CONCURRENCY = 10;
|
|
116
|
+
/**
|
|
117
|
+
* Desktop and self-hosted runners should use the machine as a pool rather than
|
|
118
|
+
* serialising every session. The conservative end of the supported 10–20 range
|
|
119
|
+
* is the default; operators can still size it explicitly for their host.
|
|
120
|
+
*
|
|
121
|
+
* Managed sandboxes receive their measured limit explicitly from the control
|
|
122
|
+
* plane, so one remains the safe fallback if that configuration is absent.
|
|
123
|
+
*/
|
|
124
|
+
function parseConcurrency(raw, runtime) {
|
|
125
|
+
if (!raw) {
|
|
126
|
+
return runtime === 'sandbox' ? 1 : LOCAL_RUNNER_DEFAULT_CONCURRENCY;
|
|
127
|
+
}
|
|
128
|
+
const value = Number.parseInt(raw, 10);
|
|
129
|
+
if (!Number.isInteger(value) || value < 1 || value > 64) {
|
|
130
|
+
throw new OptionsError(`Invalid max concurrent sessions: '${raw}'. Expected an integer 1-64.`);
|
|
131
|
+
}
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
function parseLabels(raw) {
|
|
135
|
+
if (!raw) {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
const labels = raw
|
|
139
|
+
.split(',')
|
|
140
|
+
.map((label) => label.trim())
|
|
141
|
+
.filter(Boolean);
|
|
142
|
+
return labels.length > 0 ? labels : undefined;
|
|
143
|
+
}
|
|
144
|
+
function parseBoolean(raw, fallback, name) {
|
|
145
|
+
if (raw === undefined || raw === '')
|
|
146
|
+
return fallback;
|
|
147
|
+
if (raw === 'true')
|
|
148
|
+
return true;
|
|
149
|
+
if (raw === 'false')
|
|
150
|
+
return false;
|
|
151
|
+
throw new OptionsError(`Invalid ${name}: '${raw}'. Expected true or false.`);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Flags win over environment variables, so a local developer can override a
|
|
155
|
+
* value that pf-bootstrap injected.
|
|
156
|
+
*
|
|
157
|
+
* NOTE ON THE ENROLMENT TOKEN: it is accepted on the command line for local
|
|
158
|
+
* development and self-hosted installs, which is what the design calls for.
|
|
159
|
+
* In a managed sandbox pf-bootstrap passes it through PF_ENROLMENT_TOKEN
|
|
160
|
+
* instead, so it never appears in the process arguments — /proc/<pid>/cmdline
|
|
161
|
+
* is world-readable, while /proc/<pid>/environ is readable only by the
|
|
162
|
+
* process owner.
|
|
163
|
+
*/
|
|
164
|
+
function parseOptions(argv, env) {
|
|
165
|
+
const controlPlaneUrl = readFlag(argv, 'control-plane') ?? env.PF_CONTROL_PLANE_URL;
|
|
166
|
+
if (!controlPlaneUrl) {
|
|
167
|
+
throw new OptionsError('Missing control plane url. Pass --control-plane <url> or set PF_CONTROL_PLANE_URL.');
|
|
168
|
+
}
|
|
169
|
+
const enrolmentToken = readFlag(argv, 'token') ?? env.PF_ENROLMENT_TOKEN;
|
|
170
|
+
if (!enrolmentToken) {
|
|
171
|
+
throw new OptionsError('Missing enrolment token. Pass --token <token> or set PF_ENROLMENT_TOKEN.');
|
|
172
|
+
}
|
|
173
|
+
let parsed;
|
|
174
|
+
try {
|
|
175
|
+
parsed = new URL(controlPlaneUrl);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
throw new OptionsError(`Invalid control plane url: ${controlPlaneUrl}`);
|
|
179
|
+
}
|
|
180
|
+
// Transport rule, and it MUST match `agentSandbox/bin/pf-bootstrap`.
|
|
181
|
+
//
|
|
182
|
+
// https everywhere, except LOOPBACK, which is allowed for local
|
|
183
|
+
// development — the form this package's README documents.
|
|
184
|
+
// PF_ALLOW_INSECURE_CONTROL_PLANE exists ONLY for the containerised exit
|
|
185
|
+
// test, where the stub is reached by a CONTAINER HOSTNAME rather than by
|
|
186
|
+
// loopback. It must never be set on a managed sandbox.
|
|
187
|
+
const isLoopback = LOOPBACK_HOSTS.has(parsed.hostname);
|
|
188
|
+
const insecureAllowed = env.PF_ALLOW_INSECURE_CONTROL_PLANE === 'true';
|
|
189
|
+
if (parsed.protocol !== 'https:' && !isLoopback && !insecureAllowed) {
|
|
190
|
+
throw new OptionsError(`Control plane url must use https (loopback is allowed for development): ${controlPlaneUrl}`);
|
|
191
|
+
}
|
|
192
|
+
// Resolved before the object literal, because the workspace-root DEFAULT
|
|
193
|
+
// depends on it.
|
|
194
|
+
const runtime = parseRuntime(readFlag(argv, 'runtime') ?? env.PF_RUNNER_RUNTIME);
|
|
195
|
+
return {
|
|
196
|
+
controlPlaneUrl,
|
|
197
|
+
enrolmentToken,
|
|
198
|
+
healthPort: parsePort(readFlag(argv, 'health-port') ?? env.PF_HARNESS_HEALTH_PORT, DEFAULT_HEALTH_PORT),
|
|
199
|
+
githubProxyPort: parsePort(readFlag(argv, 'github-proxy-port') ?? env.PF_GITHUB_PROXY_PORT, DEFAULT_GITHUB_PROXY_PORT),
|
|
200
|
+
autoCommitDirtyWorktree: parseBoolean(readFlag(argv, 'worktree-wip-commit') ?? env.PF_WORKTREE_WIP_COMMIT, runtime === 'sandbox', 'worktree WIP commit flag'),
|
|
201
|
+
heartbeatIntervalMs: parseInterval(readFlag(argv, 'heartbeat-interval-ms') ??
|
|
202
|
+
env.PF_HARNESS_HEARTBEAT_INTERVAL_MS, DEFAULT_HEARTBEAT_INTERVAL_MS),
|
|
203
|
+
degraded: readBoolFlag(argv, 'degraded') || env.PF_HARNESS_DEGRADED === 'true',
|
|
204
|
+
degradedReason: readFlag(argv, 'degraded-reason') ?? env.PF_HARNESS_DEGRADED_REASON,
|
|
205
|
+
workspaceRoot: readFlag(argv, 'workspace-root') ??
|
|
206
|
+
env.PF_WORKSPACE_ROOT ??
|
|
207
|
+
defaultWorkspaceRoot(),
|
|
208
|
+
runtime,
|
|
209
|
+
maxConcurrentSessions: parseConcurrency(readFlag(argv, 'max-sessions') ?? env.PF_MAX_CONCURRENT_SESSIONS, runtime),
|
|
210
|
+
labels: parseLabels(readFlag(argv, 'labels') ?? env.PF_RUNNER_LABELS)
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SERVER-SIDE PORT IMPLEMENTATIONS — what a headless runner uses when the
|
|
3
|
+
* desktop is not there to supply anything.
|
|
4
|
+
*
|
|
5
|
+
* Every one of these is a REAL implementation of a capability a server genuinely
|
|
6
|
+
* has, or an HONEST NO-OP for one it genuinely lacks. None of them pretends: a
|
|
7
|
+
* server has no user to notify and never sleeps, so `ServerNotifier` does
|
|
8
|
+
* nothing and says so. That is why `Notifier` stayed a port instead of being
|
|
9
|
+
* deleted — the desktop's behaviour is preserved without the runtime having to
|
|
10
|
+
* ask which host it is running on.
|
|
11
|
+
*/
|
|
12
|
+
import { ArtifactInspector, EventSink, InspectedArtifact, Notifier, SecretProvider, TaskMcpProvider, Telemetry, TokenInjector, WorkspaceProvider, WorkspaceRequest } from './ports';
|
|
13
|
+
import { JsonValue } from './types';
|
|
14
|
+
/**
|
|
15
|
+
* A no-op, per the brief: product analytics on a server is the CONTROL PLANE's
|
|
16
|
+
* business, and it already sees every register, heartbeat, lease and ingest.
|
|
17
|
+
* Shipping a second telemetry pipe out of a customer VM would be a data-flow the
|
|
18
|
+
* customer never agreed to.
|
|
19
|
+
*/
|
|
20
|
+
export declare class NoopTelemetry implements Telemetry {
|
|
21
|
+
record(): void;
|
|
22
|
+
}
|
|
23
|
+
/** No human at this machine, and nothing that sleeps. */
|
|
24
|
+
export declare class ServerNotifier implements Notifier {
|
|
25
|
+
notify(): void;
|
|
26
|
+
keepAwake(): () => void;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* No local secret store.
|
|
30
|
+
*
|
|
31
|
+
* THIS IS A SECURITY POSITION, NOT A GAP. A customer VM is outside our security
|
|
32
|
+
* perimeter, so the harness deliberately holds no vendor credential to hand an
|
|
33
|
+
* agent: everything the agent can reach, it reaches through the MCP gateway,
|
|
34
|
+
* which injects the real credentials SERVER-SIDE. A runner with a secret store
|
|
35
|
+
* would recreate exactly the exposure the gateway exists to remove.
|
|
36
|
+
*
|
|
37
|
+
* `getBrokerPort()` returning null makes the runtime skip secret wiring
|
|
38
|
+
* entirely, which is why this is a working implementation and not a stub.
|
|
39
|
+
*/
|
|
40
|
+
export declare class NoSecretProvider implements SecretProvider {
|
|
41
|
+
getBrokerPort(): number | null;
|
|
42
|
+
registerSession(): void;
|
|
43
|
+
unregisterSession(): void;
|
|
44
|
+
writeShellWrapper(): string | null;
|
|
45
|
+
getSecretEnv(): Record<string, string>;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Describes nothing.
|
|
49
|
+
*
|
|
50
|
+
* Artifact inspection on the desktop reads the file to build a preview. On a
|
|
51
|
+
* runner the file is on the runner, and the reader is a browser somewhere else,
|
|
52
|
+
* so the useful implementation is an upload — which belongs to the control
|
|
53
|
+
* plane's ingest path, not here.
|
|
54
|
+
*/
|
|
55
|
+
export declare class NoArtifactInspector implements ArtifactInspector {
|
|
56
|
+
inspect(): Promise<InspectedArtifact | null>;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The task-management MCP server, when the control plane supplies one.
|
|
60
|
+
*
|
|
61
|
+
* ON THE DESKTOP this port is backed by an IN-PROCESS server. On a runner there
|
|
62
|
+
* is no such server and there must not be one — a task MCP endpoint on the
|
|
63
|
+
* runner would be a second, unauthenticated door into the tenant's data. The
|
|
64
|
+
* runner is handed a REMOTE gateway URL in the lease instead, already scoped and
|
|
65
|
+
* already authenticated, and this class simply carries it.
|
|
66
|
+
*
|
|
67
|
+
* Constructed with null when the lease carried no task MCP, which is the
|
|
68
|
+
* ordinary case for a free-standing session (decision 6).
|
|
69
|
+
*/
|
|
70
|
+
export declare class StaticTaskMcpProvider implements TaskMcpProvider {
|
|
71
|
+
private readonly url;
|
|
72
|
+
constructor(url: string | null);
|
|
73
|
+
getUrl(): Promise<string | null>;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Hands out the session-scoped MCP token from the lease, KEYED BY TARGET URL.
|
|
77
|
+
*
|
|
78
|
+
* The token is held in memory and never written anywhere. It is short-lived,
|
|
79
|
+
* bound to this runner AND this session, and carries no tool list — so the
|
|
80
|
+
* gateway re-reads the allowlist on every call and a revocation takes effect
|
|
81
|
+
* immediately rather than at the next session.
|
|
82
|
+
*
|
|
83
|
+
* WHY THE MAP, AND NOT A SINGLE FIELD — this is a concurrency correctness bug,
|
|
84
|
+
* not a refinement. The loopback proxy holds ONE reference to this injector for
|
|
85
|
+
* the whole process life, and `runLeasedTurn` runs several turns at once when
|
|
86
|
+
* `maxConcurrentSessions > 1`. A single shared `token` field therefore had the
|
|
87
|
+
* exact failure the per-turn model gateway holder was already fixed to avoid:
|
|
88
|
+
*
|
|
89
|
+
* - turn B's `setToken(tokenB)` OVERWROTE turn A's token, so A's forwarded
|
|
90
|
+
* MCP calls carried B's session token. The gateway's session-id gate
|
|
91
|
+
* (`mcpSessionAuthMiddleware`, gate 3) then answered 401 for A — a valid
|
|
92
|
+
* token for the wrong session — and OpenCode mis-reported that 401 as an
|
|
93
|
+
* OAuth challenge, so `create_artifact` failed mid-session with two agents
|
|
94
|
+
* running;
|
|
95
|
+
* - whichever turn finished FIRST called `revoke()` and blanked the token for
|
|
96
|
+
* every still-running turn, 401ing the survivor for the rest of its run.
|
|
97
|
+
*
|
|
98
|
+
* Each target url embeds its own session id (`/api/mcp/agent/:sessionId/...`),
|
|
99
|
+
* so keying the token by target url gives every concurrent session an
|
|
100
|
+
* independent slot. `getToken(targetUrl)` receives that url from the proxy on
|
|
101
|
+
* every request, so the per-request contract the proxy already relies on is
|
|
102
|
+
* unchanged — only the storage is now per-session instead of process-global.
|
|
103
|
+
*
|
|
104
|
+
* The constructor argument is a FALLBACK, used only for a target that was never
|
|
105
|
+
* explicitly registered (the desktop single-token case). It is deliberately NOT
|
|
106
|
+
* mutated by the per-session methods, so a fallback can never leak a session
|
|
107
|
+
* token to an unrelated target.
|
|
108
|
+
*/
|
|
109
|
+
export declare class SessionTokenInjector implements TokenInjector {
|
|
110
|
+
/** targetUrl -> the session token to attach for that url. */
|
|
111
|
+
private readonly byTarget;
|
|
112
|
+
private fallback;
|
|
113
|
+
constructor(token: string | null);
|
|
114
|
+
getToken(targetUrl: string): Promise<string | null>;
|
|
115
|
+
/**
|
|
116
|
+
* Install one session's token for exactly the target urls that session will
|
|
117
|
+
* reach, for the turn that is about to run.
|
|
118
|
+
*
|
|
119
|
+
* The injector is re-pointed per turn rather than recreated, because the
|
|
120
|
+
* loopback proxy holds a reference to it for the whole process life. Two
|
|
121
|
+
* concurrent turns register DISJOINT url sets (their session ids differ), so
|
|
122
|
+
* neither can overwrite the other's slot.
|
|
123
|
+
*/
|
|
124
|
+
registerTargets(targetUrls: readonly string[], token: string | null): void;
|
|
125
|
+
/**
|
|
126
|
+
* Revoke exactly one session's tokens when its turn ends.
|
|
127
|
+
*
|
|
128
|
+
* Scoped to the urls that turn owned, so a sibling turn still in flight keeps
|
|
129
|
+
* its own credential — the process-global `revoke()` blanked everyone's.
|
|
130
|
+
*/
|
|
131
|
+
revokeTargets(targetUrls: readonly string[]): void;
|
|
132
|
+
/**
|
|
133
|
+
* Set the process-wide FALLBACK token. Retained for the desktop single-token
|
|
134
|
+
* path and for tests; the concurrent runner path uses `registerTargets`.
|
|
135
|
+
*/
|
|
136
|
+
setToken(token: string | null): void;
|
|
137
|
+
/** Clear the fallback AND every per-target token. Used on full teardown. */
|
|
138
|
+
revoke(): void;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Drops everything.
|
|
142
|
+
*
|
|
143
|
+
* The runner's real event path is `POST /ingest`, driven by the session runner
|
|
144
|
+
* on the transcript it has already stored. This exists so a caller that supplies
|
|
145
|
+
* no sink does not have to null-check on the hot path of the agent loop.
|
|
146
|
+
*/
|
|
147
|
+
export declare class NullEventSink implements EventSink {
|
|
148
|
+
transcriptChanged(): void;
|
|
149
|
+
emit(): void;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Fans out to several sinks, and NEVER lets one failure hide another.
|
|
153
|
+
*
|
|
154
|
+
* The desktop needs exactly this once it can also run cloud sessions: the same
|
|
155
|
+
* transcript change goes to the renderer AND to the control plane. A sink that
|
|
156
|
+
* throws must not stop the sinks after it — the agent loop is the caller, and a
|
|
157
|
+
* broken IPC channel silently ending transcript delivery is precisely the class
|
|
158
|
+
* of bug this package exists to make impossible.
|
|
159
|
+
*/
|
|
160
|
+
export declare class CompositeEventSink implements EventSink {
|
|
161
|
+
private readonly sinks;
|
|
162
|
+
constructor(sinks: EventSink[]);
|
|
163
|
+
transcriptChanged(sessionId: string, maxRev: number): void;
|
|
164
|
+
emit(channel: string, payload: JsonValue): void;
|
|
165
|
+
}
|
|
166
|
+
export interface DirectoryWorkspaceOptions {
|
|
167
|
+
/**
|
|
168
|
+
* The root under which every session directory is created.
|
|
169
|
+
*
|
|
170
|
+
* `/var/pf/workspaces` in the sandbox image, and it is owned `pfharness:0700`
|
|
171
|
+
* — Phase 0 found it as `root:0755`, which would have stopped the harness
|
|
172
|
+
* creating session directories at all.
|
|
173
|
+
*/
|
|
174
|
+
root: string;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* One directory per session, mode 0700.
|
|
178
|
+
*
|
|
179
|
+
* DECISION 2 REMOVED THE UNIX USER PER SESSION, and `useradd` is deliberately
|
|
180
|
+
* absent from the sandbox image. The consequence is stated plainly rather than
|
|
181
|
+
* left implicit: INSIDE A TENANT, this directory is the only filesystem
|
|
182
|
+
* separation there is, and it is separation by path, not by kernel. Everything
|
|
183
|
+
* stronger is enforced by the harness and by the phase-3 MCP gateway, which is
|
|
184
|
+
* what makes that gateway load-bearing rather than merely useful.
|
|
185
|
+
*
|
|
186
|
+
* 'repository' mode is REFUSED here. Cloning a customer repository is an
|
|
187
|
+
* explicit per-agent opt-in on a runner the customer chose, so the capability
|
|
188
|
+
* belongs to a workspace provider that a self-hosted runner installs — not to
|
|
189
|
+
* the default that a shared cloud sandbox gets.
|
|
190
|
+
*/
|
|
191
|
+
export declare class DirectoryWorkspaceProvider implements WorkspaceProvider {
|
|
192
|
+
private readonly options;
|
|
193
|
+
constructor(options: DirectoryWorkspaceOptions);
|
|
194
|
+
prepare(request: WorkspaceRequest): Promise<string>;
|
|
195
|
+
release(sessionId: string): Promise<void>;
|
|
196
|
+
/**
|
|
197
|
+
* Session ids come from the control plane, but a PATH is built from them, so
|
|
198
|
+
* they are treated as UNTRUSTED INPUT regardless.
|
|
199
|
+
*
|
|
200
|
+
* IT REJECTS RATHER THAN SANITISES, and that distinction is the whole point.
|
|
201
|
+
* An earlier version mapped every unsafe character to `_`, which is not
|
|
202
|
+
* injective: `a/b` and `a_b` both became `<root>/a_b`. Two distinct sessions
|
|
203
|
+
* would then SHARE one workspace — and `release()` on either would delete the
|
|
204
|
+
* other's files. Decision 2 removed the unix user per session, so this
|
|
205
|
+
* directory is the only filesystem separation two sessions of one tenant have;
|
|
206
|
+
* a sanitiser that can collide quietly removes even that.
|
|
207
|
+
*
|
|
208
|
+
* The id is a control-plane identifier, so refusing an unrepresentable one
|
|
209
|
+
* costs nothing real and fails loudly instead of silently sharing state.
|
|
210
|
+
*/
|
|
211
|
+
private pathFor;
|
|
212
|
+
}
|