diff --git a/apps/server/src/orchestration-v2/workflowScriptQuery.test.ts b/apps/server/src/orchestration-v2/workflowScriptQuery.test.ts index f408534245ea..55df88b99414 100644 --- a/apps/server/src/orchestration-v2/workflowScriptQuery.test.ts +++ b/apps/server/src/orchestration-v2/workflowScriptQuery.test.ts @@ -3,19 +3,36 @@ import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeChildProcess from "node:child_process"; -import { OrchestrationWorkflowFileError } from "@t3tools/contracts"; +import { + OrchestrationWorkflowFileError, + CommandId, + ProjectId, + ThreadId, + type OrchestrationV2GetWorkflowScriptInput, + ProviderInstanceId, + ProviderDriverKind, +} from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { it as effectIt } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import { afterAll, assert, describe } from "vite-plus/test"; +import { afterAll, assert } from "vite-plus/test"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import { readWorkflowScript } from "./workflowScriptQuery.ts"; +import * as Settings from "../serverSettings.ts"; +import * as Orchestrator from "./Orchestrator.ts"; +import * as ProjectStore from "./ProjectStore.ts"; +import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; +import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; +import * as Layer from "effect/Layer"; +import * as NodePathService from "@effect/platform-node/NodePath"; -const root = NodePath.join(NodeOS.homedir(), ".claude", "projects", "__wf_script_test__"); +const sandbox = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "wf-script-")); +const home = NodePath.join(sandbox, "home"); +const root = NodePath.join(home, ".claude", "projects", "test"); NodeFS.mkdirSync(root, { recursive: true }); const scriptPath = NodePath.join(root, "run.js"); NodeFS.writeFileSync(scriptPath, "export const meta = {};\n"); -const outside = NodePath.join(NodeOS.tmpdir(), "wf-outside.js"); +const outside = NodePath.join(sandbox, "outside.js"); NodeFS.writeFileSync(outside, "evil\n"); const link = NodePath.join(root, "sneaky.js"); if (symlinksSupported) { @@ -27,25 +44,185 @@ if (symlinksSupported) { } afterAll(() => { - NodeFS.rmSync(root, { recursive: true, force: true }); - NodeFS.rmSync(outside, { force: true }); + NodeFS.rmSync(sandbox, { recursive: true, force: true }); }); -describe("workflow script containment", () => { +const customHome = NodePath.join(sandbox, "custom"); +const envHome = NodePath.join(sandbox, "environment"); +const customScript = NodePath.join(customHome, "projects", "test", "run.js"); +const envScript = NodePath.join(envHome, "projects", "test", "run.js"); +NodeFS.mkdirSync(NodePath.dirname(customScript), { recursive: true }); +NodeFS.mkdirSync(NodePath.dirname(envScript), { recursive: true }); +NodeFS.writeFileSync(customScript, "custom home"); +NodeFS.writeFileSync(envScript, "environment home"); +const customLink = NodePath.join(NodePath.dirname(customScript), "escape.js"); +if (symlinksSupported) NodeFS.symlinkSync(outside, customLink); + +const fixtureThreadId = ThreadId.make("workflow-script-fixture"); +const fixture = Layer.effectDiscard( + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("workflow-script-fixture:create"), + threadId: fixtureThreadId, + projectId: ProjectId.make("workflow-project"), + title: "Workflow", + modelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), model: "default" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + createdBy: "user", + creationSource: "web", + }); + }), +).pipe( + Layer.provideMerge( + ProviderReplayHarness.layerWithRegistry( + { name: "workflow-script-fixture" }, + ProviderAdapterRegistry.layerFromAdapters([]), + { runEffectWorker: false }, + ), + ), +); + +const providerInstances = { + [ProviderInstanceId.make("claude-missing")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: NodePath.join(sandbox, "missing") }, + }, + [ProviderInstanceId.make("claude-custom")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: NodePath.relative(process.cwd(), customHome) }, + environment: [{ name: "CLAUDE_CONFIG_DIR", value: "ignored-config" }], + }, + [ProviderInstanceId.make("claude-env")]: { + driver: ProviderDriverKind.make("claudeAgent"), + environment: [{ name: "CLAUDE_CONFIG_DIR", value: envHome }], + }, +}; + +effectIt.layer( + Settings.layerTest({ providerInstances }).pipe( + Layer.provideMerge(Layer.succeed(HostProcess.HomeDirectory, home)), + Layer.provideMerge(Layer.succeed(HostProcess.Environment, {})), + Layer.provideMerge(NodePathService.layer), + Layer.provideMerge(fixture), + Layer.provideMerge(Layer.mock(ProjectStore.ProjectStoreV2)({})), + ), +)("workflow script containment", (effectIt) => { + effectIt.effect.each(["valid", "malformed"] as const)( + "reads configured Claude homes while rejecting escapes with %s unrelated config", + (unrelatedConfig) => + Effect.gen(function* () { + assert.equal( + (yield* readWorkflowScript({ threadId: fixtureThreadId, scriptPath: customScript })) + .contents, + "custom home", + ); + assert.equal( + (yield* readWorkflowScript({ threadId: fixtureThreadId, scriptPath: envScript })) + .contents, + "environment home", + ); + assert.equal( + (yield* readWorkflowScript({ threadId: fixtureThreadId, scriptPath })).contents, + "export const meta = {};\n", + ); + const escaped = yield* readWorkflowScript({ + threadId: fixtureThreadId, + scriptPath: outside, + }).pipe(Effect.flip); + assert.equal(escaped.reason, "outside-root"); + if (symlinksSupported) { + const linked = yield* readWorkflowScript({ + threadId: fixtureThreadId, + scriptPath: customLink, + }).pipe(Effect.flip); + assert.equal(linked.reason, "outside-root"); + } + }).pipe( + Effect.provide( + Settings.layerTest({ + providerInstances: { + ...providerInstances, + [ProviderInstanceId.make("claude-unrelated")]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: false, + config: + unrelatedConfig === "malformed" ? { autoCompactWindow: "not-a-number" } : {}, + }, + }, + }), + ), + ), + ); + + effectIt.effect("reads a relative Claude config from the thread checkout", () => + Effect.gen(function* () { + const cwd = NodePath.join(sandbox, "checkout"); + const relativeScript = NodePath.join(cwd, "claude-config", "projects", "test", "run.js"); + NodeFS.mkdirSync(NodePath.dirname(relativeScript), { recursive: true }); + NodeFS.writeFileSync(relativeScript, "thread workflow"); + assert.notEqual(cwd, process.cwd()); + const threadId = ThreadId.make("workflow-relative-home"); + const orchestrator = yield* Orchestrator.OrchestratorV2; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("workflow-relative-home:create"), + threadId, + projectId: ProjectId.make("workflow-project"), + title: "Workflow", + modelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), model: "default" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature", + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + const input: OrchestrationV2GetWorkflowScriptInput = { threadId, scriptPath: relativeScript }; + const result = yield* readWorkflowScript(input); + assert.equal(result.contents, "thread workflow"); + assert.equal(result.truncated, false); + }).pipe( + Effect.provide( + Layer.mergeAll( + ProviderReplayHarness.layerWithRegistry( + { name: "workflow-relative-home" }, + ProviderAdapterRegistry.layerFromAdapters([]), + { runEffectWorker: false }, + ), + Settings.layerTest({ + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + environment: [{ name: "CLAUDE_CONFIG_DIR", value: "claude-config" }], + }, + }, + }), + ), + ), + ), + ); + effectIt.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( "rejects a FIFO without waiting for a writer", () => Effect.gen(function* () { const fifo = NodePath.join(root, "pipe.js"); NodeChildProcess.execFileSync("mkfifo", [fifo]); - const error = yield* Effect.flip(readWorkflowScript({ scriptPath: fifo })); + const error = yield* Effect.flip( + readWorkflowScript({ threadId: fixtureThreadId, scriptPath: fifo }), + ); assert.equal(error.reason, "not-regular-file"); }), ); effectIt.effect("serves a real script under the projects root", () => Effect.gen(function* () { - const result = yield* readWorkflowScript({ scriptPath }); + const result = yield* readWorkflowScript({ threadId: fixtureThreadId, scriptPath }); assert.include(result.contents, "export const meta"); assert.equal(result.truncated, false); }), @@ -53,13 +230,20 @@ describe("workflow script containment", () => { effectIt.effect("rejects relative and non-js paths", () => Effect.gen(function* () { - const relative = yield* Effect.exit(readWorkflowScript({ scriptPath: "run.js" })); + const relative = yield* Effect.exit( + readWorkflowScript({ threadId: fixtureThreadId, scriptPath: "run.js" }), + ); assert.equal(relative._tag, "Failure"); const nonJs = yield* Effect.exit( - readWorkflowScript({ scriptPath: scriptPath.replace(".js", ".ts") }), + readWorkflowScript({ + threadId: fixtureThreadId, + scriptPath: scriptPath.replace(".js", ".ts"), + }), ); assert.equal(nonJs._tag, "Failure"); - const error = yield* Effect.flip(readWorkflowScript({ scriptPath: "run.js" })); + const error = yield* Effect.flip( + readWorkflowScript({ threadId: fixtureThreadId, scriptPath: "run.js" }), + ); assert.equal(error._tag, "OrchestrationGetWorkflowScriptError"); assert.equal(error.scriptPath, "run.js"); assert.equal(error.reason, "invalid-path"); @@ -72,10 +256,12 @@ describe("workflow script containment", () => { "rejects paths outside the root and symlink escapes", () => Effect.gen(function* () { - const escaped = yield* Effect.exit(readWorkflowScript({ scriptPath: outside })); + const escaped = yield* Effect.exit( + readWorkflowScript({ threadId: fixtureThreadId, scriptPath: outside }), + ); assert.equal(escaped._tag, "Failure"); const sneaky = yield* Effect.exit( - readWorkflowScript({ scriptPath: link }).pipe( + readWorkflowScript({ threadId: fixtureThreadId, scriptPath: link }).pipe( Effect.flip, Effect.map((error) => error.reason), ), diff --git a/apps/server/src/orchestration-v2/workflowScriptQuery.ts b/apps/server/src/orchestration-v2/workflowScriptQuery.ts index 1775202a79a3..7f041ba98f9e 100644 --- a/apps/server/src/orchestration-v2/workflowScriptQuery.ts +++ b/apps/server/src/orchestration-v2/workflowScriptQuery.ts @@ -1,14 +1,96 @@ -import { OrchestrationGetWorkflowScriptError } from "@t3tools/contracts"; +import { + ClaudeSettings, + OrchestrationGetWorkflowScriptError, + OrchestrationWorkflowFileError, + type OrchestrationV2GetWorkflowScriptInput, +} from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Settings from "../serverSettings.ts"; +import { deriveProviderInstanceConfigMap } from "../provider/ProviderInstanceRegistryHydration.ts"; +import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { readContainedWorkflowFile } from "./workflowFileRead.ts"; +import * as Orchestrator from "./Orchestrator.ts"; +import * as ProjectStore from "./ProjectStore.ts"; -export const readWorkflowScript = Effect.fn("orchestration.readWorkflowScript")(function* (input: { - readonly scriptPath: string; -}) { - const file = yield* readContainedWorkflowFile({ - path: input.scriptPath, - extension: ".js", - byteCap: 256 * 1024, +const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); + +export const readWorkflowScript = Effect.fn("orchestration.readWorkflowScript")(function* ( + input: OrchestrationV2GetWorkflowScriptInput, +) { + const settings = yield* Settings.ServerSettingsService; + const environment = yield* HostProcess.Environment; + const path = yield* Path.Path; + const threads = yield* Orchestrator.OrchestratorV2; + const projects = yield* ProjectStore.ProjectStoreV2; + const roots = yield* Effect.gen(function* () { + const { thread } = yield* threads.getThreadRecords(input.threadId, []); + let cwd = thread.worktreePath; + if (cwd === null) { + const project = yield* projects.get(thread.projectId); + if (Option.isNone(project)) { + return yield* new OrchestrationWorkflowFileError({ + reason: "root-unavailable", + path: input.scriptPath, + }); + } + cwd = project.value.workspaceRoot; + } + const instances = deriveProviderInstanceConfigMap(yield* settings.getSettings); + const roots: string[] = []; + for (const instance of Object.values(instances)) { + if (instance.driver !== "claudeAgent") continue; + const config = decodeClaudeSettings(instance.config ?? {}); + if (Option.isNone(config)) continue; + const env = yield* mergeProviderInstanceEnvironment(instance.environment, environment); + const inherited = env.CLAUDE_CONFIG_DIR?.trim() ?? ""; + // Explicit homes are normalized on the server before the provider starts. + roots.push( + config.value.homePath.trim().length === 0 && inherited.length > 0 + ? path.resolve(cwd, inherited) + : yield* resolveClaudeHomePath(config.value, env), + ); + } + return roots; + }).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetWorkflowScriptError({ + reason: "read-failed", + scriptPath: input.scriptPath, + cause, + }), + ), + ); + let unavailable: OrchestrationWorkflowFileError | undefined; + const file = yield* Effect.gen(function* () { + for (const configDir of new Set(roots)) { + const result = yield* readContainedWorkflowFile({ + path: input.scriptPath, + extension: ".js", + byteCap: 256 * 1024, + configDir, + }).pipe(Effect.result); + if (result._tag === "Success") return result.success; + if ( + result.failure.reason !== "outside-root" && + result.failure.reason !== "root-unavailable" + ) { + return yield* result.failure; + } + if (unavailable?.reason !== "outside-root") unavailable = result.failure; + } + return yield* ( + unavailable ?? + new OrchestrationWorkflowFileError({ + reason: "root-unavailable", + path: input.scriptPath, + }) + ); }).pipe( Effect.mapError( (error) => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ae32d3626ad2..462e4eb912ed 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1822,8 +1822,7 @@ const layerWsRpc = ( ), ), [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: (input) => threadManagement.stopWorkflow(input), - [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: (input) => - readWorkflowScript({ scriptPath: input.scriptPath }), + [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: (input) => readWorkflowScript(input), [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: (input) => threadManagement.getTurnItem(input).pipe( Effect.mapError(