@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.
- package/README.md +1 -0
- package/docs/pop-task-guide.md +45 -0
- package/package.json +3 -3
- package/src/core/__tests__/agent-step.test.ts +402 -35
- package/src/core/__tests__/task-runner.test.ts +104 -0
- package/src/core/agent-step.ts +295 -41
- package/src/core/agent-types.ts +61 -0
- package/src/core/file-io.ts +8 -74
- package/src/core/orchestrator.ts +2 -1
- package/src/core/pipeline-definition.ts +1 -1
- package/src/core/pipeline-runner.ts +54 -3
- package/src/core/status-writer.ts +44 -26
- package/src/core/task-runner.ts +44 -1
- package/src/core/validation.ts +1 -1
- package/src/harness/__tests__/discovery.test.ts +235 -0
- package/src/harness/discovery.ts +109 -0
- package/src/harness/index.ts +22 -0
- package/src/harness/mcp-io-server.ts +1 -1
- package/src/ui/client/hooks/useJobListWithUpdates.ts +16 -1
- package/src/ui/dist/assets/{index-D7hzshSS.js → index--RH3sAt3.js} +129 -1
- package/src/ui/dist/assets/index--RH3sAt3.js.map +1 -0
- package/src/ui/dist/assets/style-CSSKuMOe.css +2 -0
- package/src/ui/dist/index.html +2 -2
- package/src/ui/embedded-assets.js +6 -6
- package/src/ui/pages/Code.tsx +135 -0
- package/src/ui/server/__tests__/gate-endpoints.test.ts +90 -0
- package/src/ui/server/__tests__/job-control-endpoints.test.ts +188 -0
- package/src/ui/server/__tests__/path-containment.test.ts +54 -0
- package/src/ui/server/__tests__/status-corruption.test.ts +55 -0
- package/src/ui/server/config-bridge.ts +1 -0
- package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +77 -0
- package/src/ui/server/endpoints/gate-endpoints.ts +17 -1
- package/src/ui/server/endpoints/job-control-endpoints.ts +36 -2
- package/src/ui/server/endpoints/upload-endpoints.ts +13 -3
- package/src/ui/server/utils/http-utils.ts +6 -1
- package/src/ui/server/utils/path-containment.ts +18 -0
- package/src/ui/server/utils/status-corruption.ts +27 -0
- package/src/harness/__tests__/descriptors.test.ts +0 -378
- package/src/harness/__tests__/executor.test.ts +0 -193
- package/src/harness/__tests__/resolve.test.ts +0 -200
- package/src/harness/__tests__/types.test.ts +0 -297
- package/src/harness/descriptors/claude.ts +0 -132
- package/src/harness/descriptors/codex.ts +0 -126
- package/src/harness/descriptors/index.ts +0 -10
- package/src/harness/descriptors/opencode.ts +0 -147
- package/src/harness/executor.ts +0 -128
- package/src/harness/resolve.ts +0 -176
- package/src/harness/types.ts +0 -100
- package/src/ui/dist/assets/index-D7hzshSS.js.map +0 -1
- 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
|
|
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
|
-
|
|
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
|
|
45
|
-
await mkdir(path.dirname(
|
|
46
|
-
await Bun.write(
|
|
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
|
|
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
|
+
}
|