History

feat: native subagent & workflow observability (#5219)

Theo Browne committed 3 months agov0.0.32-nightly.20260806.1012

Commit a2ca89a1 parent 990bb0b46 files · +6871 −71Browse files

Showing the changes to apps/server/src/orchestration/workflowScriptQuery.ts (1 of 46 files). Show all

Unified · Split

apps/server/src/orchestration/workflowScriptQuery.ts+124−0
@@ -0,0 +1,124 @@
1// @effect-diagnostics nodeBuiltinImport:off
2/**
3 * Read-only access to persisted workflow scripts for the Agents surface's
4 * "{} script" affordance.
5 *
6 * Containment rules (lifted from the reviewed #3650 inspection service):
7 * - the resolved realpath must live under ~/.claude/projects (where the
8 * Claude harness persists workflow scripts) — realpath re-containment
9 * defeats symlink escapes, including a symlinked leaf file;
10 * - only .js leaf files are served;
11 * - reads are size-capped rather than failed, with a truncation marker.
12 *
13 * The client-supplied path is a hint from the workflow's runHandles; it is
14 * never trusted beyond these checks.
15 */
16import * as NodeFSP from "node:fs/promises";
17import * as NodeOS from "node:os";
18import * as NodePath from "node:path";
19
20import { OrchestrationGetWorkflowScriptError } from "@t3tools/contracts";
21import * as Effect from "effect/Effect";
22
23const SCRIPT_BYTE_CAP = 256 * 1024;
24
25function scriptsRoot(): string {
26 return NodePath.join(NodeOS.homedir(), ".claude", "projects");
27}
28
29export const readWorkflowScript = Effect.fn("orchestration.readWorkflowScript")(function* (input: {
30 readonly scriptPath: string;
31}) {
32 const requested = input.scriptPath;
33
34 if (!NodePath.isAbsolute(requested) || NodePath.extname(requested) !== ".js") {
35 return yield* Effect.fail(
36 new OrchestrationGetWorkflowScriptError({ reason: "invalid-path", scriptPath: requested }),
37 );
38 }
39
40 const root = yield* Effect.tryPromise({
41 try: () => NodeFSP.realpath(scriptsRoot()),
42 catch: (cause) =>
43 new OrchestrationGetWorkflowScriptError({
44 reason: "root-unavailable",
45 scriptPath: requested,
46 cause,
47 }),
48 });
49
50 // Realpath the FILE itself (not just its directory): a symlink named
51 // like a script inside a contained directory must not escape.
52 const resolved = yield* Effect.tryPromise({
53 try: () => NodeFSP.realpath(requested),
54 catch: (cause) =>
55 new OrchestrationGetWorkflowScriptError({
56 reason: "not-found",
57 scriptPath: requested,
58 cause,
59 }),
60 });
61
62 if (resolved !== root && !resolved.startsWith(`${root}${NodePath.sep}`)) {
63 return yield* Effect.fail(
64 new OrchestrationGetWorkflowScriptError({ reason: "outside-root", scriptPath: resolved }),
65 );
66 }
67 if (NodePath.extname(resolved) !== ".js") {
68 return yield* Effect.fail(
69 new OrchestrationGetWorkflowScriptError({ reason: "not-js", scriptPath: resolved }),
70 );
71 }
72
73 // TOCTOU-safe read (review finding): open FIRST, then verify what was
74 // actually opened via the file descriptor. Re-checking the path after
75 // open would race against a swap; fstat on the handle cannot. The two
76 // containment checks fail with their own tagged reasons (not manufactured
77 // Errors folded into read-failed); "read-failed" is reserved for genuine
78 // platform failures with the real cause attached.
79 const read = yield* Effect.tryPromise({
80 try: async () => {
81 const handle = await NodeFSP.open(resolved, "r");
82 try {
83 const stat = await handle.stat();
84 if (!stat.isFile()) {
85 return { failure: "not-regular-file" as const };
86 }
87 // The opened inode must be the same one realpath resolved to: a
88 // process swapping the path between realpath and open changes the
89 // inode, which this comparison catches.
90 const pathStat = await NodeFSP.lstat(resolved);
91 if (stat.ino !== pathStat.ino || stat.dev !== pathStat.dev) {
92 return { failure: "changed-during-read" as const };
93 }
94 const truncated = stat.size > SCRIPT_BYTE_CAP;
95 const buffer = Buffer.alloc(Math.min(stat.size, SCRIPT_BYTE_CAP));
96 const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
97 return {
98 contents: buffer.subarray(0, bytesRead).toString("utf8"),
99 truncated,
100 };
101 } finally {
102 await handle.close();
103 }
104 },
105 catch: (cause) =>
106 new OrchestrationGetWorkflowScriptError({
107 reason: "read-failed",
108 scriptPath: resolved,
109 cause,
110 }),
111 });
112 if ("failure" in read) {
113 return yield* new OrchestrationGetWorkflowScriptError({
114 reason: read.failure,
115 scriptPath: resolved,
116 });
117 }
118
119 return {
120 scriptPath: resolved,
121 contents: read.contents,
122 truncated: read.truncated,
123 };
124});