@cat-factory/executor-harness 1.102.0 → 1.104.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.
@@ -0,0 +1,71 @@
1
+ import type { ReferenceScreenshotsSpec } from './job.js';
2
+ import type { Logger } from './logger.js';
3
+ /** Subdirectory of {@link CONTEXT_DIR} the reference designs are written to. */
4
+ export declare const REFERENCE_SCREENSHOT_SUBDIR = "reference-screenshots";
5
+ /** What the pass has on disk, and what it does not. */
6
+ export interface ReferenceScreenshotOutcome {
7
+ written: {
8
+ fileName: string;
9
+ view: string;
10
+ }[];
11
+ /**
12
+ * One entry per reference that is NOT on disk, with the cause stated in `reason`. Covers both
13
+ * halves of that absence, because the agent's job is the same either way (capture the view under
14
+ * its own name, with nothing to compare against): a transfer that failed, and a view the cap
15
+ * dropped before this container was ever asked to fetch it.
16
+ */
17
+ missing: {
18
+ view: string;
19
+ reason: string;
20
+ }[];
21
+ /** Where the written files live, relative to the checkout root. */
22
+ dir: string;
23
+ }
24
+ /** The relative directory the references are written to (what the prompt points the agent at). */
25
+ export declare const REFERENCE_SCREENSHOT_DIR = ".cat-context/reference-screenshots";
26
+ /**
27
+ * Download the manifest's images into the checkout and report what landed.
28
+ *
29
+ * IDEMPOTENT, and that is load-bearing rather than an optimisation: an agent flow re-enters its
30
+ * workspace once per repair round, so this pass runs several times over one checkout. A file
31
+ * already on disk is counted and never re-fetched, which keeps a later round from spending the
32
+ * budget again AND from reporting a view as absent that pass 1 successfully delivered. A view that
33
+ * MISSED is retried, since the next round is a fresh chance at whatever was transiently down.
34
+ *
35
+ * Never throws: references are an aid to a comparison, not a precondition for running, so a
36
+ * backend outage degrades a UI run to "name your own views" (the documented fallback) rather than
37
+ * failing it. Every miss is carried out on {@link ReferenceScreenshotOutcome.missing} so the caller
38
+ * can say so in the prompt, which is the difference between a design the platform failed to hand
39
+ * over and one that has no such screen.
40
+ */
41
+ export declare function materializeReferenceScreenshots(cwd: string, spec: ReferenceScreenshotsSpec, options?: {
42
+ signal?: AbortSignal;
43
+ fetchImpl?: typeof fetch;
44
+ }): Promise<ReferenceScreenshotOutcome>;
45
+ /**
46
+ * The whole delivery in one call: download the manifest (when there is one) and answer the prompt
47
+ * block naming what landed, reporting any miss to the operator on the way.
48
+ *
49
+ * One entry point so an agent-running flow cannot end up doing half of it. A miss is stated to the
50
+ * AGENT in its prompt (it still has to capture that view) AND logged here, because a reference that
51
+ * never arrives is otherwise invisible in the run's output: the gallery simply pairs against
52
+ * nothing, months later, with no line anywhere saying why.
53
+ */
54
+ export declare function deliverReferenceScreenshots(cwd: string, spec: ReferenceScreenshotsSpec | undefined, options: {
55
+ signal?: AbortSignal;
56
+ log: Logger;
57
+ fetchImpl?: typeof fetch;
58
+ }): Promise<string>;
59
+ /**
60
+ * The prompt block naming what the agent was handed, or '' when the pass produced nothing at all.
61
+ *
62
+ * States the MISSES beside the files, because the whole point of writing this directory is that
63
+ * the tester captures the same views the gate will pair against: a reference that did not arrive
64
+ * is a view the agent should still capture (under that name) rather than one that does not exist.
65
+ *
66
+ * The "on disk" sentence is bound to the files that ARE on disk, and appears only with them. A
67
+ * block that asserts a populated directory when the pass wrote nothing (every transfer failed, or
68
+ * the directory could not be created at all) sends the agent looking for a path that may not even
69
+ * exist, and reads as a platform bug at exactly the moment the platform is already degraded.
70
+ */
71
+ export declare function referenceScreenshotGuidance(outcome: ReferenceScreenshotOutcome): string;
@@ -0,0 +1,261 @@
1
+ import { mkdir, stat, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { CONTEXT_DIR, excludeContextDir } from './pi.js';
4
+ // ---------------------------------------------------------------------------
5
+ // REFERENCE DESIGNS on disk: download the images the backend resolved for this task into
6
+ // `.cat-context/reference-screenshots/`, the directory the UI-tester prompt has always named and
7
+ // nothing wrote.
8
+ //
9
+ // The harness MATERIALISES and never decides: which artifact is the reference for which view, and
10
+ // what each file is called, are backend answers that ride the job body. What lives here is the
11
+ // transfer and its failure reporting: a reference the container could not fetch is NAMED to the
12
+ // agent rather than silently missing, because an absent file and a design that has no such screen
13
+ // look identical on disk.
14
+ // ---------------------------------------------------------------------------
15
+ /** Subdirectory of {@link CONTEXT_DIR} the reference designs are written to. */
16
+ export const REFERENCE_SCREENSHOT_SUBDIR = 'reference-screenshots';
17
+ /** Per-image ceiling, matching the platform's own upload ceiling (16 MiB). */
18
+ const MAX_REFERENCE_BYTES = 16 * 1024 * 1024;
19
+ /** Per-image request timeout. */
20
+ const REQUEST_TIMEOUT_MS = 20_000;
21
+ /**
22
+ * Wall-clock ceiling on the WHOLE pass.
23
+ *
24
+ * Downloading is activity-silent from the watchdog's point of view (no agent stream, no output),
25
+ * and `JOB_INACTIVITY_MS` (10 min) is what kills a job that stops producing. Rather than heartbeat
26
+ * a transfer that should take seconds, the pass is bounded far below that: a slow or wedged blob
27
+ * backend costs the run its references (stated to the agent) instead of costing it the run.
28
+ */
29
+ const TOTAL_BUDGET_MS = 90_000;
30
+ /** How many images are fetched at once. Small on purpose: this is a shared blob backend. */
31
+ const CONCURRENCY = 4;
32
+ /** The cause reported for a view the backend resolved but never sent this job a file for. */
33
+ const OMITTED_REASON = 'not sent to this container (reference limit)';
34
+ /** The relative directory the references are written to (what the prompt points the agent at). */
35
+ export const REFERENCE_SCREENSHOT_DIR = `${CONTEXT_DIR}/${REFERENCE_SCREENSHOT_SUBDIR}`;
36
+ /**
37
+ * Download the manifest's images into the checkout and report what landed.
38
+ *
39
+ * IDEMPOTENT, and that is load-bearing rather than an optimisation: an agent flow re-enters its
40
+ * workspace once per repair round, so this pass runs several times over one checkout. A file
41
+ * already on disk is counted and never re-fetched, which keeps a later round from spending the
42
+ * budget again AND from reporting a view as absent that pass 1 successfully delivered. A view that
43
+ * MISSED is retried, since the next round is a fresh chance at whatever was transiently down.
44
+ *
45
+ * Never throws: references are an aid to a comparison, not a precondition for running, so a
46
+ * backend outage degrades a UI run to "name your own views" (the documented fallback) rather than
47
+ * failing it. Every miss is carried out on {@link ReferenceScreenshotOutcome.missing} so the caller
48
+ * can say so in the prompt, which is the difference between a design the platform failed to hand
49
+ * over and one that has no such screen.
50
+ */
51
+ export async function materializeReferenceScreenshots(cwd, spec, options = {}) {
52
+ const dir = join(cwd, CONTEXT_DIR, REFERENCE_SCREENSHOT_SUBDIR);
53
+ const outcome = {
54
+ written: [],
55
+ // The backend's own dropped views are missing before a single byte is fetched, and for a cause
56
+ // no transfer could have changed.
57
+ missing: spec.omitted.map((view) => ({ view, reason: OMITTED_REASON })),
58
+ dir: REFERENCE_SCREENSHOT_DIR,
59
+ };
60
+ try {
61
+ await mkdir(dir, { recursive: true });
62
+ }
63
+ catch (error) {
64
+ // Nowhere to write: report every reference as missed rather than half of them, since none of
65
+ // them can land and the cause is the same for all.
66
+ for (const file of spec.files)
67
+ outcome.missing.push({ view: file.view, reason: describe(error) });
68
+ return sortByManifest(outcome, spec);
69
+ }
70
+ const deadline = Date.now() + TOTAL_BUDGET_MS;
71
+ const queue = [...spec.files];
72
+ const workers = Array.from({ length: Math.min(CONCURRENCY, queue.length) }, async () => {
73
+ for (;;) {
74
+ const file = queue.shift();
75
+ if (!file)
76
+ return;
77
+ // An earlier pass over this same checkout already delivered it. Checked before the budget so
78
+ // a fully-delivered set costs one stat per file and no network at all, however long an
79
+ // earlier round took.
80
+ if (await alreadyOnDisk(dir, file.fileName)) {
81
+ outcome.written.push({ fileName: file.fileName, view: file.view });
82
+ continue;
83
+ }
84
+ if (Date.now() >= deadline) {
85
+ outcome.missing.push({ view: file.view, reason: 'reference download budget exhausted' });
86
+ continue;
87
+ }
88
+ const failure = await downloadOne(dir, spec, file, options);
89
+ if (failure)
90
+ outcome.missing.push({ view: file.view, reason: failure });
91
+ else
92
+ outcome.written.push({ fileName: file.fileName, view: file.view });
93
+ }
94
+ });
95
+ await Promise.all(workers);
96
+ // Even a partial set must not reach the agent's PR (same rule as every other context file).
97
+ await excludeContextDir(cwd);
98
+ return sortByManifest(outcome, spec);
99
+ }
100
+ /**
101
+ * Order both lists the way the BACKEND composed the set (its own gallery order) rather than the
102
+ * order the transfers happened to finish in, so the list the agent reads is stable across rounds.
103
+ * The dropped views trail the sent ones, having no position in the manifest to sort by.
104
+ */
105
+ function sortByManifest(outcome, spec) {
106
+ const rank = new Map(spec.files.map((file, index) => [file.view, index]));
107
+ const at = (view) => rank.get(view) ?? Number.MAX_SAFE_INTEGER;
108
+ outcome.written.sort((a, b) => at(a.view) - at(b.view));
109
+ outcome.missing.sort((a, b) => at(a.view) - at(b.view));
110
+ return outcome;
111
+ }
112
+ /**
113
+ * Whether a previous pass over this checkout already wrote this reference.
114
+ *
115
+ * Non-empty is the test, not mere existence: a zero-length file is what a half-written transfer
116
+ * leaves behind, and treating it as delivered would hand the agent a blank image it reads as a
117
+ * design with nothing on the screen (the same case {@link downloadOne} refuses to write).
118
+ */
119
+ async function alreadyOnDisk(dir, fileName) {
120
+ try {
121
+ return (await stat(join(dir, fileName))).size > 0;
122
+ }
123
+ catch {
124
+ // silent-catch-ok: absence is the ordinary answer here (first pass over the checkout), and any
125
+ // other stat failure is answered the same way — by attempting the download.
126
+ return false;
127
+ }
128
+ }
129
+ /**
130
+ * The whole delivery in one call: download the manifest (when there is one) and answer the prompt
131
+ * block naming what landed, reporting any miss to the operator on the way.
132
+ *
133
+ * One entry point so an agent-running flow cannot end up doing half of it. A miss is stated to the
134
+ * AGENT in its prompt (it still has to capture that view) AND logged here, because a reference that
135
+ * never arrives is otherwise invisible in the run's output: the gallery simply pairs against
136
+ * nothing, months later, with no line anywhere saying why.
137
+ */
138
+ export async function deliverReferenceScreenshots(cwd, spec, options) {
139
+ if (!spec)
140
+ return '';
141
+ const outcome = await materializeReferenceScreenshots(cwd, spec, options);
142
+ if (outcome.missing.length) {
143
+ options.log.warn('agent: some reference designs are not on disk', {
144
+ written: outcome.written.length,
145
+ missing: outcome.missing.length,
146
+ reasons: outcome.missing.map((file) => file.reason).slice(0, 5),
147
+ });
148
+ }
149
+ return referenceScreenshotGuidance(outcome);
150
+ }
151
+ /** Fetch and write one reference, answering a failure reason or undefined on success. */
152
+ async function downloadOne(dir, spec, file, options) {
153
+ const fetchImpl = options.fetchImpl ?? fetch;
154
+ const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
155
+ const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
156
+ try {
157
+ const response = await fetchImpl(`${spec.url}/${encodeURIComponent(file.artifactId)}`, {
158
+ headers: { authorization: `Bearer ${spec.token}` },
159
+ signal,
160
+ });
161
+ if (!response.ok)
162
+ return `HTTP ${response.status}`;
163
+ const bytes = await readBounded(response, MAX_REFERENCE_BYTES);
164
+ if (bytes === 'too-large')
165
+ return 'reference exceeds size limit';
166
+ // A zero-length body is a miss, not a file: written out it would be an image the agent opens,
167
+ // finds empty, and reads as a design with nothing on the screen.
168
+ if (!bytes.byteLength)
169
+ return 'empty response';
170
+ await writeFile(join(dir, file.fileName), bytes);
171
+ return undefined;
172
+ }
173
+ catch (error) {
174
+ return describe(error);
175
+ }
176
+ }
177
+ /**
178
+ * Read a response body, refusing one that goes past `limit` WITHOUT buffering all of it first.
179
+ *
180
+ * The ceiling has to bound the transfer and not just the write. Buffering the whole body and then
181
+ * measuring it means an oversized (or endless) response is already resident, times the pass's
182
+ * concurrency, by the time it is rejected — which is the container's memory, in a run whose whole
183
+ * point is that it has not started working yet. So the declared length is refused up front where
184
+ * it is honest, and the stream is counted as it arrives and cancelled the moment it crosses the
185
+ * line, which is what makes a chunked or lying body cost no more than a truthful one.
186
+ */
187
+ async function readBounded(response, limit) {
188
+ const declared = Number(response.headers.get('content-length'));
189
+ if (Number.isFinite(declared) && declared > limit)
190
+ return 'too-large';
191
+ const body = response.body;
192
+ if (!body) {
193
+ // No stream to count (a mocked or already-buffered response): fall back to measuring after the
194
+ // fact, which is sound because there is nothing left to stop arriving.
195
+ const bytes = new Uint8Array(await response.arrayBuffer());
196
+ return bytes.byteLength > limit ? 'too-large' : bytes;
197
+ }
198
+ const reader = body.getReader();
199
+ const chunks = [];
200
+ let total = 0;
201
+ try {
202
+ for (;;) {
203
+ const { done, value } = await reader.read();
204
+ if (done)
205
+ break;
206
+ total += value.byteLength;
207
+ if (total > limit) {
208
+ await reader.cancel();
209
+ return 'too-large';
210
+ }
211
+ chunks.push(value);
212
+ }
213
+ }
214
+ finally {
215
+ reader.releaseLock();
216
+ }
217
+ const bytes = new Uint8Array(total);
218
+ let offset = 0;
219
+ for (const chunk of chunks) {
220
+ bytes.set(chunk, offset);
221
+ offset += chunk.byteLength;
222
+ }
223
+ return bytes;
224
+ }
225
+ /** A one-line cause for a failed transfer (never the token, which only rides a header). */
226
+ function describe(error) {
227
+ return error instanceof Error ? error.message : String(error);
228
+ }
229
+ /**
230
+ * The prompt block naming what the agent was handed, or '' when the pass produced nothing at all.
231
+ *
232
+ * States the MISSES beside the files, because the whole point of writing this directory is that
233
+ * the tester captures the same views the gate will pair against: a reference that did not arrive
234
+ * is a view the agent should still capture (under that name) rather than one that does not exist.
235
+ *
236
+ * The "on disk" sentence is bound to the files that ARE on disk, and appears only with them. A
237
+ * block that asserts a populated directory when the pass wrote nothing (every transfer failed, or
238
+ * the directory could not be created at all) sends the agent looking for a path that may not even
239
+ * exist, and reads as a platform bug at exactly the moment the platform is already degraded.
240
+ */
241
+ export function referenceScreenshotGuidance(outcome) {
242
+ if (!outcome.written.length && !outcome.missing.length)
243
+ return '';
244
+ const onDisk = outcome.written.length
245
+ ? `\n\nThese are on disk, one file per view:\n${outcome.written
246
+ .map((file) => `- \`${outcome.dir}/${file.fileName}\`: ${file.view}`)
247
+ .join('\n')}`
248
+ : '';
249
+ const absent = outcome.missing.length
250
+ ? `\n\nThese views have NO reference image in this container, for the reason given. Capture them
251
+ anyway, under exactly these names. There is simply nothing here to compare against:\n${outcome.missing
252
+ .map((file) => `- ${file.view}: NOT on disk (${file.reason})`)
253
+ .join('\n')}`
254
+ : '';
255
+ return `
256
+
257
+ ## Reference designs (capture these views)
258
+ Capture the views named below and name each screenshot's \`view\` EXACTLY as given, so the platform
259
+ can pair your capture with its reference. Capture any other view the task needs under a name of
260
+ your own.${onDisk}${absent}`;
261
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.102.0",
3
+ "version": "1.104.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",
@@ -30,9 +30,9 @@
30
30
  "hono": "^4.13.0",
31
31
  "typescript": "7.0.2",
32
32
  "vitest": "^4.1.10",
33
- "@cat-factory/kernel": "0.273.0",
34
- "@cat-factory/server": "0.253.0",
35
- "@cat-factory/spend": "0.15.41"
33
+ "@cat-factory/kernel": "0.275.0",
34
+ "@cat-factory/server": "0.255.0",
35
+ "@cat-factory/spend": "0.15.43"
36
36
  },
37
37
  "scripts": {
38
38
  "build": "tsc -p tsconfig.json",
@@ -1,4 +1,10 @@
1
- import type { AgentJob, AgentResult, McpServerSpec, SkillSpec } from './job.js'
1
+ import type {
2
+ AgentJob,
3
+ AgentResult,
4
+ McpServerSpec,
5
+ ReferenceScreenshotsSpec,
6
+ SkillSpec,
7
+ } from './job.js'
2
8
  import type { EffortReport } from './effort.js'
3
9
 
4
10
  // Small helpers shared by every agent MODE (explore / coding / bootstrap / preview). They live
@@ -18,17 +24,20 @@ export function mergeEffort(
18
24
  }
19
25
 
20
26
  /**
21
- * The agent-capability fields (skills + tool servers) every agent-running flow forwards to
22
- * {@link runAgentInWorkspace}. One helper rather than a per-flow spread, so a flow cannot silently
23
- * be the one that drops a kind's declared playbook or tool server the failure mode is invisible
24
- * (the agent simply works without it) and would only show up as degraded output.
27
+ * The agent-capability fields (skills, tool servers, reference designs) every agent-running flow
28
+ * forwards to {@link runAgentInWorkspace}. One helper rather than a per-flow spread, so a flow
29
+ * cannot silently be the one that drops a kind's declared playbook, tool server or reference
30
+ * gallery: the failure mode is invisible (the agent simply works without it) and would only show
31
+ * up as degraded output.
25
32
  */
26
33
  export function agentCapabilities(job: AgentJob): {
27
34
  skills?: SkillSpec[]
28
35
  mcpServers?: McpServerSpec[]
36
+ referenceScreenshots?: ReferenceScreenshotsSpec
29
37
  } {
30
38
  return {
31
39
  ...(job.skills?.length ? { skills: job.skills } : {}),
32
40
  ...(job.mcpServers?.length ? { mcpServers: job.mcpServers } : {}),
41
+ ...(job.referenceScreenshots ? { referenceScreenshots: job.referenceScreenshots } : {}),
33
42
  }
34
43
  }
@@ -8,6 +8,7 @@ import type {
8
8
  HarnessAuthFields,
9
9
  PeerRepoSpec,
10
10
  ReferenceRepoSpec,
11
+ ReferenceScreenshotsSpec,
11
12
  RepoSpec,
12
13
  SkillSpec,
13
14
  McpServerSpec,
@@ -180,6 +181,14 @@ export interface CodingAgentSpec extends HarnessAuthFields {
180
181
  * has already dropped anything this harness cannot serve. Absent ⇒ built-in tools only.
181
182
  */
182
183
  mcpServers?: McpServerSpec[]
184
+ /**
185
+ * The task's reference design images, downloaded into `.cat-context/reference-screenshots/`
186
+ * before the agent's first turn. Carried on the coding path as well as the explore one because
187
+ * what earns a run its references is the KIND's declared `ui` image, and a deployment's own
188
+ * UI-facing kind may well be a coding one, and nothing here switches on which built-in it is.
189
+ * Absent ⇒ none (the normal case).
190
+ */
191
+ referenceScreenshots?: ReferenceScreenshotsSpec
183
192
  }
184
193
 
185
194
  /** The outcome of a coding agent run, before each caller maps it to its own result shape. */
@@ -458,6 +467,9 @@ export async function runCodingAgent(
458
467
  guardLimits: spec.guardLimits,
459
468
  ...(spec.skills?.length ? { skills: spec.skills } : {}),
460
469
  ...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
470
+ ...(spec.referenceScreenshots
471
+ ? { referenceScreenshots: spec.referenceScreenshots }
472
+ : {}),
461
473
  },
462
474
  opts,
463
475
  )
@@ -1190,6 +1202,7 @@ export async function runMultiRepoCoding(
1190
1202
  // are properties of the AGENT KIND, not of the checkout layout.
1191
1203
  ...(job.skills?.length ? { skills: job.skills } : {}),
1192
1204
  ...(job.mcpServers?.length ? { mcpServers: job.mcpServers } : {}),
1205
+ ...(job.referenceScreenshots ? { referenceScreenshots: job.referenceScreenshots } : {}),
1193
1206
  multiRepo: true,
1194
1207
  },
1195
1208
  opts,
@@ -0,0 +1,156 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The two manifests of FILES the backend stages into a checkout before the agent's first turn:
3
+ // the linked-context documents it materialises under CONTEXT_DIR, and the reference design images
4
+ // it has the harness download beside them.
5
+ //
6
+ // One module because they are the same kind of thing parsed the same defensive way, and because
7
+ // they share the basename rule below: both name files the container writes into a directory it
8
+ // then points the agent at, so both are held to a value that cannot escape it or clobber a repo
9
+ // file. Split out of `job.ts`, which parses everything else a job body carries.
10
+ // ---------------------------------------------------------------------------
11
+
12
+ /**
13
+ * A linked-context file the backend prepared (requirements / RFC / PRD / tracker issue)
14
+ * for the harness to materialise under CONTEXT_DIR in the checkout, so the agent can read
15
+ * it on demand. The harness can't reach Jira/GitHub itself, so all such context is fetched
16
+ * and shipped here up front. `path` is sanitised to a safe basename on parse.
17
+ */
18
+ export interface ContextFileSpec {
19
+ path: string
20
+ title: string
21
+ url: string
22
+ content: string
23
+ }
24
+
25
+ /**
26
+ * The REFERENCE DESIGN IMAGES the backend holds for this task, for the harness to download into
27
+ * `.cat-context/reference-screenshots/` before the agent runs: the directory a UI tester's prompt
28
+ * names and, until now, nothing wrote.
29
+ *
30
+ * A manifest rather than the bytes: a design frame is a full-page PNG, and a job body is JSON
31
+ * that crosses every transport and is persisted with the dispatch. The bytes come back over the
32
+ * SAME container session token the run already holds (`GET ${url}/<artifactId>`), so this needs
33
+ * no extra credential and no publicly reachable URL.
34
+ *
35
+ * `view` is what the backend's gate pairs on, and `fileName` is the name the BACKEND chose for it,
36
+ * never derived here. The file name is how the agent learns the view name, so deriving it in the
37
+ * container would let a harness image the deployment has not rolled out yet rename every view a
38
+ * run reports, and the pairing would come apart with nothing failing.
39
+ */
40
+ export interface ReferenceScreenshotsSpec {
41
+ /** Base URL of the reference download route; the artifact id is appended as a path segment. */
42
+ url: string
43
+ /** The run's container session token (the same one the LLM proxy is called with). */
44
+ token: string
45
+ files: ReferenceScreenshotSpec[]
46
+ /**
47
+ * View names the task holds a reference for that this job was NOT sent a file for, because the
48
+ * set was capped. Stated to the agent beside the transfers that failed: from where it stands
49
+ * both are a view to capture with no image to compare against.
50
+ *
51
+ * Two producers, and they mean the same thing here: the BACKEND's own ceiling (the number that
52
+ * should ever actually bind, chosen where the precedence between an upload and a design frame
53
+ * is known), and this parser's backstop against a body claiming more files than any real set
54
+ * has. A drop with no entry here is the bug this field exists to prevent.
55
+ */
56
+ omitted: string[]
57
+ }
58
+
59
+ /** One reference image in a {@link ReferenceScreenshotsSpec}. `fileName` is sanitised on parse. */
60
+ export interface ReferenceScreenshotSpec {
61
+ artifactId: string
62
+ fileName: string
63
+ view: string
64
+ }
65
+
66
+ /**
67
+ * Sanitise a body-supplied context filename to a safe basename within CONTEXT_DIR:
68
+ * strip any directory part, allow only `[A-Za-z0-9._-]`, and reject empties / dotfiles
69
+ * / `..` so a hostile value can't escape the directory or clobber repo files.
70
+ */
71
+ export function sanitizeContextFileName(value: unknown): string | undefined {
72
+ if (typeof value !== 'string') return undefined
73
+ const base = value.replace(/\\/g, '/').split('/').pop() ?? ''
74
+ const cleaned = base.replace(/[^A-Za-z0-9._-]/g, '')
75
+ if (!cleaned || cleaned === '.' || cleaned === '..' || cleaned.startsWith('.')) return undefined
76
+ return cleaned
77
+ }
78
+
79
+ /** Parse the linked-context files, dropping any malformed/unsafe entry. */
80
+ export function parseContextFiles(value: unknown): ContextFileSpec[] {
81
+ if (!Array.isArray(value)) return []
82
+ const files: ContextFileSpec[] = []
83
+ const used = new Set<string>()
84
+ for (const entry of value) {
85
+ if (typeof entry !== 'object' || entry === null) continue
86
+ const e = entry as Record<string, unknown>
87
+ const path = sanitizeContextFileName(e.path)
88
+ if (!path || used.has(path)) continue
89
+ if (typeof e.content !== 'string') continue
90
+ used.add(path)
91
+ files.push({
92
+ path,
93
+ title: typeof e.title === 'string' ? e.title : path,
94
+ url: typeof e.url === 'string' ? e.url : '',
95
+ content: e.content,
96
+ })
97
+ }
98
+ return files
99
+ }
100
+
101
+ /**
102
+ * How many reference images one job may be handed. The backend caps the set it sends well below
103
+ * this, so this is the harness's own backstop against a malformed or hostile body turning the
104
+ * pre-run setup into an unbounded download, never the ceiling a real run meets.
105
+ *
106
+ * Hitting it is REPORTED rather than silently obeyed: an entry past the ceiling is dropped from
107
+ * `files` and its view named on `omitted`, so an agent facing a truncated set is still told which
108
+ * views to capture. A cap that shortened the list and said nothing would be indistinguishable, on
109
+ * disk and in the prompt, from a design that simply has no such screen.
110
+ */
111
+ const MAX_REFERENCE_SCREENSHOTS = 40
112
+
113
+ /**
114
+ * Parse the reference-design manifest, or undefined when absent/unusable.
115
+ *
116
+ * The whole manifest is dropped when its transport half is unusable (no absolute http(s) URL, no
117
+ * token): every file would fail the same way, and one stated cause beats N identical ones. An
118
+ * individual entry is dropped only when it cannot name a file safely: the same basename
119
+ * sanitisation every context file gets, so a hostile `fileName` can neither escape the directory
120
+ * nor clobber a repo file.
121
+ */
122
+ export function parseReferenceScreenshots(value: unknown): ReferenceScreenshotsSpec | undefined {
123
+ if (typeof value !== 'object' || value === null) return undefined
124
+ const o = value as Record<string, unknown>
125
+ const url = typeof o.url === 'string' ? o.url.trim() : ''
126
+ const token = typeof o.token === 'string' ? o.token : ''
127
+ if (!url || !token || !/^https?:\/\//i.test(url)) return undefined
128
+ if (!Array.isArray(o.files)) return undefined
129
+ const files: ReferenceScreenshotSpec[] = []
130
+ // The backend's own dropped views come first; anything this parser drops joins them below.
131
+ const omitted = Array.isArray(o.omitted)
132
+ ? o.omitted.filter((view): view is string => typeof view === 'string' && view.length > 0)
133
+ : []
134
+ const used = new Set<string>()
135
+ for (const entry of o.files) {
136
+ if (typeof entry !== 'object' || entry === null) continue
137
+ const e = entry as Record<string, unknown>
138
+ const fileName = sanitizeContextFileName(e.fileName)
139
+ const artifactId = typeof e.artifactId === 'string' ? e.artifactId.trim() : ''
140
+ // The id becomes a path segment on the download URL, so it is held to the shape the platform
141
+ // mints rather than encoded and hoped for: anything else cannot be a real artifact anyway.
142
+ if (!fileName || used.has(fileName) || !/^[A-Za-z0-9_-]{1,64}$/.test(artifactId)) continue
143
+ const view = typeof e.view === 'string' ? e.view : fileName
144
+ // Past the backstop the entry is NAMED, not dropped: it stays a view the agent must capture.
145
+ // Checked here rather than at the top of the loop so a malformed entry is refused on its own
146
+ // terms (it names no usable view to report) instead of being counted against the ceiling.
147
+ if (files.length >= MAX_REFERENCE_SCREENSHOTS) {
148
+ omitted.push(view)
149
+ continue
150
+ }
151
+ used.add(fileName)
152
+ files.push({ artifactId, fileName, view })
153
+ }
154
+ if (!files.length && !omitted.length) return undefined
155
+ return { url: url.replace(/\/+$/, ''), token, files, omitted }
156
+ }