From cc7ae04fce5f0e302e2f3967dbf25b434b8238a7 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Fri, 16 Oct 2026 16:23:08 +1000 Subject: [PATCH 1/6] test(factory): route layer behind T3's auth and the loop's status Red: apps/server/src/factory/t3/auth.ts and http.ts do not exist yet, so http.test.ts fails to resolve "./auth.ts" and "./http.ts". The three new index.test.ts cases fail because FactoryLoop has no `status` (it reads undefined instead of idle/running). Issue: #57 --- apps/server/src/factory/index.test.ts | 34 +++++- apps/server/src/factory/t3/http.test.ts | 138 ++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/factory/t3/http.test.ts diff --git a/apps/server/src/factory/index.test.ts b/apps/server/src/factory/index.test.ts index 318614629bdb..53393efd6513 100644 --- a/apps/server/src/factory/index.test.ts +++ b/apps/server/src/factory/index.test.ts @@ -100,7 +100,7 @@ const logsFor = ( logs.push(messageText(message)); }); - yield* Effect.scoped( + const built = yield* Effect.scoped( Layer.build( FactoryRuntime.pipe( Layer.provide( @@ -116,13 +116,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)); @@ -134,9 +137,19 @@ it.effect("logs that there is no config when the machine file does not exist", ( }), ); +it.effect("reports itself idle, and why, when there is no config", () => + Effect.gen(function* () { + const { machineConfigPath, status } = yield* logsFor(undefined); + assert.deepStrictEqual(status, { + state: "idle", + reason: `no factory config at ${machineConfigPath}`, + }); + }), +); + 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( @@ -144,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"]); }), ); @@ -157,6 +171,18 @@ it.effect("logs each config error and does not start for an invalid machine file }), ); +it.effect("reports itself idle, with the config errors, when the machine file is invalid", () => + Effect.gen(function* () { + const { machineConfigPath, status } = yield* logsFor( + VALID.replace("model: claude-sonnet-5-5\n", ""), + ); + assert.deepStrictEqual(status, { + state: "idle", + reason: `factory config failed to load: ${machineConfigPath}: model: is missing`, + }); + }), +); + it.effect("clears the gate locks a dead server left and nothing else under gates", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; 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..6b8ea26f2390 --- /dev/null +++ b/apps/server/src/factory/t3/http.test.ts @@ -0,0 +1,138 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + type AuthEnvironmentScope, + AuthOrchestrationReadScope, + AuthSettingsWriteScope, + AuthStandardClientScopes, +} 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 { 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-" })), +); + +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, + issue: (scopes: ReadonlyArray) => Promise, + ) => Promise, +) => + Effect.gen(function* () { + const auth = yield* EnvironmentAuth.EnvironmentAuth; + const context = Context.make(EnvironmentAuth.EnvironmentAuth, auth).pipe( + Context.add(FactoryLoop, loopWith(status)), + ); + 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, + ), + async (scopes) => (await Effect.runPromise(auth.issueSession({ scopes }))).token, + ), + ), + ({ dispose }) => Effect.promise(dispose), + ); + }).pipe(Effect.provide(layerEnvironmentAuth)); + +const idle: FactoryStatus = { state: "idle", reason: "no factory config at /x/config.yml" }; + +describe("GET /api/factory/status", () => { + it.effect("answers 401 without credentials", () => + serving(idle, async (get) => { + const response = await get(STATUS_PATH); + 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("reports an idle factory and why", () => + serving(idle, async (get, issue) => { + const response = await get(STATUS_PATH, await issue(AuthStandardClientScopes)); + 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, issue) => { + const response = await get(STATUS_PATH, await issue(AuthStandardClientScopes)); + assert.strictEqual(response.status, 200); + assert.deepStrictEqual(await response.json(), { state: "running" }); + }), + ); +}); + +describe("the scope a route requires", () => { + it.effect("answers 403 to a session without it", () => + serving(idle, async (get, issue) => { + const response = await get(TEST_ROUTE_PATH, await issue([AuthOrchestrationReadScope])); + 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, issue) => { + const response = await get(TEST_ROUTE_PATH, await issue([AuthSettingsWriteScope])); + assert.strictEqual(response.status, 204); + }), + ); +}); From 641bc77146940406b8cedaf6408839164340a99a Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Fri, 16 Oct 2026 16:24:22 +1000 Subject: [PATCH 2/6] feat(factory): serve /api/factory/status behind T3's authentication Adds the factory route layer under factory/t3. withScope authenticates through EnvironmentAuth.authenticateHttpRequest, the call AuthHttp.layerAuthenticatedAuth makes, then checks the route's scope. FactoryLoop now carries a status (running, or idle with a reason) which GET /api/factory/status reports. server.ts merges the routes in one line. Question 1 in the design doc is answered under "Decided". Issue: #57 --- apps/server/src/factory/index.ts | 29 ++++++++++--- apps/server/src/factory/t3/auth.ts | 56 +++++++++++++++++++++++++ apps/server/src/factory/t3/http.test.ts | 1 + apps/server/src/factory/t3/http.ts | 25 +++++++++++ apps/server/src/server.ts | 2 + factory/docs/design.md | 5 ++- 6 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 apps/server/src/factory/t3/auth.ts create mode 100644 apps/server/src/factory/t3/http.ts diff --git a/apps/server/src/factory/index.ts b/apps/server/src/factory/index.ts index b01e37e3a7e2..813a87f022d5 100644 --- a/apps/server/src/factory/index.ts +++ b/apps/server/src/factory/index.ts @@ -49,11 +49,26 @@ 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). */ +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 @@ -342,11 +357,13 @@ const startFactory = (options: RuntimeOptions) => for (const error of loaded.failure.errors) { yield* Effect.logError(formatConfigError(error)); } - return idle; + return idle( + `factory config failed to load: ${loaded.failure.errors.map(formatConfigError).join("; ")}`, + ); } if (Option.isNone(loaded.success)) { yield* Effect.logInfo(`no factory config at ${machineConfigPath}`); - return idle; + return idle(`no factory config at ${machineConfigPath}`); } const machine = loaded.success.value; yield* Effect.logInfo("factory started"); @@ -365,7 +382,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..e671da368a40 --- /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 + * routes in `http.ts` do. 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 index 6b8ea26f2390..aafe76695acf 100644 --- a/apps/server/src/factory/t3/http.test.ts +++ b/apps/server/src/factory/t3/http.test.ts @@ -39,6 +39,7 @@ const layerEnvironmentAuth = EnvironmentAuth.layer.pipe( 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( 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..a2642825c45f 100644 --- a/factory/docs/design.md +++ b/factory/docs/design.md @@ -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 routes (the OTLP trace proxy and the asset route in `apps/server/src/http.ts`) call that same function and map 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. From 54fa1b3dd985c6d432c8687beae9b5473768d9f6 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Fri, 16 Oct 2026 16:29:35 +1000 Subject: [PATCH 3/6] test(factory): pin the status route's scope and its path The 200 cases hold only orchestration:read, a settings:write-only session gets 403, and one request names /api/factory/status literally. Issue: #57 --- apps/server/src/factory/t3/http.test.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/server/src/factory/t3/http.test.ts b/apps/server/src/factory/t3/http.test.ts index aafe76695acf..e27f558d48d7 100644 --- a/apps/server/src/factory/t3/http.test.ts +++ b/apps/server/src/factory/t3/http.test.ts @@ -3,7 +3,6 @@ import { type AuthEnvironmentScope, AuthOrchestrationReadScope, AuthSettingsWriteScope, - AuthStandardClientScopes, } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Context from "effect/Context"; @@ -89,9 +88,13 @@ const serving = ( const idle: FactoryStatus = { state: "idle", reason: "no factory config at /x/config.yml" }; describe("GET /api/factory/status", () => { + it("is served at the path the Factory page and the build smoke test call", () => { + assert.strictEqual(STATUS_PATH, "/api/factory/status"); + }); + it.effect("answers 401 without credentials", () => serving(idle, async (get) => { - const response = await get(STATUS_PATH); + const response = await get("/api/factory/status"); assert.strictEqual(response.status, 401); assert.strictEqual(((await response.json()) as { code: string }).code, "auth_invalid"); }), @@ -104,9 +107,17 @@ describe("GET /api/factory/status", () => { }), ); + it.effect("needs orchestration:read, the scope every reading client holds", () => + serving(idle, async (get, issue) => { + const response = await get(STATUS_PATH, await issue([AuthSettingsWriteScope])); + 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, issue) => { - const response = await get(STATUS_PATH, await issue(AuthStandardClientScopes)); + const response = await get("/api/factory/status", await issue([AuthOrchestrationReadScope])); assert.strictEqual(response.status, 200); assert.deepStrictEqual(await response.json(), idle); }), @@ -114,7 +125,7 @@ describe("GET /api/factory/status", () => { it.effect("reports a running factory with no reason", () => serving({ state: "running" }, async (get, issue) => { - const response = await get(STATUS_PATH, await issue(AuthStandardClientScopes)); + const response = await get(STATUS_PATH, await issue([AuthOrchestrationReadScope])); assert.strictEqual(response.status, 200); assert.deepStrictEqual(await response.json(), { state: "running" }); }), From dfe3aa5f89e53183853fbf3c338ed8aa0e8750fb Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Fri, 16 Oct 2026 16:29:35 +1000 Subject: [PATCH 4/6] docs(factory): name only the OTLP proxy as the plain-route auth precedent Issue: #57 --- apps/server/src/factory/t3/auth.ts | 2 +- factory/docs/design.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/factory/t3/auth.ts b/apps/server/src/factory/t3/auth.ts index e671da368a40..357f7d347327 100644 --- a/apps/server/src/factory/t3/auth.ts +++ b/apps/server/src/factory/t3/auth.ts @@ -12,7 +12,7 @@ import * as AuthHttp from "../../auth/http.ts"; * 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 - * routes in `http.ts` do. The contract error classes render themselves, so a failing route + * 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. */ diff --git a/factory/docs/design.md b/factory/docs/design.md index a2642825c45f..1d12814d1393 100644 --- a/factory/docs/design.md +++ b/factory/docs/design.md @@ -230,7 +230,7 @@ The fork replaces Rust softfac if its output is at least as good and the sync co ## 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 routes (the OTLP trace proxy and the asset route in `apps/server/src/http.ts`) call that same function and map 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. +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 From e4afa79cb27681be9b8010ae6521bf8e6ec409b1 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Fri, 16 Oct 2026 17:35:28 +1000 Subject: [PATCH 5/6] test(factory): an internal auth error answers 500; smoke-test step matches build.sh - http.test.ts: a route whose auth lookup fails inside T3 answers 500 internal_error and never runs its handler (review f2). - Session tokens are issued in the test's Effect on the live clock instead of through Effect.runPromise, which the t3code lint rule forbids in tests. - design.md step 6 describes the smoke test build.sh runs (environment 200, "factory started" in the log); it never called /api/factory/status, which needs a paired session (review f4). --- apps/server/src/factory/t3/http.test.ts | 77 ++++++++++++++++++++----- factory/docs/design.md | 2 +- 2 files changed, 65 insertions(+), 14 deletions(-) diff --git a/apps/server/src/factory/t3/http.test.ts b/apps/server/src/factory/t3/http.test.ts index e27f558d48d7..7a5fd49d533e 100644 --- a/apps/server/src/factory/t3/http.test.ts +++ b/apps/server/src/factory/t3/http.test.ts @@ -8,6 +8,7 @@ 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"; @@ -58,7 +59,7 @@ const serving = ( status: FactoryStatus, use: ( get: (path: string, token?: string) => Promise, - issue: (scopes: ReadonlyArray) => Promise, + tokens: { readonly read: string; readonly settingsWrite: string }, ) => Promise, ) => Effect.gen(function* () { @@ -66,6 +67,16 @@ const serving = ( 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 }) => @@ -78,7 +89,7 @@ const serving = ( }), context, ), - async (scopes) => (await Effect.runPromise(auth.issueSession({ scopes }))).token, + tokens, ), ), ({ dispose }) => Effect.promise(dispose), @@ -88,7 +99,7 @@ const serving = ( const idle: FactoryStatus = { state: "idle", reason: "no factory config at /x/config.yml" }; describe("GET /api/factory/status", () => { - it("is served at the path the Factory page and the build smoke test call", () => { + it("is served at the path the Factory page calls", () => { assert.strictEqual(STATUS_PATH, "/api/factory/status"); }); @@ -108,42 +119,82 @@ describe("GET /api/factory/status", () => { ); it.effect("needs orchestration:read, the scope every reading client holds", () => - serving(idle, async (get, issue) => { - const response = await get(STATUS_PATH, await issue([AuthSettingsWriteScope])); + 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, issue) => { - const response = await get("/api/factory/status", await issue([AuthOrchestrationReadScope])); + 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, issue) => { - const response = await get(STATUS_PATH, await issue([AuthOrchestrationReadScope])); + 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, issue) => { - const response = await get(TEST_ROUTE_PATH, await issue([AuthOrchestrationReadScope])); + 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, issue) => { - const response = await get(TEST_ROUTE_PATH, await issue([AuthSettingsWriteScope])); + serving(idle, async (get, tokens) => { + const response = await get(TEST_ROUTE_PATH, tokens.settingsWrite); assert.strictEqual(response.status, 204); }), ); diff --git a/factory/docs/design.md b/factory/docs/design.md index 1d12814d1393..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`. From d33aaab62c1a0d860ae8392ed27f9dc9069fff6d Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Fri, 16 Oct 2026 17:44:49 +1000 Subject: [PATCH 6/6] fix(factory): status reasons name no path or config error; say what running means GET /api/factory/status goes to every client holding orchestration:read, so the idle reasons are now the two the issue names, "no factory config" and "factory config failed to load"; the config path and each config error stay in the server log, where they already went (review f1). FactoryStatus says `running` means the pass loop started, not that passes succeed; pass health is in the log until the status route grows (#27) (review f3). --- apps/server/src/factory/index.test.ts | 20 +++++++------------- apps/server/src/factory/index.ts | 13 ++++++++----- apps/server/src/factory/t3/http.test.ts | 2 +- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/apps/server/src/factory/index.test.ts b/apps/server/src/factory/index.test.ts index 53393efd6513..5a37224e51fb 100644 --- a/apps/server/src/factory/index.test.ts +++ b/apps/server/src/factory/index.test.ts @@ -137,13 +137,12 @@ 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 { machineConfigPath, status } = yield* logsFor(undefined); - assert.deepStrictEqual(status, { - state: "idle", - reason: `no factory config at ${machineConfigPath}`, - }); + const { status } = yield* logsFor(undefined); + assert.deepStrictEqual(status, { state: "idle", reason: "no factory config" }); }), ); @@ -171,15 +170,10 @@ it.effect("logs each config error and does not start for an invalid machine file }), ); -it.effect("reports itself idle, with the config errors, when the machine file is invalid", () => +it.effect("reports itself idle, without the config errors, when the machine file is invalid", () => Effect.gen(function* () { - const { machineConfigPath, status } = yield* logsFor( - VALID.replace("model: claude-sonnet-5-5\n", ""), - ); - assert.deepStrictEqual(status, { - state: "idle", - reason: `factory config failed to load: ${machineConfigPath}: model: is missing`, - }); + const { status } = yield* logsFor(VALID.replace("model: claude-sonnet-5-5\n", "")); + assert.deepStrictEqual(status, { state: "idle", reason: "factory config failed to load" }); }), ); diff --git a/apps/server/src/factory/index.ts b/apps/server/src/factory/index.ts index 813a87f022d5..1a65b6a32e75 100644 --- a/apps/server/src/factory/index.ts +++ b/apps/server/src/factory/index.ts @@ -49,7 +49,12 @@ import { type IssueSummary, } from "./tracker/github.ts"; -/** What the factory is doing: running, or idle for a reason (no config, or one it cannot read). */ +/** + * 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 }; @@ -357,13 +362,11 @@ const startFactory = (options: RuntimeOptions) => for (const error of loaded.failure.errors) { yield* Effect.logError(formatConfigError(error)); } - return idle( - `factory config failed to load: ${loaded.failure.errors.map(formatConfigError).join("; ")}`, - ); + return idle("factory config failed to load"); } if (Option.isNone(loaded.success)) { yield* Effect.logInfo(`no factory config at ${machineConfigPath}`); - return idle(`no factory config at ${machineConfigPath}`); + return idle("no factory config"); } const machine = loaded.success.value; yield* Effect.logInfo("factory started"); diff --git a/apps/server/src/factory/t3/http.test.ts b/apps/server/src/factory/t3/http.test.ts index 7a5fd49d533e..ff7f904156ef 100644 --- a/apps/server/src/factory/t3/http.test.ts +++ b/apps/server/src/factory/t3/http.test.ts @@ -96,7 +96,7 @@ const serving = ( ); }).pipe(Effect.provide(layerEnvironmentAuth)); -const idle: FactoryStatus = { state: "idle", reason: "no factory config at /x/config.yml" }; +const idle: FactoryStatus = { state: "idle", reason: "no factory config" }; describe("GET /api/factory/status", () => { it("is served at the path the Factory page calls", () => {