diff --git a/apps/server/src/factory/index.test.ts b/apps/server/src/factory/index.test.ts index 679e216eb398..a26a4451c974 100644 --- a/apps/server/src/factory/index.test.ts +++ b/apps/server/src/factory/index.test.ts @@ -101,7 +101,7 @@ const logsFor = ( logs.push(messageText(message)); }); - yield* Effect.scoped( + const built = yield* Effect.scoped( Layer.build( FactoryRuntime.pipe( Layer.provide( @@ -117,13 +117,16 @@ const logsFor = ( Layer.provide(quietEvents), ), ), - ).pipe(Effect.provide(Logger.layer([capture], { mergeWithExisting: false }))); + ).pipe( + Effect.map((context) => Context.get(context, FactoryLoop).status), + Effect.provide(Logger.layer([capture], { mergeWithExisting: false })), + ); const gates = yield* fs .readDirectory(join(factoryDir, "gates")) .pipe(Effect.orElseSucceed(() => [])); const events = scanMachineLog(factoryDir).map((entry) => entry.event); - return { logs, machineConfigPath, gates, events }; + return { logs, machineConfigPath, gates, events, status: built }; }), ).pipe(Effect.provide(NodeServices.layer)); @@ -135,9 +138,18 @@ it.effect("logs that there is no config when the machine file does not exist", ( }), ); +// The status goes to every client that can read, so its reason names no path and no error: +// those stay in the server log. +it.effect("reports itself idle, and why, when there is no config", () => + Effect.gen(function* () { + const { status } = yield* logsFor(undefined); + assert.deepStrictEqual(status, { state: "idle", reason: "no factory config" }); + }), +); + it.effect("logs factory started once for a valid machine file, then runs the first pass", () => Effect.gen(function* () { - const { logs, events } = yield* logsFor(VALID); + const { logs, events, status } = yield* logsFor(VALID); assert.strictEqual(logs[0], "factory started"); // The pass found no gh: the repo's config could not be read, and the pass said so and ended. assert.ok( @@ -145,6 +157,7 @@ it.effect("logs factory started once for a valid machine file, then runs the fir logs.join("\n"), ); assert.strictEqual(logs.at(-1), "pass done: 0 runs, 0 claimed, 1 errors, 0 need attention"); + assert.deepStrictEqual(status, { state: "running" }); assert.deepStrictEqual(events, ["pass_started", "repo_skipped", "pass_finished"]); }), ); @@ -161,6 +174,13 @@ it.effect("logs each config error and does not start for an invalid machine file }), ); +it.effect("reports itself idle, without the config errors, when the machine file is invalid", () => + Effect.gen(function* () { + const { status } = yield* logsFor(VALID.replace("model: claude-sonnet-5-5\n", "")); + assert.deepStrictEqual(status, { state: "idle", reason: "factory config failed to load" }); + }), +); + // ---- attention ---- /** A process runner that records each call: osascript succeeds and every other command fails to start. */ diff --git a/apps/server/src/factory/index.ts b/apps/server/src/factory/index.ts index 46288e33847c..ab74bf6b4fa8 100644 --- a/apps/server/src/factory/index.ts +++ b/apps/server/src/factory/index.ts @@ -53,11 +53,31 @@ import { type IssueSummary, } from "./tracker/github.ts"; -/** The running factory, for the code that wakes it (a thread's run changing, a person's request). */ -export class FactoryLoop extends Context.Service()("t3/factory/FactoryLoop") {} +/** + * What the factory is doing: running, or idle for a reason (no config, or one it cannot read). + * `running` means the pass loop started; it says nothing about whether passes succeed, which the + * server log records. The reason names no path and no error, as every reading client gets it; + * the log has the detail. + */ +export type FactoryStatus = + | { readonly state: "running" } + | { readonly state: "idle"; readonly reason: string }; + +/** + * The running factory, for the code that wakes it (a thread's run changing, a person's request). + * Its `status` is what `GET /api/factory/status` reports. + */ +export class FactoryLoop extends Context.Service< + FactoryLoop, + Loop & { readonly status: FactoryStatus } +>()("t3/factory/FactoryLoop") {} /** A loop that does nothing: the factory has no config, or one it cannot read. */ -const idle: Loop = { wake: Effect.void, awaitPass: Effect.never }; +const idle = (reason: string): FactoryLoop["Service"] => ({ + wake: Effect.void, + awaitPass: Effect.never, + status: { state: "idle", reason }, +}); /** * How long an issue must have sat in `factory:triage` or `factory:building` with no assignee @@ -373,12 +393,12 @@ const startFactory = (options: RuntimeOptions) => run: null, message: `machine config not loaded: ${loaded.failure.errors.map(formatConfigError).join("; ")}`, }); - return idle; + return idle("factory config failed to load"); } yield* attention.clear((item) => item.id === CONFIG_ITEM_ID); if (Option.isNone(loaded.success)) { yield* Effect.logInfo(`no factory config at ${machineConfigPath}`); - return idle; + return idle("no factory config"); } const machine = loaded.success.value; yield* attention.setNotify(machine.notify); @@ -410,7 +430,7 @@ const startFactory = (options: RuntimeOptions) => }).pipe(Effect.ensuring(refreshOwned(factoryDir, owned))); const loop = yield* runLoop(onePass, machine.pollSeconds); yield* Effect.forkScoped(wakeOnOwnedThreads(owned, loop, options.restartDelay ?? "5 seconds")); - return loop; + return { ...loop, status: { state: "running" } } satisfies FactoryLoop["Service"]; }); /** diff --git a/apps/server/src/factory/t3/auth.ts b/apps/server/src/factory/t3/auth.ts new file mode 100644 index 000000000000..357f7d347327 --- /dev/null +++ b/apps/server/src/factory/t3/auth.ts @@ -0,0 +1,56 @@ +import { EnvironmentAuthenticatedPrincipal, type AuthEnvironmentScope } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest, type HttpServerResponse } from "effect/http"; +import * as HttpRouter from "effect/http/HttpRouter"; + +import * as EnvironmentAuth from "../../auth/EnvironmentAuth.ts"; +import * as AuthHttp from "../../auth/http.ts"; + +/** + * The factory's routes behind T3's authentication. The factory does not parse tokens or + * cookies: T3 does, through `EnvironmentAuth.authenticateHttpRequest`, the same call the + * middleware of its own HTTP API makes (`AuthHttp.layerAuthenticatedAuth`), so paired, relay and + * tunnel sessions are accepted or refused exactly as there. That middleware is typed against + * HttpApi endpoints and cannot wrap a plain router route, so this does what upstream's own plain + * OTLP proxy route does (`authenticateRawRouteWithScope` in `http.ts`). The contract error classes render themselves, so a failing route + * answers 401, 403 or 500 JSON without a handler. + */ + +/** + * Runs `handler` for an authenticated request whose session holds `scope`. A request without a + * session fails 401 and one without the scope fails 403. The handler may read + * `EnvironmentAuthenticatedPrincipal`. + */ +export const withScope = ( + scope: AuthEnvironmentScope, + handler: Effect.Effect, +) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const session = yield* serverAuth.authenticateHttpRequest(request).pipe( + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => + AuthHttp.failEnvironmentAuthInvalid( + EnvironmentAuth.serverAuthCredentialReason(error), + EnvironmentAuth.serverAuthDpopFailureReason(error), + ), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + AuthHttp.failEnvironmentInternal("internal_error", error), + ), + ); + return yield* Effect.andThen(AuthHttp.requireEnvironmentScope(scope), handler).pipe( + Effect.provideService(EnvironmentAuthenticatedPrincipal, { + ...session, + scopes: new Set(session.scopes), + }), + ); + }); + +/** A route on T3's router that needs a session holding `scope`. */ +export const route = ( + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", + path: `/${string}`, + scope: AuthEnvironmentScope, + handler: Effect.Effect, +) => HttpRouter.add(method, path, withScope(scope, handler)); diff --git a/apps/server/src/factory/t3/http.test.ts b/apps/server/src/factory/t3/http.test.ts new file mode 100644 index 000000000000..ff7f904156ef --- /dev/null +++ b/apps/server/src/factory/t3/http.test.ts @@ -0,0 +1,201 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + type AuthEnvironmentScope, + AuthOrchestrationReadScope, + AuthSettingsWriteScope, +} from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpServerResponse } from "effect/http"; +import * as Etag from "effect/http/Etag"; +import * as HttpPlatform from "effect/http/HttpPlatform"; +import * as HttpRouter from "effect/http/HttpRouter"; +import { describe } from "vite-plus/test"; + +import * as EnvironmentAuth from "../../auth/EnvironmentAuth.ts"; +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../../config.ts"; +import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; +import * as SqlitePersistence from "../../persistence/Sqlite.ts"; +import { FactoryLoop, type FactoryStatus } from "../index.ts"; +import { route } from "./auth.ts"; +import { FactoryRoutes, STATUS_PATH } from "./http.ts"; + +const TEST_ROUTE_PATH = "/api/factory/test/settings"; + +/** A route that needs a scope a standard client lacks, to prove the scope check. */ +const testOnlyRoute = route( + "GET", + TEST_ROUTE_PATH, + AuthSettingsWriteScope, + Effect.succeed(HttpServerResponse.empty({ status: 204 })), +); + +const layerEnvironmentAuth = EnvironmentAuth.layer.pipe( + Layer.provide(SqlitePersistence.layerMemory), + Layer.provide(ServerSecretStore.layer), + Layer.provide(ServerEnvironment.layerIdentity), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-factory-http-test-" })), + Layer.provide(NodeServices.layer), +); + +const layerRoutes = Layer.mergeAll(FactoryRoutes, testOnlyRoute).pipe( + Layer.provideMerge( + HttpPlatform.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Etag.layerWeak), + ), + ), +); + +const loopWith = (status: FactoryStatus) => + FactoryLoop.of({ wake: Effect.void, awaitPass: Effect.never, status }); + +/** Runs `use` against the factory's routes behind T3's auth, with a loop reporting `status`. */ +const serving = ( + status: FactoryStatus, + use: ( + get: (path: string, token?: string) => Promise, + tokens: { readonly read: string; readonly settingsWrite: string }, + ) => Promise, +) => + Effect.gen(function* () { + const auth = yield* EnvironmentAuth.EnvironmentAuth; + const context = Context.make(EnvironmentAuth.EnvironmentAuth, auth).pipe( + Context.add(FactoryLoop, loopWith(status)), + ); + // Issued on the live clock: the handler checks expiry on it, not on the test clock. + const issue = (scope: AuthEnvironmentScope) => + auth.issueSession({ scopes: [scope] }).pipe( + TestClock.withLive, + Effect.map((session) => session.token), + ); + const tokens = { + read: yield* issue(AuthOrchestrationReadScope), + settingsWrite: yield* issue(AuthSettingsWriteScope), + }; + return yield* Effect.acquireUseRelease( + Effect.sync(() => HttpRouter.toWebHandler(layerRoutes, { disableLogger: true })), + ({ handler }) => + Effect.tryPromise(() => + use( + (path, token) => + handler( + new Request(`http://127.0.0.1${path}`, { + headers: token === undefined ? {} : { authorization: `Bearer ${token}` }, + }), + context, + ), + tokens, + ), + ), + ({ dispose }) => Effect.promise(dispose), + ); + }).pipe(Effect.provide(layerEnvironmentAuth)); + +const idle: FactoryStatus = { state: "idle", reason: "no factory config" }; + +describe("GET /api/factory/status", () => { + it("is served at the path the Factory page calls", () => { + assert.strictEqual(STATUS_PATH, "/api/factory/status"); + }); + + it.effect("answers 401 without credentials", () => + serving(idle, async (get) => { + const response = await get("/api/factory/status"); + assert.strictEqual(response.status, 401); + assert.strictEqual(((await response.json()) as { code: string }).code, "auth_invalid"); + }), + ); + + it.effect("answers 401 for a token that is not a session", () => + serving(idle, async (get) => { + const response = await get(STATUS_PATH, "not-a-session-token"); + assert.strictEqual(response.status, 401); + }), + ); + + it.effect("needs orchestration:read, the scope every reading client holds", () => + serving(idle, async (get, tokens) => { + const response = await get(STATUS_PATH, tokens.settingsWrite); + assert.strictEqual(response.status, 403); + assert.strictEqual(((await response.json()) as { code: string }).code, "insufficient_scope"); + }), + ); + + it.effect("reports an idle factory and why", () => + serving(idle, async (get, tokens) => { + const response = await get("/api/factory/status", tokens.read); + assert.strictEqual(response.status, 200); + assert.deepStrictEqual(await response.json(), idle); + }), + ); + + it.effect("reports a running factory with no reason", () => + serving({ state: "running" }, async (get, tokens) => { + const response = await get(STATUS_PATH, tokens.read); + assert.strictEqual(response.status, 200); + assert.deepStrictEqual(await response.json(), { state: "running" }); + }), + ); +}); + +describe("an auth failure inside T3", () => { + it.effect("answers 500 internal_error, not 401, and never runs the handler", () => { + let handled = false; + const failingRoute = route( + "GET", + "/api/factory/test/internal", + AuthOrchestrationReadScope, + Effect.sync(() => { + handled = true; + return HttpServerResponse.empty({ status: 204 }); + }), + ); + return Effect.gen(function* () { + const auth = yield* EnvironmentAuth.EnvironmentAuth; + const context = Context.make(EnvironmentAuth.EnvironmentAuth, { + ...auth, + authenticateHttpRequest: () => + Effect.fail( + new EnvironmentAuth.ServerAuthSessionCredentialValidationError({ + cause: new Error("session store unreadable"), + }), + ), + }).pipe(Context.add(FactoryLoop, loopWith(idle))); + const layer = failingRoute.pipe(Layer.provideMerge(layerRoutes)); + const response = yield* Effect.acquireUseRelease( + Effect.sync(() => HttpRouter.toWebHandler(layer, { disableLogger: true })), + ({ handler }) => + Effect.promise(() => + handler(new Request("http://127.0.0.1/api/factory/test/internal"), context), + ), + ({ dispose }) => Effect.promise(dispose), + ); + assert.strictEqual(response.status, 500); + const body = (yield* Effect.promise(() => response.json())) as { code: string }; + assert.strictEqual(body.code, "internal_error"); + assert.isFalse(handled); + }).pipe(Effect.provide(layerEnvironmentAuth)); + }); +}); + +describe("the scope a route requires", () => { + it.effect("answers 403 to a session without it", () => + serving(idle, async (get, tokens) => { + const response = await get(TEST_ROUTE_PATH, tokens.read); + assert.strictEqual(response.status, 403); + assert.strictEqual(((await response.json()) as { code: string }).code, "insufficient_scope"); + }), + ); + + it.effect("lets a session with it through", () => + serving(idle, async (get, tokens) => { + const response = await get(TEST_ROUTE_PATH, tokens.settingsWrite); + assert.strictEqual(response.status, 204); + }), + ); +}); diff --git a/apps/server/src/factory/t3/http.ts b/apps/server/src/factory/t3/http.ts new file mode 100644 index 000000000000..72e24cfc13c0 --- /dev/null +++ b/apps/server/src/factory/t3/http.ts @@ -0,0 +1,25 @@ +import { AuthOrchestrationReadScope } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpServerResponse } from "effect/http"; + +import { FactoryLoop } from "../index.ts"; +import { route } from "./auth.ts"; + +export const STATUS_PATH = "/api/factory/status"; + +const statusRoute = route( + "GET", + STATUS_PATH, + AuthOrchestrationReadScope, + Effect.gen(function* () { + const { status } = yield* FactoryLoop; + return HttpServerResponse.jsonUnsafe(status); + }), +); + +/** + * The factory's HTTP routes. server.ts merges this into `layerMakeRoutes`; later routes are + * added here. + */ +export const FactoryRoutes = Layer.mergeAll(statusRoute); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 35e3b9f55037..d20d66700c92 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -8,6 +8,7 @@ import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReact import * as PullRequestWatchReactor from "./orchestration-v2/PullRequestWatchReactor.ts"; import * as DelegatedTaskQuestionReactor from "./orchestration-v2/DelegatedTaskQuestionReactor.ts"; import { FactoryLayer } from "./factory/index.ts"; +import { FactoryRoutes } from "./factory/t3/http.ts"; // @effect-diagnostics nodeBuiltinImport:off import * as NodeHttp from "node:http"; @@ -719,6 +720,7 @@ const layerMakeRoutes = Layer.mergeAll( ServerBrowserStream.routeLayer, ServerHttp.layerStaticAndDevRoute, Ws.layer, + FactoryRoutes, ), // The MCP session registry is provided globally (shared with V2 provider // sessions) rather than inline here. The orchestrator toolkit resolves diff --git a/factory/docs/design.md b/factory/docs/design.md index e11e8bdbe933..6fa823215b55 100644 --- a/factory/docs/design.md +++ b/factory/docs/design.md @@ -180,7 +180,7 @@ Local on this Mac. No CI macOS runners, so no cost to the `rawphp` budget. Signi 3. `cargo build --locked --release` in `native/resource-monitor` 4. `node apps/server/scripts/cli.ts build-exe` with the SEA Node version upstream pins 5. `node scripts/build-cli-archive.ts --platform mac --arch arm64 --version …` -6. Smoke test: `t3 serve` on a throwaway base dir, wait for `/.well-known/t3/environment`, then hit `/api/factory/status`. +6. Smoke test: `t3 serve` on a throwaway home and base dir, wait for `/.well-known/t3/environment` to answer 200, then check the server log holds `factory started`. It does not call `/api/factory/status`, which needs a paired session (see Decided below). Version: `+fac.`, for example `0.0.46-nightly.20261014.2950+fac.1a2b3c4`. @@ -228,9 +228,12 @@ Cost of the fork: The fork replaces Rust softfac if its output is at least as good and the sync cost stays under an agreed bound, for example one red sync a week fixed by the factory without Tom. If the sync cost is the problem, the fallback is the cheaper design from the brainstorm: Rust softfac bundled with T3 as one install, still talking over the wire. +## Decided + +1. Routes added through `layerMakeRoutes` do not get T3's auth by themselves. `AuthHttp.layerAuthenticatedAuth` is an `HttpApiMiddleware` wired to `EnvironmentHttpApi` endpoints, and its middleware function is typed against HttpApi endpoint metadata, so it cannot wrap a plain `HttpRouter` route. What it does inside is one call, `EnvironmentAuth.authenticateHttpRequest`, and upstream's own plain OTLP trace proxy route (`authenticateRawRouteWithScope` in `apps/server/src/http.ts`) calls that same function and maps its failures with the helpers `auth/http.ts` exports. The factory does the same in `apps/server/src/factory/t3/auth.ts`: `withScope(scope, handler)` authenticates the request through `EnvironmentAuth.authenticateHttpRequest`, provides `EnvironmentAuthenticatedPrincipal`, and checks the scope with `AuthHttp.requireEnvironmentScope`. Paired, relay and tunnel sessions pass through the same code as for `EnvironmentHttpApi`; there is no factory token, no contract change and no RPC. The route layer is `apps/server/src/factory/t3/http.ts` (not `factory/http.ts` as the layout above sketches) because it reads T3's auth, and `server.ts` merges `FactoryRoutes` into `layerMakeRoutes` in one line. Not carried over: the DPoP `WWW-Authenticate` challenge header on a 401, which is private to `auth/http.ts`; upstream's plain routes omit it too. The Factory page will call `/api/factory/*` with the browser session cookie, as it calls T3's HTTP API. + ## Open questions to settle in step 1 or 2 -1. Do routes added through `layerMakeRoutes` get T3's auth? The Factory page needs it. If they don't, the page has to use T3's RPC, which means more seams. 2. Answered (#8): `ProcessRunner` spawns through Effect's Node spawner, which does put the child in its own process group (`detached: true` on POSIX) and sends the group `SIGTERM` when the effect is interrupted. It cannot do the rest: it always extends the parent environment (`extendEnv` is true whenever `env` is set), it reads stdout and stderr on separate pipes, and on timeout it drops the partial output and never escalates to `SIGKILL`. `apps/server/src/factory/process.ts` therefore uses `node:child_process` directly, outside `factory/t3/`, and needs no seam. 3. Is a YAML parser already a server dependency? If not, adding one touches `package.json` and the lockfile, so they join the seam list. 4. How does T3 handle a thread's worktree on archive? It leaves it on disk. `thread.archive` cancels the thread's queued runs, detaches its provider sessions, revokes its MCP credential, and closes its terminals and deletes their history (`terminal.cleanup`); the worktree stays. Storage cleanup (Settings → Storage, off by default) only considers active threads, so an archived thread's worktree is never a candidate; only "Delete worktrees with deleted threads" removes one, when the thread is deleted. So the factory removes the worktree itself with git after archiving, as softfac does: `FactorySessions.removeWorktree`, which keeps the branch.