@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.
Files changed (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -2
  3. package/dist/adapters/acp-adapter.d.ts +177 -0
  4. package/dist/adapters/acp-adapter.js +1732 -0
  5. package/dist/adapters/claude-code-adapter.d.ts +171 -0
  6. package/dist/adapters/claude-code-adapter.js +1918 -0
  7. package/dist/adapters/codex-adapter.d.ts +67 -0
  8. package/dist/adapters/codex-adapter.js +681 -0
  9. package/dist/adapters/codex-app-server-adapter.d.ts +105 -0
  10. package/dist/adapters/codex-app-server-adapter.js +1309 -0
  11. package/dist/adapters/coding-agent-adapter.d.ts +289 -0
  12. package/dist/adapters/coding-agent-adapter.js +31 -0
  13. package/dist/adapters/index.d.ts +17 -0
  14. package/dist/adapters/index.js +35 -0
  15. package/dist/adapters/model-gateway.d.ts +84 -0
  16. package/dist/adapters/model-gateway.js +270 -0
  17. package/dist/adapters/opencode-adapter.d.ts +403 -0
  18. package/dist/adapters/opencode-adapter.js +2710 -0
  19. package/dist/adapters/opencode-config.d.ts +41 -0
  20. package/dist/adapters/opencode-config.js +147 -0
  21. package/dist/adapters/opencode-server-policy.d.ts +82 -0
  22. package/dist/adapters/opencode-server-policy.js +148 -0
  23. package/dist/adapters/pi-adapter.d.ts +168 -0
  24. package/dist/adapters/pi-adapter.js +1468 -0
  25. package/dist/capabilities.d.ts +58 -0
  26. package/dist/capabilities.js +161 -0
  27. package/dist/cli.d.ts +2 -0
  28. package/dist/cli.js +231 -0
  29. package/dist/contract.d.ts +273 -0
  30. package/dist/contract.js +357 -0
  31. package/dist/control-plane.d.ts +178 -0
  32. package/dist/control-plane.js +431 -0
  33. package/dist/credential-store.d.ts +110 -0
  34. package/dist/credential-store.js +181 -0
  35. package/dist/enrol/browser-enrol.d.ts +85 -0
  36. package/dist/enrol/browser-enrol.js +387 -0
  37. package/dist/github-command-guards.d.ts +20 -0
  38. package/dist/github-command-guards.js +330 -0
  39. package/dist/github-proxy-certificates.d.ts +25 -0
  40. package/dist/github-proxy-certificates.js +145 -0
  41. package/dist/github-proxy.d.ts +37 -0
  42. package/dist/github-proxy.js +476 -0
  43. package/dist/github-workspace.d.ts +55 -0
  44. package/dist/github-workspace.js +537 -0
  45. package/dist/harness.d.ts +333 -0
  46. package/dist/harness.js +1414 -0
  47. package/dist/health.d.ts +45 -0
  48. package/dist/health.js +116 -0
  49. package/dist/index.d.ts +53 -0
  50. package/dist/index.js +123 -0
  51. package/dist/model-probe.d.ts +96 -0
  52. package/dist/model-probe.js +195 -0
  53. package/dist/options.d.ts +15 -0
  54. package/dist/options.js +212 -0
  55. package/dist/ports-default.d.ts +212 -0
  56. package/dist/ports-default.js +333 -0
  57. package/dist/ports.d.ts +436 -0
  58. package/dist/ports.js +27 -0
  59. package/dist/runtime/attachments.d.ts +92 -0
  60. package/dist/runtime/attachments.js +377 -0
  61. package/dist/runtime/child-stream-guards.d.ts +45 -0
  62. package/dist/runtime/child-stream-guards.js +111 -0
  63. package/dist/runtime/conversation.d.ts +87 -0
  64. package/dist/runtime/conversation.js +156 -0
  65. package/dist/runtime/lease-renewal.d.ts +169 -0
  66. package/dist/runtime/lease-renewal.js +433 -0
  67. package/dist/runtime/mcp-auth-proxy.d.ts +82 -0
  68. package/dist/runtime/mcp-auth-proxy.js +300 -0
  69. package/dist/runtime/permission-mode.d.ts +116 -0
  70. package/dist/runtime/permission-mode.js +146 -0
  71. package/dist/runtime/projection.d.ts +71 -0
  72. package/dist/runtime/projection.js +211 -0
  73. package/dist/runtime/session-runner.d.ts +520 -0
  74. package/dist/runtime/session-runner.js +2122 -0
  75. package/dist/shared/reasoning-effort.d.ts +11 -0
  76. package/dist/shared/reasoning-effort.js +20 -0
  77. package/dist/store/conversation-store.d.ts +100 -0
  78. package/dist/store/conversation-store.js +305 -0
  79. package/dist/store/sqlite-store.d.ts +120 -0
  80. package/dist/store/sqlite-store.js +346 -0
  81. package/dist/types.d.ts +952 -0
  82. package/dist/types.js +55 -0
  83. package/dist/version.d.ts +32 -0
  84. package/dist/version.js +77 -0
  85. package/package.json +53 -4
@@ -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
+ }