@cat-factory/executor-harness 1.147.0 → 1.149.0

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.
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { scrubbedExcerpt } from './redact.js';
2
3
  // ---------------------------------------------------------------------------
3
4
  // The one-container image the platform runs to find out whether this machine's Docker daemon
4
5
  // can actually run a container, built here in memory rather than pulled.
@@ -39,6 +40,140 @@ const PROBE_BINARY_PATH = 'busybox';
39
40
  export const PROBE_SENTINEL = 'cat-factory-docker-probe-ok';
40
41
  /** The argv the probe container runs. `busybox` dispatches on its own name, so this is an echo. */
41
42
  export const PROBE_COMMAND = [`/${PROBE_BINARY_PATH}`, 'echo', PROBE_SENTINEL];
43
+ // ---------------------------------------------------------------------------
44
+ // The second thing the same image is asked, and the one the marker run above structurally cannot
45
+ // answer: whether a container started on this daemon can reach the NETWORK.
46
+ //
47
+ // Loading and running a local image needs no network at all, so a daemon whose nested containers
48
+ // are cut off passes the marker run exactly as a working one does. That is not hypothetical: the
49
+ // published executor image ran its rootless daemon with `--iptables=false`, which drops the
50
+ // MASQUERADE rule for the bridge, and every nested container on it had no egress whatsoever
51
+ // (issue #2173). The harness reported `dockerDaemon: "usable"` throughout, and each agent
52
+ // discovered otherwise about seven minutes into an `npm ci` inside a `docker build` (issue
53
+ // #2174). An agent TOLD it has no egress can plan around it; an agent told docker works cannot.
54
+ //
55
+ // It runs as its own container rather than as one more command in the marker run, because the
56
+ // marker run is deliberately `--network none`. The two need opposite networking, so they cannot
57
+ // be the same `docker run`, and keeping them apart has a second payoff: a failure of anything
58
+ // below can only ever produce an EGRESS verdict, never a verdict about the daemon.
59
+ // ---------------------------------------------------------------------------
60
+ /**
61
+ * What the egress container prints for each observation: the marker, then the exit STATUS of the
62
+ * command that made it.
63
+ *
64
+ * The status rather than a pass/fail marker, because the two failures need different answers.
65
+ * A refused connection is evidence about the network; a 127 is busybox saying it has no such
66
+ * applet, which is evidence about the platform's own probe image and may never be reported as a
67
+ * network that is not there.
68
+ */
69
+ export const EGRESS_TCP_MARKER = 'cat-factory-egress-tcp=';
70
+ export const EGRESS_DNS_MARKER = 'cat-factory-egress-dns=';
71
+ /** How long the in-container connect may take. Short: a blocked route is silent, not slow. */
72
+ const EGRESS_CONNECT_TIMEOUT_SECONDS = 3;
73
+ /**
74
+ * How long busybox is given to print its own `nc` usage, for the capability check below. Bounded
75
+ * like everything else in that container: an applet that somehow blocks may not take the budget
76
+ * of the measurement it is only a preamble to.
77
+ */
78
+ const EGRESS_USAGE_TIMEOUT_SECONDS = 2;
79
+ /**
80
+ * How long the in-container lookup may take. Its own ceiling because busybox's `nslookup` retries
81
+ * on its own schedule, and an unbounded one would spend the whole check's budget on the half that
82
+ * is the diagnostic rather than the verdict.
83
+ */
84
+ const EGRESS_LOOKUP_TIMEOUT_SECONDS = 6;
85
+ /**
86
+ * How much of a rejected setting is quoted back. Enough to recognise which value was refused,
87
+ * short of letting a pasted blob be most of an agent's system prompt.
88
+ */
89
+ const SETTING_CHARS = 60;
90
+ /** An IPv4 literal. Names are refused on purpose: a target that needs DNS cannot TEST DNS. */
91
+ const IPV4 = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])$/;
92
+ /** A hostname, in the narrow shape a DNS lookup can be aimed at. */
93
+ const HOSTNAME = /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i;
94
+ /**
95
+ * Read the configured target, or say why it cannot be used.
96
+ *
97
+ * Validated rather than trusted, and strictly, for two reasons that both matter. The host and the
98
+ * name are interpolated into a `sh -c` script INSIDE the probe container, so anything else there
99
+ * would be running whatever a deployment's environment happened to hold; and a target that is
100
+ * quietly wrong produces a confident `blocked` about a daemon that is fine, which is the exact
101
+ * class of lie this whole module exists to remove. A rejected setting is REPORTED as a check that
102
+ * could not be carried out, never silently swapped for the default: an operator who pointed this
103
+ * at an address their network permits is entitled to find out that it was ignored.
104
+ */
105
+ export function parseEgressTarget(target, dnsName) {
106
+ const [host = '', port = '', ...rest] = target.split(':');
107
+ if (rest.length > 0 || !IPV4.test(host)) {
108
+ return {
109
+ invalid: `the platform's egress check is configured with \`${scrubbedExcerpt(target, SETTING_CHARS)}\`, which is not an \`IPv4:port\` address`,
110
+ };
111
+ }
112
+ const parsed = Number(port);
113
+ if (!/^[0-9]{1,5}$/.test(port) || parsed < 1 || parsed > 65535) {
114
+ return {
115
+ invalid: `the platform's egress check is configured with \`${scrubbedExcerpt(target, SETTING_CHARS)}\`, whose port is not a number between 1 and 65535`,
116
+ };
117
+ }
118
+ if (!HOSTNAME.test(dnsName)) {
119
+ return {
120
+ invalid: `the platform's egress check is configured to resolve \`${scrubbedExcerpt(dnsName, SETTING_CHARS)}\`, which is not a hostname`,
121
+ };
122
+ }
123
+ return { target: { host, port: parsed, dnsName } };
124
+ }
125
+ /**
126
+ * The argv the egress container runs: connect, say what that returned, resolve, say the same.
127
+ *
128
+ * Both observations are made and BOTH are reported, because they fail for different reasons and
129
+ * have different fixes. A connect to a raw address needs only a route; a lookup needs the
130
+ * daemon's embedded resolver to be reachable and to forward. Reporting only the first would call
131
+ * a container with a working route and broken DNS "reachable", and nothing an agent fetches by
132
+ * name would work there.
133
+ *
134
+ * Every applet is called by its full path (`/busybox nc`) rather than by name. The image holds
135
+ * one file and no PATH, and busybox's standalone-shell dispatch is a build-time option nothing
136
+ * here may assume.
137
+ *
138
+ * The connect is the half that is easy to get silently wrong, and `nc -w SEC` alone gets it
139
+ * wrong. busybox documents that flag as the timeout for connects AND FINAL NET READS: once stdin
140
+ * hits EOF `nc` half-closes and then waits to be spoken to, so a connect that SUCCEEDED to a peer
141
+ * which expects the client to speak first (every TLS port, the default `1.1.1.1:443` included)
142
+ * hits the alarm and exits non-zero. Read off the exit status alone that is indistinguishable
143
+ * from a refusal, so a working network reports a route that is not there. `-z` means "connect,
144
+ * then stop", which is the question being asked, so it is used wherever the payload's busybox was
145
+ * built with it; where it was not, the connect runs with no `-w`, which is the build whose `nc`
146
+ * exits on its own when stdin closes.
147
+ *
148
+ * Both halves are wrapped in `${busybox} timeout` either way, since a blackholed route is silent
149
+ * rather than refused and the applet's own ceiling is the thing this comment exists because we
150
+ * cannot assume.
151
+ */
152
+ export function buildEgressCommand(target) {
153
+ const busybox = `/${PROBE_BINARY_PATH}`;
154
+ const bounded = (seconds, command) => `${busybox} timeout ${seconds} ${command}`;
155
+ const where = `${target.host} ${target.port}`;
156
+ const connectSeconds = EGRESS_CONNECT_TIMEOUT_SECONDS + 1;
157
+ const connect = [
158
+ 'nc_z=no',
159
+ `case "$(${bounded(EGRESS_USAGE_TIMEOUT_SECONDS, `${busybox} nc`)} 2>&1)" in *-z*) nc_z=yes ;; esac`,
160
+ 'if [ "$nc_z" = yes ]; then',
161
+ ` ${bounded(connectSeconds, `${busybox} nc -w ${EGRESS_CONNECT_TIMEOUT_SECONDS} -z ${where}`)} >/dev/null 2>&1`,
162
+ 'else',
163
+ ` ${bounded(connectSeconds, `${busybox} nc ${where}`)} </dev/null >/dev/null 2>&1`,
164
+ 'fi',
165
+ ].join('\n');
166
+ const resolve = bounded(EGRESS_LOOKUP_TIMEOUT_SECONDS, `${busybox} nslookup ${target.dnsName}`);
167
+ return [
168
+ busybox,
169
+ 'sh',
170
+ '-c',
171
+ [
172
+ `${connect}\necho "${EGRESS_TCP_MARKER}$?"`,
173
+ `${resolve} >/dev/null 2>&1; echo "${EGRESS_DNS_MARKER}$?"`,
174
+ ].join('\n'),
175
+ ];
176
+ }
42
177
  /**
43
178
  * Node's architecture names mapped onto the docker name for the same machine.
44
179
  *
@@ -31,7 +31,20 @@ export type DockerSource =
31
31
  export interface DockerStatus {
32
32
  available: boolean | undefined;
33
33
  source: DockerSource;
34
- /** Why, in the entrypoint's own closed vocabulary (`serving`/`failed`/`missing`/…). */
34
+ /**
35
+ * Why, in the entrypoint's own closed vocabulary (`serving`, `serving-without-nat`,
36
+ * `still-starting`, `failed`, `missing`, `unreachable`, `probing`).
37
+ *
38
+ * Reported and never branched on, which is what lets the entrypoint add a word without anything
39
+ * here having to know it. Two are worth knowing about. `serving-without-nat`: the daemon that
40
+ * manages its own firewall rules exited without serving, so the one that came up runs with
41
+ * `--iptables=false` and its NESTED containers have no egress. That is a CAUSE, and the only
42
+ * place one exists; what MEASURES the consequence is the egress half of the workload check
43
+ * (docker-capability.ts), from inside a container, with no way to learn why. `still-starting`
44
+ * is a daemon that had not answered when the boot budget ran out and is STILL RUNNING, which is
45
+ * the one absence that routinely stops being true: it is exactly what the live re-probe below
46
+ * exists to catch.
47
+ */
35
48
  reason: string;
36
49
  /** A human detail for the failing cases: the dockerd log tail, or what was unreachable. */
37
50
  detail?: string;
@@ -1,5 +1,5 @@
1
1
  import { type Logger } from './logger.js';
2
- import { type DockerWorkload } from './docker-capability.js';
2
+ import { type ContainerEgress, type DockerWorkload } from './docker-capability.js';
3
3
  /**
4
4
  * What running one probe did, kept as RAW as the spawn: whether the binary ran, and with what.
5
5
  * Classification into presence lives in pure code below, because the two callers classify the
@@ -50,10 +50,16 @@ export type ToolPresence = {
50
50
  * - `serving`: it answered, and the workload check could not be carried out (no payload on
51
51
  * this machine, an unmapped architecture, a timeout). Neither of the other two,
52
52
  * and rendered as "try it if you need it".
53
+ *
54
+ * `usable` then carries what a nested container could REACH, which is a second fact and not a
55
+ * sixth state: what an agent may do with the daemon and what its containers can fetch are
56
+ * different questions, and each of the three egress answers changes the advice without changing
57
+ * the verdict on the daemon.
53
58
  */
54
59
  export type DockerCapability = {
55
60
  status: 'usable';
56
61
  server?: string;
62
+ egress: ContainerEgress;
57
63
  } | {
58
64
  status: 'unusable';
59
65
  server?: string;
@@ -2,7 +2,7 @@ import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { log } from './logger.js';
4
4
  import { harnessListenPort } from './harness-port.js';
5
- import { probeDockerWorkload } from './docker-capability.js';
5
+ import { probeDockerWorkload, } from './docker-capability.js';
6
6
  // ---------------------------------------------------------------------------
7
7
  // What this machine actually has, probed ONCE per job and stated to the agent.
8
8
  //
@@ -38,6 +38,12 @@ import { probeDockerWorkload } from './docker-capability.js';
38
38
  // all fail (issue #2120). Only a container that RAN settles that, so the reachable case is
39
39
  // split by a real workload (docker-capability.ts) into `usable`, `unusable`, and a daemon
40
40
  // that answered while the check itself could not be carried out.
41
+ // - And a daemon that runs containers is still not one whose containers have a NETWORK, which
42
+ // is the same mistake one level in again. Loading and running a local image needs no network,
43
+ // so a daemon started with `--iptables=false` passes the workload check while every nested
44
+ // container is cut off (issue #2174): what an agent then hits is a `docker build` whose
45
+ // `RUN npm ci` sits in retry backoff for about seven minutes before failing. So `usable`
46
+ // carries its own egress verdict, and the line below says something different for each.
41
47
  //
42
48
  // Deliberately NOT here: the agent's own tools (web search, file tools, MCP servers). Those are
43
49
  // the CLI's, they differ per harness, and each is already stated where it is true. Claiming one
@@ -289,7 +295,7 @@ async function probeDockerCapability(run, opts) {
289
295
  const server = daemon.version ? { server: daemon.version } : {};
290
296
  const workload = await (opts.workload ?? probeDockerWorkload)(opts.signal);
291
297
  if (workload.status === 'usable')
292
- return { status: 'usable', ...server };
298
+ return { status: 'usable', ...server, egress: workload.egress };
293
299
  if (workload.status === 'unusable')
294
300
  return { status: 'unusable', ...server, detail: workload.detail };
295
301
  return { status: 'serving', ...server, reason: workload.reason };
@@ -420,8 +426,7 @@ function dockerDaemonLine(daemon) {
420
426
  const server = 'server' in daemon && daemon.server ? ` (server ${daemon.server})` : '';
421
427
  switch (daemon.status) {
422
428
  case 'usable':
423
- return (`A Docker daemon is reachable${server} and the platform ran a container on it: ` +
424
- '`docker build`, `docker run` and `docker compose up` work here.');
429
+ return `A Docker daemon is reachable${server} and the platform ran a container on it: ${usableCommands(daemon.egress)} work here. ${egressSentence(daemon.egress)}`;
425
430
  case 'unusable':
426
431
  return (`A Docker daemon is reachable${server} but it CANNOT run a container: the platform ` +
427
432
  `built a one-layer image and tried to run it here, and that failed (${daemon.detail}). ` +
@@ -448,6 +453,70 @@ function dockerDaemonLine(daemon) {
448
453
  function unnamedCapability(daemon) {
449
454
  return `Whether a Docker daemon is reachable could not be determined (the platform reported an unrecognised verdict ${JSON.stringify(daemon)}): try it if you need it.`;
450
455
  }
456
+ /**
457
+ * Which commands the `usable` line may claim, which is the EGRESS verdict's business and not the
458
+ * daemon's.
459
+ *
460
+ * Split out because the line used to open with the full list and then, one sentence later, tell
461
+ * the agent that every `RUN` line which fetches anything fails. A block that also says not to
462
+ * spend turns re-checking it cannot afford to state and then retract the same fact: an agent
463
+ * reading the first sentence has already been told `docker build` works here, which is the exact
464
+ * shape of the lie issue #2174 is about. TOTAL over {@link ContainerEgress}, like its sibling.
465
+ */
466
+ function usableCommands(egress) {
467
+ switch (egress.status) {
468
+ case 'blocked':
469
+ // Deliberately omits `docker build`: it is the one the missing NAT rule actually breaks,
470
+ // and the sentence that follows explains which part of it and why.
471
+ return '`docker run` and `docker compose up` of images that are already built';
472
+ case 'reachable':
473
+ case 'undetermined':
474
+ return '`docker build`, `docker run` and `docker compose up`';
475
+ default:
476
+ return unnamedEgressCommands(egress);
477
+ }
478
+ }
479
+ /** The commands claimed for an egress verdict this build does not know: none of them. */
480
+ function unnamedEgressCommands(egress) {
481
+ return `the docker commands the platform could name for its verdict ${JSON.stringify(egress)}`;
482
+ }
483
+ /**
484
+ * The second half of the `usable` line: what a container started HERE can reach, and what to do
485
+ * about it. TOTAL over {@link ContainerEgress} for the same reason the line above is over the
486
+ * daemon's states.
487
+ *
488
+ * The `blocked` arm is the one this exists for, and it is precise about WHICH commands break,
489
+ * because "docker has no network" is not true and an agent that believed it would skip work it
490
+ * could have done. The daemon has a network: it pulls base images and compose pulls its services
491
+ * normally. What has none is the container each `RUN` line executes in. The seven minutes are
492
+ * named because the failure does not look like a failure from inside the agent's loop: npm turns
493
+ * "no route" into `EAI_AGAIN` only once its retry backoff gives up, so the build reads as a hang
494
+ * and the natural response is to wait longer.
495
+ */
496
+ function egressSentence(egress) {
497
+ switch (egress.status) {
498
+ case 'reachable':
499
+ return 'A container started here also reaches the network, so a build that installs dependencies works.';
500
+ case 'blocked':
501
+ return ('A container started here could reach NOTHING the platform tried, and that is a fact ' +
502
+ `about this sandbox rather than about your work (${egress.detail}). The daemon itself is ` +
503
+ 'fine: it pulls base images, and `docker compose up` of pre-built images works. What ' +
504
+ 'fails is every `RUN` line in a `docker build` that fetches from the public internet ' +
505
+ '(`npm ci`, `apk add`, `pip install`), and it fails SLOWLY: npm reports `EAI_AGAIN` only ' +
506
+ 'after some seven minutes of retry backoff, so it reads as a hang. Do not wait it out ' +
507
+ 'and do not retry. Vendor what you need, skip the image build, verify some other way, or ' +
508
+ 'say in one line that you could not verify it here. If this project already builds ' +
509
+ 'against a mirror inside this network, that is not one of the addresses tried above and ' +
510
+ 'is worth one attempt.');
511
+ case 'undetermined':
512
+ return `Whether a container started here can reach the network was NOT established (${egress.reason}), so try it if you need it and do not read a failure as a defect in the work.`;
513
+ default:
514
+ return unnamedEgress(egress);
515
+ }
516
+ }
517
+ function unnamedEgress(egress) {
518
+ return `Whether a container started here can reach the network could not be determined (the platform reported an unrecognised verdict ${JSON.stringify(egress)}).`;
519
+ }
451
520
  /**
452
521
  * Probe the machine and fold the inventory onto `systemPrompt`. THE composition point: the harness
453
522
  * calls this once per job, in `handleAgent`, before any mode branches, so every mode and every CLI
@@ -477,6 +546,12 @@ export async function appendEnvironmentInventory(systemPrompt, opts = {}) {
477
546
  .map((t) => t.name)
478
547
  .join(','),
479
548
  dockerDaemon: inventory.dockerDaemon.status,
549
+ // Present only when a nested container was actually asked, which is the one state that has
550
+ // an egress answer at all. A word here for every other state would report "no measurement"
551
+ // and "measured, and it cannot get out" in the same field.
552
+ ...(inventory.dockerDaemon.status === 'usable'
553
+ ? { dockerEgress: inventory.dockerDaemon.egress.status }
554
+ : {}),
480
555
  // The unknowns, by NAME: the block tells the agent a probe failed, and this is the only place
481
556
  // an operator can see WHICH, since the reason the agent reads is deliberately wordy prose.
482
557
  unknown: inventory.tools
@@ -62,6 +62,9 @@ export async function standUpInfra(dir, infra, signal, logger, probe = probeLive
62
62
  // boot record's own words for a daemon that answers and cannot run anything are `serving`
63
63
  // and nothing else, so a log line carrying the record alone describes the wrong failure.
64
64
  dockerWorkload: docker.workload?.status ?? 'unmeasured',
65
+ ...(docker.workload?.status === 'usable'
66
+ ? { dockerEgress: docker.workload.egress.status }
67
+ : {}),
65
68
  ...(docker.workload?.status === 'unusable' ? { dockerDetail: docker.workload.detail } : {}),
66
69
  });
67
70
  return {
@@ -140,10 +143,22 @@ export async function standUpInfra(dir, infra, signal, logger, probe = probeLive
140
143
  * container DID on it is `usable`, `unusable`, or a check that could not be carried out. Absent
141
144
  * when nothing was measured at all (an undecided boot record probes nothing), which is not the
142
145
  * same as a check that ran and could not tell.
146
+ *
147
+ * THREE fields rather than two, because `usable` is not one fact. A daemon running with
148
+ * `--iptables=false` runs containers perfectly and gives them no network, so it is `usable` and
149
+ * every `docker build` that fetches anything on it is guaranteed to fail. The agent's prompt and
150
+ * `GET /health` both learn that; without `dockerEgress` the record a human reads on the Tester
151
+ * step, and every backend consumer of it, sees the same undifferentiated `usable` as a sandbox
152
+ * where the stack actually works. Present only on `usable`, which is the one verdict that has an
153
+ * egress half at all: on any other, a word here would report "no measurement" and "measured, and
154
+ * it cannot get out" in the same field.
143
155
  */
144
156
  function workloadRecord(workload) {
145
157
  if (!workload)
146
158
  return {};
159
+ if (workload.status === 'usable') {
160
+ return { dockerWorkload: 'usable', dockerEgress: workload.egress.status };
161
+ }
147
162
  return { dockerWorkload: workload.status === 'unknown' ? 'undetermined' : workload.status };
148
163
  }
149
164
  /**
package/dist/job.d.ts CHANGED
@@ -566,6 +566,16 @@ export interface InfraSetupRecord {
566
566
  * ran and could not tell; ABSENT means nothing was measured at all.
567
567
  */
568
568
  dockerWorkload?: 'usable' | 'unusable' | 'undetermined';
569
+ /**
570
+ * What a container started ON that daemon could REACH, when the platform measured it.
571
+ *
572
+ * The fourth diagnosis, and the one `dockerWorkload: 'usable'` structurally cannot carry: a
573
+ * rootless daemon started with `--iptables=false` installs no MASQUERADE rule for its bridge,
574
+ * so it runs containers perfectly and none of them has a route out. The stack comes up and
575
+ * every `docker build` that fetches a dependency fails, slowly. Present only alongside
576
+ * `usable`, which is the one verdict with an egress half; absent means nothing measured it.
577
+ */
578
+ dockerEgress?: 'reachable' | 'blocked' | 'undetermined';
569
579
  /** The repo-relative compose file that was stood up. */
570
580
  composePath?: string;
571
581
  /** Epoch ms the stand-up attempt finished. */
package/dist/redact.d.ts CHANGED
@@ -11,6 +11,21 @@ export declare function registerKnownSecrets(values: readonly string[]): void;
11
11
  export declare function redact(input: string, knownSecrets?: readonly string[]): string;
12
12
  /** Pattern + registered-value redaction. Kept for callers without a per-call secret list. */
13
13
  export declare function redactSecrets(input: string): string;
14
+ /**
15
+ * A scrubbed, length-bounded excerpt of a string that is about to be QUOTED at a human or a
16
+ * model: a failing command's output, a rejected setting, a thrown message.
17
+ *
18
+ * One helper rather than a private `bounded()` per module, which is what this replaced. Two of
19
+ * them had drifted: `docker-probe-image.ts` echoed a rejected `HARNESS_DOCKER_EGRESS_TARGET`
20
+ * verbatim into a string that reaches every agent's system prompt and `GET /health`, while its
21
+ * same-named neighbour in `docker-capability.ts` scrubbed first. A proxy URL with an embedded
22
+ * token in that setting is the ordinary way that becomes a leak, and two helpers with one name
23
+ * in sibling files is what kept the divergence invisible.
24
+ *
25
+ * Scrub BEFORE bounding, so the cut cannot land inside a credential and leave half of it
26
+ * quotable, and mark a trimmed value so a reader never takes the head for the whole.
27
+ */
28
+ export declare function scrubbedExcerpt(text: string, maxChars: number): string;
14
29
  /** Cap on captured command output kept on an infra record (tail-biased — failures show last). */
15
30
  export declare const MAX_CAPTURED_OUTPUT_CHARS = 16000;
16
31
  /**
package/dist/redact.js CHANGED
@@ -65,6 +65,24 @@ export function redact(input, knownSecrets = []) {
65
65
  export function redactSecrets(input) {
66
66
  return redact(input);
67
67
  }
68
+ /**
69
+ * A scrubbed, length-bounded excerpt of a string that is about to be QUOTED at a human or a
70
+ * model: a failing command's output, a rejected setting, a thrown message.
71
+ *
72
+ * One helper rather than a private `bounded()` per module, which is what this replaced. Two of
73
+ * them had drifted: `docker-probe-image.ts` echoed a rejected `HARNESS_DOCKER_EGRESS_TARGET`
74
+ * verbatim into a string that reaches every agent's system prompt and `GET /health`, while its
75
+ * same-named neighbour in `docker-capability.ts` scrubbed first. A proxy URL with an embedded
76
+ * token in that setting is the ordinary way that becomes a leak, and two helpers with one name
77
+ * in sibling files is what kept the divergence invisible.
78
+ *
79
+ * Scrub BEFORE bounding, so the cut cannot land inside a credential and leave half of it
80
+ * quotable, and mark a trimmed value so a reader never takes the head for the whole.
81
+ */
82
+ export function scrubbedExcerpt(text, maxChars) {
83
+ const scrubbed = redactSecrets(text);
84
+ return scrubbed.length > maxChars ? `${scrubbed.slice(0, maxChars)}…` : scrubbed;
85
+ }
68
86
  /** Cap on captured command output kept on an infra record (tail-biased — failures show last). */
69
87
  export const MAX_CAPTURED_OUTPUT_CHARS = 16_000;
70
88
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.147.0",
3
+ "version": "1.149.0",
4
4
  "description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -25,10 +25,10 @@
25
25
  "access": "public"
26
26
  },
27
27
  "devDependencies": {
28
- "@cat-factory/contracts": "0.338.0",
29
- "@cat-factory/kernel": "0.327.0",
30
- "@cat-factory/server": "0.310.1",
31
- "@cat-factory/spend": "0.17.3",
28
+ "@cat-factory/contracts": "0.343.0",
29
+ "@cat-factory/kernel": "0.332.0",
30
+ "@cat-factory/server": "0.311.3",
31
+ "@cat-factory/spend": "0.17.8",
32
32
  "@hono/node-server": "^2.1.1",
33
33
  "@types/node": "^26.4.0",
34
34
  "hono": "^4.13.5",
package/src/agent.ts CHANGED
@@ -189,9 +189,12 @@ export async function handleAgent(job: AgentJob, opts: RunOptions = {}): Promise
189
189
  // single post-clone place to put this). The pass is sized for that: everything in it runs
190
190
  // concurrently, and every probe either answers in milliseconds or is bounded. Two of them are
191
191
  // deliberate waits rather than instant answers: one short retry for a daemon that is still
192
- // starting, and, only once a daemon has answered, the container the platform runs to find out
193
- // whether it can run one at all (`docker-capability.ts`, budgeted and memoised per container
194
- // for a positive). Both take the job's signal, so an abandoned run stops paying at once.
192
+ // starting, and, only once a daemon has answered, the CONTAINERS the platform runs to find out
193
+ // what this daemon can do (`docker-capability.ts`: one to prove it runs a container at all,
194
+ // then one on the default network to see what that container reaches). Both are budgeted, and
195
+ // the pair is memoised per container once it has SETTLED, which is any positive plus every
196
+ // negative that cannot change under a running container. Both take the job's signal, so an
197
+ // abandoned run stops paying at once.
195
198
 
196
199
  const staged: AgentJob = {
197
200
  ...job,