@peakflo/agent-harness 0.2.14-rc.1 → 0.2.14-rc.2

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.
@@ -3,6 +3,9 @@ export interface GitHubProxyCertificateOptions {
3
3
  /** Override only for deterministic tests. Production preserves process.env. */
4
4
  environment?: NodeJS.ProcessEnv;
5
5
  }
6
+ export declare function ambientCaCertificates(environment: NodeJS.ProcessEnv, outputPath: string): Promise<Buffer[]>;
7
+ /** Node's bundled public roots, PEM, newline-terminated. */
8
+ export declare function publicRootCertificates(): string;
6
9
  export interface GitHubProxyCertificates {
7
10
  caCertificate: Buffer;
8
11
  caCertificatePath: string;
@@ -1,5 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ambientCaCertificates = ambientCaCertificates;
4
+ exports.publicRootCertificates = publicRootCertificates;
3
5
  exports.createGitHubProxyCertificates = createGitHubProxyCertificates;
4
6
  require("reflect-metadata");
5
7
  const node_crypto_1 = require("node:crypto");
@@ -54,6 +56,10 @@ async function ambientCaCertificates(environment, outputPath) {
54
56
  }
55
57
  return certificates;
56
58
  }
59
+ /** Node's bundled public roots, PEM, newline-terminated. */
60
+ function publicRootCertificates() {
61
+ return `${node_tls_1.rootCertificates.join('\n')}\n`;
62
+ }
57
63
  function validity() {
58
64
  const now = Date.now();
59
65
  return {
@@ -103,7 +109,7 @@ async function createGitHubProxyCertificates(stateRoot, options = {}) {
103
109
  const caCertificate = Buffer.from(ca.toString('pem'));
104
110
  const caCertificatePath = (0, node_path_1.join)(root, CA_CERTIFICATE_FILE);
105
111
  const ambientRoots = await ambientCaCertificates(options.environment ?? process.env, caCertificatePath);
106
- const publicRoots = `${node_tls_1.rootCertificates.join('\n')}\n`;
112
+ const publicRoots = publicRootCertificates();
107
113
  await (0, promises_1.writeFile)(caCertificatePath, Buffer.concat([...ambientRoots, Buffer.from(publicRoots), caCertificate]), { mode: 0o644 });
108
114
  await (0, promises_1.chmod)(caCertificatePath, 0o644);
109
115
  const leaves = new Map();
@@ -11,6 +11,12 @@ export interface GitHubWorkspaceOptions {
11
11
  /** Injected in tests: the proxy's peer-uid lookup. See `GitHubTlsProxy`. */
12
12
  peerUid?: PeerUidResolver;
13
13
  }
14
+ /**
15
+ * Hostnames of URLs the agent reaches WITHOUT a proxy (control plane, model
16
+ * gateway, MCP). Shared with the credential proxy's NO_PROXY so both proxies
17
+ * bypass exactly the same hosts.
18
+ */
19
+ export declare function directHostnames(urls: string[]): string[];
14
20
  /** Bare clone plus one worktree per session, adapted from 20x. */
15
21
  export declare class GitHubWorkspaceProvider implements WorkspaceProvider {
16
22
  private readonly options;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.GitHubWorkspaceProvider = void 0;
4
+ exports.directHostnames = directHostnames;
4
5
  const node_child_process_1 = require("node:child_process");
5
6
  const node_crypto_1 = require("node:crypto");
6
7
  const node_fs_1 = require("node:fs");
@@ -17,6 +18,25 @@ const DEFAULT_PROXY_PORT = 7780;
17
18
  const LINK_SECRET_FILE = '.github-proxy-link-secret';
18
19
  const WIP_PUSH_FAILED_FILE = '.worktree-wip-push-failed';
19
20
  const WIP_COMMIT_MESSAGE = 'chore(agent): wip — uncommitted at turn end';
21
+ /**
22
+ * Hostnames of URLs the agent reaches WITHOUT a proxy (control plane, model
23
+ * gateway, MCP). Shared with the credential proxy's NO_PROXY so both proxies
24
+ * bypass exactly the same hosts.
25
+ */
26
+ function directHostnames(urls) {
27
+ const hosts = [];
28
+ for (const value of urls) {
29
+ if (!value)
30
+ continue;
31
+ try {
32
+ hosts.push(new URL(value).hostname.toLowerCase());
33
+ }
34
+ catch {
35
+ /* The component that owns an invalid URL reports it. */
36
+ }
37
+ }
38
+ return hosts;
39
+ }
20
40
  /** Bare clone plus one worktree per session, adapted from 20x. */
21
41
  class GitHubWorkspaceProvider {
22
42
  options;
@@ -46,16 +66,8 @@ class GitHubWorkspaceProvider {
46
66
  this.rememberDirectUrls(options.directUrls ?? []);
47
67
  }
48
68
  rememberDirectUrls(urls) {
49
- for (const value of urls) {
50
- if (!value)
51
- continue;
52
- try {
53
- this.directHosts.add(new URL(value).hostname.toLowerCase());
54
- }
55
- catch {
56
- /* The component that owns an invalid URL reports it. */
57
- }
58
- }
69
+ for (const host of directHostnames(urls))
70
+ this.directHosts.add(host);
59
71
  }
60
72
  runGit(args, options = {}) {
61
73
  const { environment, ...execOptions } = options;
package/dist/harness.js CHANGED
@@ -1872,6 +1872,8 @@ class Harness {
1872
1872
  events: this.events,
1873
1873
  telemetry: this.telemetry,
1874
1874
  coldStartLog: (line) => coldStart.add(line),
1875
+ runtime: this.options.runtime,
1876
+ directUrls: [this.options.controlPlaneUrl],
1875
1877
  notifier: this.notifier,
1876
1878
  artifacts: this.artifacts,
1877
1879
  conversation: this.conversation,
package/dist/index.d.ts CHANGED
@@ -59,4 +59,4 @@ export * from './adapters';
59
59
  * "ephemeral" means. A rule written twice is a rule that drifts.
60
60
  */
61
61
  export { CONTRACT_ERROR_CODES, DEFAULT_BASE_DELAY_MS, DEFAULT_HEARTBEAT_INTERVAL_MS, DEFAULT_LEASE_TTL_MS, DEFAULT_LEASE_WAIT_SECONDS, DEFAULT_MAX_ATTEMPTS, EPHEMERAL_PART_TYPES, FINAL_HTTP_STATUSES, HARNESS_UPGRADE_EXIT_CODE, MAX_INGEST_PARTS, MAX_INGEST_PART_BYTES, MAX_LEASE_WAIT_SECONDS, MIN_SUPPORTED_PROTOCOL_VERSION, MISSED_HEARTBEATS_BEFORE_OFFLINE, PERSISTED_PART_TYPES, PROTOCOL_VERSION, PROTOCOL_VERSION_HEADER, ROUTES, ingestPartByteLength, isEphemeralPartType, isFinalStatus, isPersistablePart, isProtocolSupported } from './contract';
62
- export type { BootstrapRequest, BootstrapResponse, ControlPlaneErrorBody, CredentialRotateRequest, CredentialRotateResponse, DeregisterRequest, DeregisterResponse, HarnessOptions, HealthReport, IngestRejection, IngestRejectionReason, IngestRequest, IngestResponse, JsonValue, LeaseRenewRequest, LeaseRenewResponse, LeaseResponse, LeasedAgent, LeasedAttachment, LeasedLimits, LeasedMcpServer, LeasedModel, LeasedSession, LeasedSkill, LeasedTurnInput, LeasedWorkspace, LeasedGitHubCredential, LeasedGitHubRepository, ProtocolEnvelope, RunnerBackend, RunnerCapabilities, RunnerHeartbeatRequest, RunnerHeartbeatResponse, RunnerModel, RunnerRegisterRequest, RunnerRegisterResponse, RunnerRuntime, RunnerStatus, SessionCommand, TranscriptPart, TranscriptPartInput, TurnMetadata } from './types';
62
+ export type { BootstrapRequest, BootstrapResponse, ControlPlaneErrorBody, CredentialRotateRequest, CredentialRotateResponse, DeregisterRequest, DeregisterResponse, HarnessOptions, HealthReport, IngestRejection, IngestRejectionReason, IngestRequest, IngestResponse, JsonValue, LeaseRenewRequest, LeaseRenewResponse, LeaseResponse, LeasedAgent, LeasedAttachment, LeasedCredentialProxy, LeasedLimits, LeasedMcpServer, LeasedModel, LeasedSession, LeasedSkill, LeasedTurnInput, LeasedWorkspace, LeasedGitHubCredential, LeasedGitHubRepository, ProtocolEnvelope, RunnerBackend, RunnerCapabilities, RunnerHeartbeatRequest, RunnerHeartbeatResponse, RunnerModel, RunnerRegisterRequest, RunnerRegisterResponse, RunnerRuntime, RunnerStatus, SessionCommand, TranscriptPart, TranscriptPartInput, TurnMetadata } from './types';
@@ -0,0 +1,44 @@
1
+ import type { LeasedCredentialProxy, RunnerRuntime } from '../types';
2
+ export declare const CREDENTIAL_PROXY_CA_BUNDLE_FILE = ".pf-credential-proxy-ca.pem";
3
+ export declare const CREDENTIAL_PROXY_HEALTH_TIMEOUT_MS = 1000;
4
+ export declare const CREDENTIAL_PROXY_TOKEN_REDACTION = "[credential-proxy-token]";
5
+ /** Every key this module may add or replace. Nothing else is ever touched. */
6
+ export declare const CREDENTIAL_PROXY_ENV_KEYS: readonly ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "NO_PROXY", "no_proxy", "SSL_CERT_FILE", "CURL_CA_BUNDLE", "NODE_EXTRA_CA_CERTS", "GIT_SSL_CAINFO", "REQUESTS_CA_BUNDLE", "PF_CREDENTIAL_PROXY"];
7
+ export type CredentialProxyUnavailableReason = 'not_offered' | 'runtime_not_sandbox' | 'vendor_backend' | 'invalid_descriptor' | 'health_check_failed' | 'ca_unreadable' | 'bundle_write_failed';
8
+ export interface CredentialProxyEnvironmentInput {
9
+ /** The HARNESS's own runtime (its CLI/env option), not the lease's claim. */
10
+ harnessRuntime: RunnerRuntime | undefined;
11
+ /** The lease's session runtime. Both must say `sandbox`. */
12
+ sessionRuntime: RunnerRuntime | string | undefined;
13
+ backend: string;
14
+ sessionId: string;
15
+ /** Session directory; the merged CA bundle is written here, 0644. */
16
+ sessionDir: string;
17
+ descriptor: LeasedCredentialProxy | undefined | null;
18
+ /** The environment the turn would get today (GitHub's, or undefined). */
19
+ environment: Record<string, string> | undefined;
20
+ /** Hosts the agent reaches directly (gateway, MCP, control plane...). */
21
+ directUrls: string[];
22
+ logger: Pick<Console, 'log' | 'warn'>;
23
+ /** Tests only. Production reads process.env, like the GitHub bundle. */
24
+ ambientEnvironment?: NodeJS.ProcessEnv;
25
+ }
26
+ export interface CredentialProxyEnvironmentResult {
27
+ environment: Record<string, string> | undefined;
28
+ /** Non-null exactly when the proxy variables were applied. */
29
+ token: string | null;
30
+ reason: CredentialProxyUnavailableReason | null;
31
+ }
32
+ /** TCP connect to 127.0.0.1:port; true when it connects within the timeout. */
33
+ export declare function probeLoopbackPort(port: number, timeoutMs?: number): Promise<boolean>;
34
+ /** The GitHub proxy's loopback port, read off the env GitHub already built. */
35
+ export declare function githubProxyPort(environment: Record<string, string> | undefined): number | null;
36
+ export declare function credentialProxyEnvironment(input: CredentialProxyEnvironmentInput): Promise<CredentialProxyEnvironmentResult>;
37
+ /** Replace every occurrence of `token` in a string. No-op without a token. */
38
+ export declare function redactCredentialProxyToken(text: string, token: string | null): string;
39
+ /**
40
+ * Same, for a JSON-shaped value (transcript parts, turn metadata). The token
41
+ * alphabet needs no JSON escaping, so a string split over the serialised form
42
+ * finds every occurrence in every nested string.
43
+ */
44
+ export declare function redactCredentialProxyTokenDeep<T>(value: T, token: string | null): T;
@@ -0,0 +1,230 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CREDENTIAL_PROXY_ENV_KEYS = exports.CREDENTIAL_PROXY_TOKEN_REDACTION = exports.CREDENTIAL_PROXY_HEALTH_TIMEOUT_MS = exports.CREDENTIAL_PROXY_CA_BUNDLE_FILE = void 0;
4
+ exports.probeLoopbackPort = probeLoopbackPort;
5
+ exports.githubProxyPort = githubProxyPort;
6
+ exports.credentialProxyEnvironment = credentialProxyEnvironment;
7
+ exports.redactCredentialProxyToken = redactCredentialProxyToken;
8
+ exports.redactCredentialProxyTokenDeep = redactCredentialProxyTokenDeep;
9
+ /**
10
+ * THE AGENT'S ROUTE TO THE SANDBOX CREDENTIAL PROXY.
11
+ *
12
+ * The credential proxy runs in the ROOT sandbox broker, not in this process.
13
+ * It holds every real credential value; the harness never does. That is not a
14
+ * style choice: the harness and the agent it spawns share one uid (1500), so
15
+ * anything in the harness's memory or `/proc/<pid>/environ` is readable by the
16
+ * agent. The only thing the harness ever handles is the lease's
17
+ * `credentialProxy` descriptor — a loopback port, a CA path, and a token that
18
+ * is bound to tenant/session/turn/sandbox and is NOT a credential value.
19
+ *
20
+ * What this module does with it, and nothing more:
21
+ * - decide whether the proxy is usable for THIS turn (sandbox runtime, Pi or
22
+ * OpenCode backend, port answers within 1 s, CA readable), and
23
+ * - if so, layer the proxy variables over the turn's existing environment.
24
+ *
25
+ * Any failed check returns the input environment UNCHANGED, plus one log line
26
+ * carrying the sessionId and a reason code. The token is never logged.
27
+ */
28
+ const node_crypto_1 = require("node:crypto");
29
+ const promises_1 = require("node:fs/promises");
30
+ const node_net_1 = require("node:net");
31
+ const node_path_1 = require("node:path");
32
+ const github_proxy_certificates_1 = require("../github-proxy-certificates");
33
+ const github_workspace_1 = require("../github-workspace");
34
+ const vendor_backends_1 = require("../vendor-backends");
35
+ exports.CREDENTIAL_PROXY_CA_BUNDLE_FILE = '.pf-credential-proxy-ca.pem';
36
+ exports.CREDENTIAL_PROXY_HEALTH_TIMEOUT_MS = 1_000;
37
+ exports.CREDENTIAL_PROXY_TOKEN_REDACTION = '[credential-proxy-token]';
38
+ /** Every key this module may add or replace. Nothing else is ever touched. */
39
+ exports.CREDENTIAL_PROXY_ENV_KEYS = [
40
+ 'HTTPS_PROXY',
41
+ 'https_proxy',
42
+ 'HTTP_PROXY',
43
+ 'http_proxy',
44
+ 'NO_PROXY',
45
+ 'no_proxy',
46
+ 'SSL_CERT_FILE',
47
+ 'CURL_CA_BUNDLE',
48
+ 'NODE_EXTRA_CA_CERTS',
49
+ 'GIT_SSL_CAINFO',
50
+ 'REQUESTS_CA_BUNDLE',
51
+ 'PF_CREDENTIAL_PROXY'
52
+ ];
53
+ /*
54
+ * The token lands in the userinfo of a proxy URL, so it must need no
55
+ * percent-encoding. The contract format `pfcp_<b64url>.<b64url>` satisfies
56
+ * this; anything else is refused rather than silently mangled.
57
+ */
58
+ const TOKEN_SHAPE = /^[A-Za-z0-9._~-]{1,2048}$/;
59
+ /** TCP connect to 127.0.0.1:port; true when it connects within the timeout. */
60
+ function probeLoopbackPort(port, timeoutMs = exports.CREDENTIAL_PROXY_HEALTH_TIMEOUT_MS) {
61
+ return new Promise((resolvePromise) => {
62
+ const socket = (0, node_net_1.connect)({ host: '127.0.0.1', port });
63
+ let settled = false;
64
+ const finish = (ok) => {
65
+ if (settled)
66
+ return;
67
+ settled = true;
68
+ socket.destroy();
69
+ resolvePromise(ok);
70
+ };
71
+ // Covers the connect phase too: no SYN-ACK within the window -> 'timeout'.
72
+ socket.setTimeout(timeoutMs);
73
+ socket.once('timeout', () => finish(false));
74
+ socket.once('connect', () => finish(true));
75
+ socket.once('error', () => finish(false));
76
+ });
77
+ }
78
+ function validDescriptor(descriptor) {
79
+ return (typeof descriptor === 'object' &&
80
+ descriptor !== null &&
81
+ Number.isInteger(descriptor.port) &&
82
+ descriptor.port > 0 &&
83
+ descriptor.port < 65_536 &&
84
+ typeof descriptor.token === 'string' &&
85
+ TOKEN_SHAPE.test(descriptor.token) &&
86
+ typeof descriptor.caPath === 'string' &&
87
+ descriptor.caPath.length > 0);
88
+ }
89
+ /** The GitHub proxy's loopback port, read off the env GitHub already built. */
90
+ function githubProxyPort(environment) {
91
+ const value = environment?.HTTPS_PROXY ?? environment?.https_proxy;
92
+ if (!value)
93
+ return null;
94
+ try {
95
+ const url = new URL(value);
96
+ if (url.hostname !== '127.0.0.1' || url.username)
97
+ return null;
98
+ const port = Number(url.port);
99
+ return Number.isInteger(port) && port > 0 ? port : null;
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ }
105
+ function withNewline(contents) {
106
+ return contents.length === 0 || contents.at(-1) === 0x0a
107
+ ? contents
108
+ : Buffer.concat([contents, Buffer.from('\n')]);
109
+ }
110
+ /**
111
+ * System trust + GitHub proxy CA (if any) + credential proxy CA.
112
+ *
113
+ * The GitHub bundle, when the turn has one, already IS "ambient roots + Node's
114
+ * public roots + GitHub CA" (see `createGitHubProxyCertificates`), so it is
115
+ * the base as-is. Without it the base is built the same way GitHub builds it.
116
+ */
117
+ async function writeMergedBundle(input, credentialCa) {
118
+ const path = (0, node_path_1.join)((0, node_path_1.resolve)(input.sessionDir), exports.CREDENTIAL_PROXY_CA_BUNDLE_FILE);
119
+ const githubBundle = input.environment?.SSL_CERT_FILE;
120
+ let base;
121
+ if (githubBundle && (0, node_path_1.resolve)(githubBundle) !== path) {
122
+ base = [withNewline(await (0, promises_1.readFile)(githubBundle))];
123
+ }
124
+ else {
125
+ base = [
126
+ ...(await (0, github_proxy_certificates_1.ambientCaCertificates)(input.ambientEnvironment ?? process.env, path)),
127
+ Buffer.from((0, github_proxy_certificates_1.publicRootCertificates)())
128
+ ];
129
+ }
130
+ const temporary = `${path}.${(0, node_crypto_1.randomUUID)()}.tmp`;
131
+ await (0, promises_1.writeFile)(temporary, Buffer.concat([...base, withNewline(credentialCa)]), {
132
+ mode: 0o644
133
+ });
134
+ await (0, promises_1.chmod)(temporary, 0o644);
135
+ await (0, promises_1.rename)(temporary, path);
136
+ return path;
137
+ }
138
+ async function credentialProxyEnvironment(input) {
139
+ const unchanged = (reason) => {
140
+ // A self-hosted/desktop turn without the field is the normal case there,
141
+ // not a degradation; it stays silent. Everything else is one line.
142
+ if (!(reason === 'runtime_not_sandbox' && !input.descriptor)) {
143
+ input.logger.warn(`[CredentialProxy] credential proxy unavailable sessionId=${input.sessionId} reason=${reason}`);
144
+ }
145
+ return { environment: input.environment, token: null, reason };
146
+ };
147
+ // Defence in depth: the API never sends the field off-sandbox, and even if
148
+ // it did, a runner on a user's machine must not route through it.
149
+ if (input.harnessRuntime !== 'sandbox' || input.sessionRuntime !== 'sandbox')
150
+ return unchanged('runtime_not_sandbox');
151
+ if (!input.descriptor)
152
+ return unchanged('not_offered');
153
+ // A vendor CLI gets only an allowlisted env (vendor-environment.ts); it is
154
+ // not a supported credential-proxy client.
155
+ if ((0, vendor_backends_1.isVendorBackend)(input.backend))
156
+ return unchanged('vendor_backend');
157
+ const descriptor = input.descriptor;
158
+ if (!validDescriptor(descriptor))
159
+ return unchanged('invalid_descriptor');
160
+ if (!(await probeLoopbackPort(descriptor.port)))
161
+ return unchanged('health_check_failed');
162
+ let credentialCa;
163
+ try {
164
+ credentialCa = await (0, promises_1.readFile)(descriptor.caPath);
165
+ }
166
+ catch {
167
+ return unchanged('ca_unreadable');
168
+ }
169
+ if (!credentialCa.includes('-----BEGIN CERTIFICATE-----'))
170
+ return unchanged('ca_unreadable');
171
+ let bundle;
172
+ try {
173
+ bundle = await writeMergedBundle(input, credentialCa);
174
+ }
175
+ catch {
176
+ return unchanged('bundle_write_failed');
177
+ }
178
+ const base = input.environment ?? {};
179
+ // The cred proxy is the FIRST hop. When the turn has a GitHub proxy, the
180
+ // broker chains GitHub traffic to it; the password slot says where.
181
+ const chainPort = githubProxyPort(base) ?? 0;
182
+ const proxy = `http://${descriptor.token}:${chainPort}@127.0.0.1:${descriptor.port}`;
183
+ const noProxy = base.NO_PROXY ??
184
+ base.no_proxy ??
185
+ [
186
+ ...new Set([
187
+ '127.0.0.1',
188
+ 'localhost',
189
+ ...(0, github_workspace_1.directHostnames)(input.directUrls)
190
+ ])
191
+ ].join(',');
192
+ return {
193
+ environment: {
194
+ ...base,
195
+ HTTPS_PROXY: proxy,
196
+ https_proxy: proxy,
197
+ HTTP_PROXY: proxy,
198
+ http_proxy: proxy,
199
+ NO_PROXY: noProxy,
200
+ no_proxy: noProxy,
201
+ SSL_CERT_FILE: bundle,
202
+ CURL_CA_BUNDLE: bundle,
203
+ NODE_EXTRA_CA_CERTS: bundle,
204
+ GIT_SSL_CAINFO: bundle,
205
+ REQUESTS_CA_BUNDLE: bundle,
206
+ PF_CREDENTIAL_PROXY: '1'
207
+ },
208
+ token: descriptor.token,
209
+ reason: null
210
+ };
211
+ }
212
+ /** Replace every occurrence of `token` in a string. No-op without a token. */
213
+ function redactCredentialProxyToken(text, token) {
214
+ return token
215
+ ? text.split(token).join(exports.CREDENTIAL_PROXY_TOKEN_REDACTION)
216
+ : text;
217
+ }
218
+ /**
219
+ * Same, for a JSON-shaped value (transcript parts, turn metadata). The token
220
+ * alphabet needs no JSON escaping, so a string split over the serialised form
221
+ * finds every occurrence in every nested string.
222
+ */
223
+ function redactCredentialProxyTokenDeep(value, token) {
224
+ if (!token || value === undefined)
225
+ return value;
226
+ const serialised = JSON.stringify(value);
227
+ if (!serialised.includes(token))
228
+ return value;
229
+ return JSON.parse(redactCredentialProxyToken(serialised, token));
230
+ }
@@ -21,7 +21,7 @@
21
21
  import type { CodingAgentAdapter } from '../adapters';
22
22
  import { SessionModelGateway } from '../adapters/model-gateway';
23
23
  import { ArtifactInspector, ConversationStore, EventSink, ModelMessageRecord, Notifier, SecretProvider, Telemetry, TranscriptStore, WorkspaceProvider } from '../ports';
24
- import { LeaseResponse, LeasedTurnInput, ModelMessageInput, TranscriptPartInput, TurnMetadata } from '../types';
24
+ import { LeaseResponse, LeasedTurnInput, ModelMessageInput, RunnerRuntime, TranscriptPartInput, TurnMetadata } from '../types';
25
25
  import { KnowledgeToolHost, type KnowledgeControl } from './knowledge-tools';
26
26
  import { McpAuthProxy } from './mcp-auth-proxy';
27
27
  import { type FailedMcpCheck } from './mcp-preflight';
@@ -47,6 +47,13 @@ export interface SessionRunnerDeps {
47
47
  telemetry: Telemetry;
48
48
  /** Send a structured timing event to the control plane without waiting. */
49
49
  coldStartLog?: (line: string) => void;
50
+ /**
51
+ * The HARNESS's own runtime. Only `sandbox` may route the agent through the
52
+ * credential proxy, whatever the lease says; absent means "not sandbox".
53
+ */
54
+ runtime?: RunnerRuntime;
55
+ /** Harness-level hosts the agent reaches directly (the control plane). */
56
+ directUrls?: string[];
50
57
  notifier: Notifier;
51
58
  artifacts: ArtifactInspector;
52
59
  /**
@@ -406,6 +413,12 @@ export declare class SessionRunner {
406
413
  private browserPauseStartedAt;
407
414
  /** When the last browser pause ended; the idle clock restarts from here. */
408
415
  private browserPauseEndedAt;
416
+ /**
417
+ * The credential-proxy token in force for this turn, kept ONLY so it can be
418
+ * scrubbed from everything this runner sends or logs. Not a credential
419
+ * value (see `LeaseResponse.credentialProxy`); null when the proxy is off.
420
+ */
421
+ private credentialProxyToken;
409
422
  constructor(deps: SessionRunnerDeps);
410
423
  /**
411
424
  * True while a permission prompt is outstanding. Read by the harness to
@@ -643,6 +656,8 @@ export declare class SessionRunner {
643
656
  * old Space's copies. Never throws; a failed sync is logged and the turn runs.
644
657
  */
645
658
  private syncSpaceFilesForTurn;
659
+ /** URLs this session's agent reaches without a proxy. */
660
+ private sessionDirectUrls;
646
661
  /**
647
662
  * Build the backend's session config.
648
663
  *
@@ -38,6 +38,7 @@ const vendor_backends_1 = require("../vendor-backends");
38
38
  const attachments_1 = require("./attachments");
39
39
  const cold_start_steps_1 = require("./cold-start-steps");
40
40
  const conversation_1 = require("./conversation");
41
+ const credential_proxy_1 = require("./credential-proxy");
41
42
  const knowledge_tools_1 = require("./knowledge-tools");
42
43
  const mcp_preflight_1 = require("./mcp-preflight");
43
44
  const mcp_tool_store_1 = require("./mcp-tool-store");
@@ -657,6 +658,12 @@ class SessionRunner {
657
658
  browserPauseStartedAt = null;
658
659
  /** When the last browser pause ended; the idle clock restarts from here. */
659
660
  browserPauseEndedAt = null;
661
+ /**
662
+ * The credential-proxy token in force for this turn, kept ONLY so it can be
663
+ * scrubbed from everything this runner sends or logs. Not a credential
664
+ * value (see `LeaseResponse.credentialProxy`); null when the proxy is off.
665
+ */
666
+ credentialProxyToken = null;
660
667
  constructor(deps) {
661
668
  this.deps = deps;
662
669
  this.logger = deps.logger ?? console;
@@ -724,7 +731,9 @@ class SessionRunner {
724
731
  async run(leased, signal) {
725
732
  const lease = withoutGatewayForVendorBackend(leased);
726
733
  const { session } = lease;
727
- const logColdStart = (line) => {
734
+ this.credentialProxyToken = null;
735
+ const logColdStart = (raw) => {
736
+ const line = (0, credential_proxy_1.redactCredentialProxyToken)(raw, this.credentialProxyToken);
728
737
  this.logger.log(line);
729
738
  this.deps.coldStartLog?.(line);
730
739
  };
@@ -1012,6 +1021,27 @@ class SessionRunner {
1012
1021
  const unavailableMcpServers = [];
1013
1022
  const config = this.buildSessionConfig(session, workspaceDir, secretToken, signal, knowledgeUrl, unavailableMcpServers);
1014
1023
  config.coldStartLog = this.deps.coldStartLog;
1024
+ /*
1025
+ * THE CREDENTIAL PROXY. Layered over the env built above only when the
1026
+ * lease offers it, the runner is a sandbox, and the broker answers; any
1027
+ * failed check leaves `config.environment` exactly as it was.
1028
+ */
1029
+ const credentialProxy = await (0, credential_proxy_1.credentialProxyEnvironment)({
1030
+ harnessRuntime: this.deps.runtime,
1031
+ sessionRuntime: session.runtime,
1032
+ backend: session.agent.backend,
1033
+ sessionId: session.sessionId,
1034
+ sessionDir: workspaceDir,
1035
+ descriptor: lease.credentialProxy,
1036
+ environment: config.environment,
1037
+ directUrls: [
1038
+ ...(this.deps.directUrls ?? []),
1039
+ ...this.sessionDirectUrls(session, knowledgeUrl)
1040
+ ],
1041
+ logger: this.logger
1042
+ });
1043
+ config.environment = credentialProxy.environment;
1044
+ this.credentialProxyToken = credentialProxy.token;
1015
1045
  /*
1016
1046
  * ITEM 1(b). A server whose stored listing is still valid for this
1017
1047
  * toolset and this speaker is not checked again: its tools are seeded
@@ -2467,9 +2497,14 @@ class SessionRunner {
2467
2497
  if (chunks.length === 0) {
2468
2498
  chunks.push([]);
2469
2499
  }
2500
+ // The agent can echo its own env (`env`, a failing curl) into a tool
2501
+ // result; the credential-proxy token must not reach the transcript.
2502
+ const token = this.credentialProxyToken;
2470
2503
  for (let i = 0; i < chunks.length; i += 1) {
2471
2504
  const isLast = i === chunks.length - 1;
2472
- await this.deps.ingest(sessionId, chunks[i], isLast ? turn : undefined, isLast ? final : false, isLast ? modelMessages : undefined);
2505
+ await this.deps.ingest(sessionId, (0, credential_proxy_1.redactCredentialProxyTokenDeep)(chunks[i], token), isLast ? (0, credential_proxy_1.redactCredentialProxyTokenDeep)(turn, token) : undefined, isLast ? final : false, isLast
2506
+ ? (0, credential_proxy_1.redactCredentialProxyTokenDeep)(modelMessages, token)
2507
+ : undefined);
2473
2508
  }
2474
2509
  }
2475
2510
  /* ---------------------------------------------------------------- */
@@ -2737,6 +2772,14 @@ class SessionRunner {
2737
2772
  this.logger.warn(`[SessionRunner] Space files sync failed: ${(error instanceof Error ? error.message : String(error)).replace(/https?:\/\/\S+/gi, '<url>')}`);
2738
2773
  }
2739
2774
  }
2775
+ /** URLs this session's agent reaches without a proxy. */
2776
+ sessionDirectUrls(session, knowledgeUrl) {
2777
+ return [
2778
+ session.agent.model.gatewayUrl ?? '',
2779
+ ...session.agent.mcpServers.map((server) => server.url),
2780
+ ...(knowledgeUrl ? [knowledgeUrl] : [])
2781
+ ];
2782
+ }
2740
2783
  /**
2741
2784
  * Build the backend's session config.
2742
2785
  *
@@ -2859,11 +2902,7 @@ class SessionRunner {
2859
2902
  secretBrokerPort: brokerPort ?? undefined,
2860
2903
  secretSessionToken: secretToken ?? undefined,
2861
2904
  secretShellPath: this.deps.secrets.writeShellWrapper() ?? undefined,
2862
- environment: this.deps.workspace.getEnvironment?.(session.sessionId, [
2863
- session.agent.model.gatewayUrl ?? '',
2864
- ...session.agent.mcpServers.map((server) => server.url),
2865
- ...(knowledgeUrl ? [knowledgeUrl] : [])
2866
- ]),
2905
+ environment: this.deps.workspace.getEnvironment?.(session.sessionId, this.sessionDirectUrls(session, knowledgeUrl)),
2867
2906
  /*
2868
2907
  * DEFECT G1, THE OTHER HALF.
2869
2908
  *
package/dist/types.d.ts CHANGED
@@ -516,6 +516,28 @@ export interface LeaseResponse {
516
516
  modelTokenExpiresAt?: string;
517
517
  /** In-memory only. It is never passed through a process environment. */
518
518
  githubCredential?: LeasedGitHubCredential;
519
+ /**
520
+ * Where the agent reaches the sandbox CREDENTIAL PROXY for this turn.
521
+ *
522
+ * NOT A SECRET VALUE. The real credential values live only in the root
523
+ * sandbox broker that runs the proxy; the harness (same uid as the agent)
524
+ * never holds one. `token` is a capability bound to tenant, session, turn
525
+ * and sandbox, which the agent presents to the proxy as the proxy URL's
526
+ * username. It is still never logged.
527
+ *
528
+ * ADDITIVE AND OPTIONAL (no protocol bump). Sent only for `sandbox` leases;
529
+ * the harness also refuses it on any other runtime and health-checks the
530
+ * port before using it. See `runtime/credential-proxy.ts`.
531
+ */
532
+ credentialProxy?: LeasedCredentialProxy;
533
+ }
534
+ export interface LeasedCredentialProxy {
535
+ /** Loopback port of the broker's proxy, e.g. 7790. */
536
+ port: number;
537
+ /** `pfcp_<b64url payload>.<b64url hmac>` — a bound capability, not a credential. */
538
+ token: string;
539
+ /** Public CA certificate (PEM) the proxy signs leaves with. */
540
+ caPath: string;
519
541
  }
520
542
  export interface LeasedSession {
521
543
  sessionId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peakflo/agent-harness",
3
- "version": "0.2.14-rc.1",
3
+ "version": "0.2.14-rc.2",
4
4
  "description": "Headless Peakflo coding-agent harness. One codebase, three hosting modes: a Daytona cloud sandbox, a customer VM, and the 20x desktop.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",