@ryanfw/prompt-orchestration-pipeline 1.3.0 → 1.3.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.
Files changed (50) hide show
  1. package/README.md +1 -0
  2. package/docs/pop-task-guide.md +45 -0
  3. package/package.json +3 -3
  4. package/src/core/__tests__/agent-step.test.ts +402 -35
  5. package/src/core/__tests__/task-runner.test.ts +104 -0
  6. package/src/core/agent-step.ts +295 -41
  7. package/src/core/agent-types.ts +61 -0
  8. package/src/core/file-io.ts +8 -74
  9. package/src/core/orchestrator.ts +2 -1
  10. package/src/core/pipeline-definition.ts +1 -1
  11. package/src/core/pipeline-runner.ts +54 -3
  12. package/src/core/status-writer.ts +44 -26
  13. package/src/core/task-runner.ts +44 -1
  14. package/src/core/validation.ts +1 -1
  15. package/src/harness/__tests__/discovery.test.ts +235 -0
  16. package/src/harness/discovery.ts +109 -0
  17. package/src/harness/index.ts +22 -0
  18. package/src/harness/mcp-io-server.ts +1 -1
  19. package/src/ui/client/hooks/useJobListWithUpdates.ts +16 -1
  20. package/src/ui/dist/assets/{index-D7hzshSS.js → index--RH3sAt3.js} +129 -1
  21. package/src/ui/dist/assets/index--RH3sAt3.js.map +1 -0
  22. package/src/ui/dist/assets/style-CSSKuMOe.css +2 -0
  23. package/src/ui/dist/index.html +2 -2
  24. package/src/ui/embedded-assets.js +6 -6
  25. package/src/ui/pages/Code.tsx +135 -0
  26. package/src/ui/server/__tests__/gate-endpoints.test.ts +90 -0
  27. package/src/ui/server/__tests__/job-control-endpoints.test.ts +188 -0
  28. package/src/ui/server/__tests__/path-containment.test.ts +54 -0
  29. package/src/ui/server/__tests__/status-corruption.test.ts +55 -0
  30. package/src/ui/server/config-bridge.ts +1 -0
  31. package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +77 -0
  32. package/src/ui/server/endpoints/gate-endpoints.ts +17 -1
  33. package/src/ui/server/endpoints/job-control-endpoints.ts +36 -2
  34. package/src/ui/server/endpoints/upload-endpoints.ts +13 -3
  35. package/src/ui/server/utils/http-utils.ts +6 -1
  36. package/src/ui/server/utils/path-containment.ts +18 -0
  37. package/src/ui/server/utils/status-corruption.ts +27 -0
  38. package/src/harness/__tests__/descriptors.test.ts +0 -378
  39. package/src/harness/__tests__/executor.test.ts +0 -193
  40. package/src/harness/__tests__/resolve.test.ts +0 -200
  41. package/src/harness/__tests__/types.test.ts +0 -297
  42. package/src/harness/descriptors/claude.ts +0 -132
  43. package/src/harness/descriptors/codex.ts +0 -126
  44. package/src/harness/descriptors/index.ts +0 -10
  45. package/src/harness/descriptors/opencode.ts +0 -147
  46. package/src/harness/executor.ts +0 -128
  47. package/src/harness/resolve.ts +0 -176
  48. package/src/harness/types.ts +0 -100
  49. package/src/ui/dist/assets/index-D7hzshSS.js.map +0 -1
  50. package/src/ui/dist/assets/style-BUFg3Sth.css +0 -2
@@ -99,4 +99,81 @@ describe("handleSeedUploadDirect", () => {
99
99
  await rm(dataDir, { recursive: true, force: true });
100
100
  }
101
101
  });
102
+
103
+ it("AC-12: unsafe artifact path returns 400 and does not create the escape target", async () => {
104
+ const dataDir = await mkdtemp(path.join(tmpdir(), "upload-unsafe-"));
105
+ try {
106
+ const seedObject = { name: "demo", pipeline: "x" };
107
+ const response = await handleSeedUploadDirect(seedObject, dataDir, [
108
+ { filename: "../../../../tmp/PWNED.txt", content: new TextEncoder().encode("pwned") },
109
+ ]);
110
+ expect(response.status).toBe(400);
111
+
112
+ const escapeTarget = "/tmp/PWNED.txt";
113
+ expect(await pathExists(escapeTarget)).toBe(false);
114
+ } finally {
115
+ await rm(dataDir, { recursive: true, force: true });
116
+ }
117
+ });
118
+
119
+ it("AC-13: mixed safe and unsafe paths return 400 with no partial writes", async () => {
120
+ const dataDir = await mkdtemp(path.join(tmpdir(), "upload-mixed-"));
121
+ try {
122
+ const seedObject = { name: "demo", pipeline: "x" };
123
+ const artifacts = [
124
+ { filename: "safe.md", content: new TextEncoder().encode("safe") },
125
+ { filename: "../../tmp/escape.txt", content: new TextEncoder().encode("escape") },
126
+ ];
127
+
128
+ const response = await handleSeedUploadDirect(seedObject, dataDir, artifacts);
129
+ expect(response.status).toBe(400);
130
+
131
+ const stagingRoot = path.join(dataDir, "pipeline-data", "staging");
132
+ expect(await pathExists(stagingRoot)).toBe(false);
133
+ } finally {
134
+ await rm(dataDir, { recursive: true, force: true });
135
+ }
136
+ });
137
+
138
+ it("rejects root-normalizing artifact paths without partial writes", async () => {
139
+ const dataDir = await mkdtemp(path.join(tmpdir(), "upload-root-target-"));
140
+ try {
141
+ const seedObject = { name: "demo", pipeline: "x" };
142
+ const artifacts = [
143
+ { filename: "safe.md", content: new TextEncoder().encode("safe") },
144
+ { filename: "dir/..", content: new TextEncoder().encode("root") },
145
+ ];
146
+
147
+ const response = await handleSeedUploadDirect(seedObject, dataDir, artifacts);
148
+ expect(response.status).toBe(400);
149
+
150
+ const stagingRoot = path.join(dataDir, "pipeline-data", "staging");
151
+ expect(await pathExists(stagingRoot)).toBe(false);
152
+ } finally {
153
+ await rm(dataDir, { recursive: true, force: true });
154
+ }
155
+ });
156
+
157
+ it("AC-14: all-safe artifact paths with nesting return 201 and files present", async () => {
158
+ const dataDir = await mkdtemp(path.join(tmpdir(), "upload-safe-"));
159
+ try {
160
+ const seedObject = { name: "demo", pipeline: "x" };
161
+ const artifacts = [
162
+ { filename: "out.json", content: new TextEncoder().encode("{}") },
163
+ { filename: "data/foo.json", content: new TextEncoder().encode('{"a":1}') },
164
+ ];
165
+
166
+ const response = await handleSeedUploadDirect(seedObject, dataDir, artifacts);
167
+ expect(response.status).toBe(201);
168
+
169
+ const body = (await response.json()) as { data: { jobId: string } };
170
+ const jobId = body.data.jobId;
171
+ const stagingJobDir = path.join(dataDir, "pipeline-data", "staging", jobId);
172
+
173
+ expect(await pathExists(path.join(stagingJobDir, "out.json"))).toBe(true);
174
+ expect(await pathExists(path.join(stagingJobDir, "data/foo.json"))).toBe(true);
175
+ } finally {
176
+ await rm(dataDir, { recursive: true, force: true });
177
+ }
178
+ });
102
179
  });
@@ -7,7 +7,8 @@ import { getJobDirectoryPath, getPipelineDataDir } from "../../../config/paths";
7
7
  import { getOrchestratorConfig } from "../../../core/config";
8
8
  import { releaseJobSlot } from "../../../core/job-concurrency";
9
9
  import { appendRunEvent } from "../../../core/run-events";
10
- import { readJobStatus, writeJobStatus } from "../../../core/status-writer";
10
+ import { readJobStatus, writeJobStatus, StatusCorruptError } from "../../../core/status-writer";
11
+ import { assertStatusParseable, statusCorruptResponse } from "../utils/status-corruption";
11
12
  import {
12
13
  acquireConcurrencySlot,
13
14
  isProcessAlive,
@@ -58,6 +59,13 @@ export async function handleGateDecision(
58
59
  return sendJson(404, createErrorResponse(Constants.ERROR_CODES.JOB_NOT_FOUND, `job "${jobId}" was not found`));
59
60
  }
60
61
 
62
+ try {
63
+ await assertStatusParseable(statusPath);
64
+ } catch (err) {
65
+ if (err instanceof StatusCorruptError) return statusCorruptResponse(err);
66
+ throw err;
67
+ }
68
+
61
69
  const snapshot = await readJobStatus(jobDir);
62
70
  if (!snapshot) {
63
71
  return sendJson(500, createErrorResponse("status_unavailable", `job "${jobId}" status could not be read`));
@@ -108,6 +116,14 @@ export async function handleGateDecision(
108
116
  slotAcquired = false;
109
117
  }
110
118
  } catch (error) {
119
+ if (error instanceof StatusCorruptError) {
120
+ if (slotAcquired) {
121
+ const orchestrator = getOrchestratorConfig();
122
+ await releaseJobSlot(getPipelineDataDir(dataDir), jobId, orchestrator.lockFileTimeout);
123
+ slotAcquired = false;
124
+ }
125
+ return statusCorruptResponse(error);
126
+ }
111
127
  if (slotAcquired) {
112
128
  const orchestrator = getOrchestratorConfig();
113
129
  await releaseJobSlot(getPipelineDataDir(dataDir), jobId, orchestrator.lockFileTimeout);
@@ -19,8 +19,10 @@ import {
19
19
  resetJobToCleanSlate,
20
20
  resetSingleTask,
21
21
  writeJobStatus,
22
+ StatusCorruptError,
22
23
  type StatusSnapshot,
23
24
  } from "../../../core/status-writer";
25
+ import { assertKnownJobStatusesParseable, statusCorruptResponse } from "../utils/status-corruption";
24
26
  import {
25
27
  materializeNormalizedPipelineDefinition,
26
28
  } from "../../../core/pipeline-definition";
@@ -454,6 +456,18 @@ export async function resolveJobLifecycle(
454
456
  return null;
455
457
  }
456
458
 
459
+ async function resolveJobLifecycleByStatusPresence(
460
+ dataDir: string,
461
+ jobId: string,
462
+ ): Promise<"current" | "complete" | null> {
463
+ for (const location of READ_LOCATIONS) {
464
+ const statusPath = path.join(dataDir, "pipeline-data", location, jobId, "tasks-status.json");
465
+ if (await Bun.file(statusPath).exists()) return location;
466
+ }
467
+
468
+ return null;
469
+ }
470
+
457
471
  export function isRestartInProgress(jobId: string): boolean {
458
472
  return restartingJobs.has(jobId);
459
473
  }
@@ -500,6 +514,9 @@ export async function handleJobRestart(
500
514
  }
501
515
 
502
516
  try {
517
+ try { await assertKnownJobStatusesParseable(dataDir, jobId); }
518
+ catch (err) { if (err instanceof StatusCorruptError) return statusCorruptResponse(err); throw err; }
519
+
503
520
  // Parse optional request body
504
521
  let fromTask: string | undefined;
505
522
  let singleTask: boolean | undefined;
@@ -579,6 +596,12 @@ export async function handleJobRestart(
579
596
  });
580
597
  spawned = true;
581
598
  } catch (err) {
599
+ if (err instanceof StatusCorruptError) {
600
+ if (!spawned) {
601
+ await releaseJobSlot(getPipelineDataDir(dataDir), jobId, getOrchestratorRuntimeConfig().lockFileTimeout);
602
+ }
603
+ return statusCorruptResponse(err);
604
+ }
582
605
  if (!spawned) {
583
606
  await releaseJobSlot(getPipelineDataDir(dataDir), jobId, getOrchestratorRuntimeConfig().lockFileTimeout);
584
607
  }
@@ -601,7 +624,7 @@ export async function handleJobStop(
601
624
  }
602
625
 
603
626
  try {
604
- const lifecycle = await resolveJobLifecycle(dataDir, jobId);
627
+ const lifecycle = await resolveJobLifecycleByStatusPresence(dataDir, jobId);
605
628
  if (!lifecycle) {
606
629
  return sendJson(404, createErrorResponse(Constants.ERROR_CODES.JOB_NOT_FOUND, `job "${jobId}" was not found`));
607
630
  }
@@ -624,10 +647,14 @@ export async function handleJobStop(
624
647
  await cleanupRunnerPid(jobDir);
625
648
  }
626
649
 
650
+ try { await assertKnownJobStatusesParseable(dataDir, jobId); }
651
+ catch (err) { if (err instanceof StatusCorruptError) return statusCorruptResponse(err); throw err; }
652
+
627
653
  let resetTask: string | null = null;
628
654
 
629
655
  // Reset running task and clear root-level fields in a single atomic write.
630
- await writeJobStatus(jobDir, (snapshot) => {
656
+ try {
657
+ await writeJobStatus(jobDir, (snapshot) => {
631
658
  if (snapshot.current && snapshot.tasks[snapshot.current]?.state === "running") {
632
659
  resetTask = snapshot.current;
633
660
  } else {
@@ -667,6 +694,10 @@ export async function handleJobStop(
667
694
  snapshot.currentStage = null;
668
695
  snapshot.progress = getProgressPercent(snapshot);
669
696
  });
697
+ } catch (err) {
698
+ if (err instanceof StatusCorruptError) return statusCorruptResponse(err);
699
+ throw err;
700
+ }
670
701
 
671
702
  return sendJson(202, {
672
703
  ok: true,
@@ -703,6 +734,9 @@ export async function handleTaskStart(
703
734
  }
704
735
 
705
736
  try {
737
+ try { await assertKnownJobStatusesParseable(dataDir, jobId); }
738
+ catch (err) { if (err instanceof StatusCorruptError) return statusCorruptResponse(err); throw err; }
739
+
706
740
  const lifecycle = await resolveJobLifecycle(dataDir, jobId);
707
741
  if (!lifecycle) {
708
742
  return sendJson(404, createErrorResponse(Constants.ERROR_CODES.JOB_NOT_FOUND, `job "${jobId}" was not found`));
@@ -3,6 +3,9 @@ import path from "node:path";
3
3
 
4
4
  import { parseMultipartFormData, sendJson } from "../utils/http-utils";
5
5
  import { extractSeedZip } from "../zip-utils";
6
+ import { createErrorResponse } from "../config-bridge";
7
+ import { Constants } from "../config-bridge-node";
8
+ import { resolveWithin } from "../utils/path-containment";
6
9
 
7
10
  export interface SeedUploadResult {
8
11
  seedObject: Record<string, unknown>;
@@ -40,10 +43,17 @@ export async function handleSeedUploadDirect(
40
43
  // current/{jobId}/files/artifacts/ during promotion.
41
44
  if (artifacts.length > 0) {
42
45
  const stagingJobDir = path.join(pipelineData, "staging", jobId);
46
+
47
+ for (const artifact of artifacts) {
48
+ if (resolveWithin(stagingJobDir, artifact.filename) === null) {
49
+ return sendJson(400, createErrorResponse(Constants.ERROR_CODES.BAD_REQUEST, `unsafe artifact path: ${artifact.filename}`));
50
+ }
51
+ }
52
+
43
53
  for (const artifact of artifacts) {
44
- const artifactPath = path.join(stagingJobDir, artifact.filename);
45
- await mkdir(path.dirname(artifactPath), { recursive: true });
46
- await Bun.write(artifactPath, artifact.content);
54
+ const resolved = resolveWithin(stagingJobDir, artifact.filename)!;
55
+ await mkdir(path.dirname(resolved), { recursive: true });
56
+ await Bun.write(resolved, artifact.content);
47
57
  }
48
58
  }
49
59
 
@@ -10,6 +10,11 @@ function badRequest(message: string): Error {
10
10
  return new Error(message);
11
11
  }
12
12
 
13
+ function normalizeContentType(contentType: string): string {
14
+ const mediaType = contentType.split(";", 1)[0]?.trim();
15
+ return mediaType || "application/octet-stream";
16
+ }
17
+
13
18
  export function sendJson(statusCode: number, data: unknown): Response {
14
19
  return new Response(JSON.stringify(data), {
15
20
  status: statusCode,
@@ -57,7 +62,7 @@ export async function parseMultipartFormData(
57
62
  } else {
58
63
  files.push({
59
64
  filename: value.name,
60
- contentType: value.type || "application/octet-stream",
65
+ contentType: normalizeContentType(value.type),
61
66
  content: new Uint8Array(await value.arrayBuffer()),
62
67
  });
63
68
  }
@@ -0,0 +1,18 @@
1
+ import path from "node:path";
2
+
3
+ /**
4
+ * Resolve a relative path under rootDir and return null when it escapes or
5
+ * resolves to rootDir itself.
6
+ * POSIX-oriented for Bun on macOS/Linux; revalidate Windows separators and
7
+ * case-insensitive filesystem behavior before reusing this on other boundaries.
8
+ */
9
+ export function resolveWithin(rootDir: string, relativePath: unknown): string | null {
10
+ if (typeof relativePath !== "string" || relativePath.length === 0) return null;
11
+ if (relativePath.includes("\0")) return null;
12
+ if (path.isAbsolute(relativePath)) return null;
13
+ const root = path.resolve(rootDir);
14
+ const resolved = path.resolve(root, relativePath);
15
+ if (resolved === root) return null;
16
+ if (!resolved.startsWith(root + path.sep)) return null;
17
+ return resolved;
18
+ }
@@ -0,0 +1,27 @@
1
+ import path from "node:path";
2
+
3
+ import { createErrorResponse } from "../config-bridge";
4
+ import { Constants } from "../config-bridge-node";
5
+ import { getJobDirectoryPath } from "../../../config/paths";
6
+ import { StatusCorruptError } from "../../../core/status-writer";
7
+ import { sendJson } from "./http-utils";
8
+
9
+ export async function assertStatusParseable(statusPath: string): Promise<void> {
10
+ try {
11
+ JSON.parse(await Bun.file(statusPath).text());
12
+ } catch (err: unknown) {
13
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
14
+ if (err instanceof SyntaxError) throw new StatusCorruptError(statusPath, { cause: err });
15
+ throw err;
16
+ }
17
+ }
18
+
19
+ export async function assertKnownJobStatusesParseable(dataDir: string, jobId: string): Promise<void> {
20
+ for (const location of ["current", "complete"] as const) {
21
+ await assertStatusParseable(path.join(getJobDirectoryPath(dataDir, jobId, location), "tasks-status.json"));
22
+ }
23
+ }
24
+
25
+ export function statusCorruptResponse(err: StatusCorruptError): Response {
26
+ return sendJson(409, createErrorResponse(Constants.ERROR_CODES.STATUS_CORRUPT, err.message, err.statusPath));
27
+ }