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+ */
16+import * as NodeFSP from "node:fs/promises";
17+import * as NodeOS from "node:os";
18+import * as NodePath from "node:path";
19+
20+import { OrchestrationGetWorkflowScriptError } from "@t3tools/contracts";
21+import * as Effect from "effect/Effect";
22+
23+const SCRIPT_BYTE_CAP = 256 * 1024;
24+
25+function scriptsRoot(): string {
26+ return NodePath.join(NodeOS.homedir(), ".claude", "projects");
27+}
28+
29+export 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+});