From dd8a278cc3a4ac024de4d5824817096bc5106f6e Mon Sep 17 00:00:00 2001 From: Costas Panay Date: Fri, 17 Jul 2026 23:01:01 -0700 Subject: [PATCH 01/28] feat: harden Codex workplace runtime Use one long-lived app-server connection per Codex environment, multiplex native threads and routed callbacks, preserve thread-scoped MCP credentials, and permanently disable workplace analytics, auto-update, and LAN binding paths. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f047b252-1029-4093-9a8e-53456b7dd740 --- apps/desktop/src/app/DesktopConfig.ts | 23 +- .../src/backend/DesktopServerExposure.ts | 59 +- .../Layers/ProviderCommandReactor.ts | 4 + .../src/provider/Layers/CodexAdapter.test.ts | 44 + .../src/provider/Layers/CodexAdapter.ts | 76 +- .../Layers/CodexSessionRuntime.test.ts | 298 ++++--- .../provider/Layers/CodexSessionRuntime.ts | 797 +++++++++++------- .../src/telemetry/AnalyticsService.test.ts | 123 +-- apps/server/src/telemetry/AnalyticsService.ts | 177 +--- packages/contracts/src/provider.ts | 5 + .../src/client.test.ts | 55 +- .../effect-codex-app-server/src/client.ts | 86 +- .../effect-codex-app-server/src/errors.ts | 1 + 13 files changed, 985 insertions(+), 763 deletions(-) diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts index 4bf6b513306..27794590cb9 100644 --- a/apps/desktop/src/app/DesktopConfig.ts +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -13,20 +13,6 @@ const trimmedString = (name: string) => const optionalBoolean = (name: string) => Config.boolean(name).pipe(Config.option, Config.map(Option.getOrElse(() => false))); -const commaSeparatedStrings = (name: string) => - trimmedString(name).pipe( - Config.map( - Option.match({ - onNone: () => [], - onSome: (value) => - value - .split(",") - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0), - }), - ), - ); - const compactEnv = (env: Readonly>): Record => Object.fromEntries( Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined), @@ -41,14 +27,15 @@ export const DesktopConfig = Config.all({ devRemoteT3ServerEntryPath: trimmedString("T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH"), configuredBackendPort: Config.port("T3CODE_PORT").pipe(Config.option), commitHashOverride: trimmedString("T3CODE_COMMIT_HASH"), - desktopLanHostOverride: trimmedString("T3CODE_DESKTOP_LAN_HOST"), - desktopHttpsEndpointUrls: commaSeparatedStrings("T3CODE_DESKTOP_HTTPS_ENDPOINTS"), - otlpTracesUrl: trimmedString("T3CODE_OTLP_TRACES_URL"), + // Workplace builds cannot be configured into a network-exposed or OTLP-exporting mode. + desktopLanHostOverride: Config.succeed(Option.none()), + desktopHttpsEndpointUrls: Config.succeed>([]), + otlpTracesUrl: Config.succeed(Option.none()), otlpExportIntervalMs: Config.int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( Config.withDefault(10_000), ), appImagePath: trimmedString("APPIMAGE"), - disableAutoUpdate: optionalBoolean("T3CODE_DISABLE_AUTO_UPDATE"), + disableAutoUpdate: Config.succeed(true), mockUpdates: optionalBoolean("T3CODE_DESKTOP_MOCK_UPDATES"), mockUpdateServerPort: Config.port("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe( Config.withDefault(3000), diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index f04d2af7b1f..3289bfa99b1 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -28,7 +28,6 @@ import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider const TAILSCALE_STATUS_CACHE_TTL = Duration.seconds(60); export const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; -const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; interface ResolvedDesktopServerExposure { readonly mode: DesktopServerExposureMode; @@ -59,14 +58,6 @@ const DESKTOP_MANUAL_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { isAddon: false, }; -const normalizeOptionalHost = (value: string | undefined): string | undefined => { - const normalized = value?.trim(); - return normalized && normalized.length > 0 ? normalized : undefined; -}; - -const isUsableLanIpv4Address = (address: string): boolean => - !address.startsWith("127.") && !address.startsWith("169.254."); - const isHttpsEndpointUrl = (value: string): boolean => { try { return new URL(value).protocol === "https:"; @@ -75,29 +66,6 @@ const isHttpsEndpointUrl = (value: string): boolean => { } }; -const resolveLanAdvertisedHost = ( - networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces, - explicitHost: string | undefined, -): string | null => { - const normalizedExplicitHost = normalizeOptionalHost(explicitHost); - if (normalizedExplicitHost) { - return normalizedExplicitHost; - } - - for (const interfaceAddresses of Object.values(networkInterfaces)) { - if (!interfaceAddresses) continue; - - for (const address of interfaceAddresses) { - if (address.internal) continue; - if (address.family !== "IPv4") continue; - if (!isUsableLanIpv4Address(address.address)) continue; - return address.address; - } - } - - return null; -}; - const resolveDesktopServerExposure = (input: { readonly mode: DesktopServerExposureMode; readonly port: number; @@ -107,29 +75,16 @@ const resolveDesktopServerExposure = (input: { const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`; const localWsUrl = `ws://${DESKTOP_LOOPBACK_HOST}:${input.port}`; - if (input.mode === "local-only") { - return { - mode: input.mode, - bindHost: DESKTOP_LOOPBACK_HOST, - localHttpUrl, - localWsUrl, - endpointUrl: null, - advertisedHost: null, - }; - } - - const advertisedHost = resolveLanAdvertisedHost( - input.networkInterfaces, - input.advertisedHostOverride, - ); - + // Deliberately ignore the requested LAN mode. The workplace distribution + // has a compile-time loopback-only invariant rather than a preference that + // an environment variable or persisted setting can override. return { - mode: input.mode, - bindHost: DESKTOP_LAN_BIND_HOST, + mode: "local-only", + bindHost: DESKTOP_LOOPBACK_HOST, localHttpUrl, localWsUrl, - endpointUrl: advertisedHost ? `http://${advertisedHost}:${input.port}` : null, - advertisedHost, + endpointUrl: null, + advertisedHost: null, }; }; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 9c7a7c94bb1..6dc9aa898b4 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -2,6 +2,7 @@ import { type ChatAttachment, CommandId, EventId, + type MessageId, type ModelSelection, type OrchestrationEvent, ProviderDriverKind, @@ -585,6 +586,7 @@ const make = Effect.gen(function* () { const buildSendTurnRequestForThread = Effect.fnUntraced(function* (input: { readonly threadId: ThreadId; + readonly clientUserMessageId: MessageId; readonly messageText: string; readonly attachments?: ReadonlyArray; readonly modelSelection?: ModelSelection; @@ -637,6 +639,7 @@ const make = Effect.gen(function* () { return { threadId: input.threadId, + clientUserMessageId: input.clientUserMessageId, ...(normalizedInput ? { input: normalizedInput } : {}), ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}), ...(modelForTurn !== undefined ? { modelSelection: modelForTurn } : {}), @@ -839,6 +842,7 @@ const make = Effect.gen(function* () { const sendTurnRequest = yield* buildSendTurnRequestForThread({ threadId: event.payload.threadId, + clientUserMessageId: event.payload.messageId, messageText: message.text, ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), ...(event.payload.modelSelection !== undefined diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 515a7c6fcbb..9e5daf3fdaa 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -40,6 +40,7 @@ import { ProviderAdapterValidationError } from "../Errors.ts"; import type { CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { + type CodexAppServerConnection, type CodexSessionRuntimeOptions, type CodexSessionRuntimeSendTurnInput, type CodexSessionRuntimeShape, @@ -289,6 +290,49 @@ validationLayer("CodexAdapterLive validation", (it) => { ); }); +it.effect("shares one Codex app-server connection across adapter thread sessions", () => { + const runtimeFactory = makeRuntimeFactory(); + const sharedConnection = { + client: {}, + exitCode: Effect.never, + } as unknown as CodexAppServerConnection; + const makeConnection = vi.fn(() => Effect.succeed(sharedConnection)); + const layer = Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = decodeCodexSettings({}); + return yield* makeCodexAdapter(codexConfig, { + makeConnection, + makeRuntime: runtimeFactory.factory, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* CodexAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("shared-thread-1"), + runtimeMode: "full-access", + }); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("shared-thread-2"), + runtimeMode: "full-access", + }); + + NodeAssert.equal(makeConnection.mock.calls.length, 1); + NodeAssert.equal(runtimeFactory.factory.mock.calls.length, 2); + NodeAssert.strictEqual(runtimeFactory.factory.mock.calls[0]?.[0].connection, sharedConnection); + NodeAssert.strictEqual(runtimeFactory.factory.mock.calls[1]?.[0].connection, sharedConnection); + }).pipe(Effect.provide(layer)); +}); + const sessionRuntimeFactory = makeRuntimeFactory(); const sessionErrorLayer = it.layer( Layer.effect( diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 270126e934b..6e457cbf5bc 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -33,6 +33,7 @@ import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as CodexErrors from "effect-codex-app-server/errors"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; @@ -54,8 +55,11 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { CodexResumeCursorSchema, + makeCodexAppServerConnection, CodexSessionRuntimeThreadIdMissingError, makeCodexSessionRuntime, + type CodexAppServerConnection, + type CodexAppServerConnectionOptions, type CodexSessionRuntimeError, type CodexSessionRuntimeOptions, type CodexSessionRuntimeShape, @@ -80,6 +84,13 @@ export interface CodexAdapterLiveOptions { CodexSessionRuntimeError, ChildProcessSpawner.ChildProcessSpawner | Scope.Scope >; + readonly makeConnection?: ( + options: CodexAppServerConnectionOptions, + ) => Effect.Effect< + CodexAppServerConnection, + CodexErrors.CodexAppServerError, + ChildProcessSpawner.ChildProcessSpawner | Scope.Scope + >; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; } @@ -1365,6 +1376,30 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; const runtimeEventQueue = yield* Queue.unbounded(); const sessions = new Map(); + const connectionScope = yield* Scope.make("sequential"); + yield* Effect.addFinalizer(() => Scope.close(connectionScope, Exit.void)); + const sharedConnectionRef = yield* SynchronizedRef.make( + undefined, + ); + const getSharedConnection = SynchronizedRef.modifyEffect( + sharedConnectionRef, + (existingConnection) => { + if (existingConnection) { + return Effect.succeed([existingConnection, existingConnection] as const); + } + const createConnection = options?.makeConnection ?? makeCodexAppServerConnection; + return createConnection({ + binaryPath: codexConfig.binaryPath, + ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}), + ...(options?.environment ? { environment: options.environment } : {}), + cwd: serverConfig.cwd, + }).pipe( + Effect.provideService(Scope.Scope, connectionScope), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.map((connection) => [connection, connection] as const), + ); + }, + ); const startSession: CodexAdapterShape["startSession"] = (input) => Effect.scoped( @@ -1404,16 +1439,10 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ...(serviceTier ? { serviceTier } : {}), ...(mcpSession ? { - environment: { - ...(options?.environment ?? process.env), - T3_MCP_BEARER_TOKEN: mcpSession.authorizationHeader.replace(/^Bearer\s+/, ""), + mcpServer: { + endpoint: mcpSession.endpoint, + authorizationHeader: mcpSession.authorizationHeader, }, - appServerArgs: [ - "-c", - `mcp_servers.t3-code.url=${mcpSession.endpoint}`, - "-c", - 'mcp_servers.t3-code.bearer_token_env_var="T3_MCP_BEARER_TOKEN"', - ], } : {}), }; @@ -1423,7 +1452,18 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), ); const createRuntime = options?.makeRuntime ?? makeCodexSessionRuntime; - const runtime = yield* createRuntime(runtimeInput).pipe( + const runtime = yield* ( + options?.makeRuntime && !options.makeConnection + ? options.makeRuntime(runtimeInput) + : getSharedConnection.pipe( + Effect.flatMap((connection) => + createRuntime({ + ...runtimeInput, + connection, + }), + ), + ) + ).pipe( Effect.provideService(Scope.Scope, sessionScope), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), Effect.provideService(Crypto.Crypto, crypto), @@ -1537,6 +1577,9 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( : undefined; return yield* session.runtime .sendTurn({ + ...(input.clientUserMessageId !== undefined + ? { clientUserMessageId: input.clientUserMessageId } + : {}), ...(input.input !== undefined ? { input: input.input } : {}), ...(input.modelSelection?.instanceId === boundInstanceId ? { model: input.modelSelection.model } @@ -1550,7 +1593,17 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), ...(codexAttachments.length > 0 ? { attachments: codexAttachments } : {}), }) - .pipe(Effect.mapError((cause) => mapCodexRuntimeError(input.threadId, "turn/start", cause))); + .pipe( + Effect.mapError((cause) => + mapCodexRuntimeError( + input.threadId, + cause._tag === "CodexSessionRuntimeActiveTurnNotSteerableError" + ? "turn/steer" + : "turn/start", + cause, + ), + ), + ); }); const requireSession = Effect.fn("requireSession")(function* (threadId: ThreadId) { @@ -1684,6 +1737,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( yield* Effect.acquireRelease(Effect.void, () => stopAll().pipe( + Effect.andThen(Effect.ignore(Scope.close(connectionScope, Exit.void))), Effect.andThen(Queue.shutdown(runtimeEventQueue)), Effect.andThen(managedNativeEventLogger?.close() ?? Effect.void), Effect.ignore, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 1527072dae7..6576f4c80a5 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -15,11 +15,15 @@ import { } from "../CodexDeveloperInstructions.ts"; import { buildTurnStartParams, + buildTurnSteerParams, + activeTurnNotSteerableKind, hasConfiguredMcpServer, + isTurnSteerUnavailableError, isRecoverableThreadResumeError, openCodexThread, } from "./CodexSessionRuntime.ts"; const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); +const encodeUnknownJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); describe("CodexSessionRuntimeIdentifierGenerationError", () => { it("retains identifier purpose and the random source failure", () => { @@ -62,10 +66,10 @@ function makeThreadOpenResponse( } describe("buildTurnStartParams", () => { - it("keeps invalid turn values only in the schema cause", () => { - const secret = "codex-turn-input-secret-sentinel"; - const error = Effect.runSync( - buildTurnStartParams({ + it.effect("keeps invalid turn values only in the schema cause", () => + Effect.gen(function* () { + const secret = "codex-turn-input-secret-sentinel"; + const error = yield* buildTurnStartParams({ threadId: "provider-thread-1", runtimeMode: "full-access", attachments: [ @@ -74,63 +78,63 @@ describe("buildTurnStartParams", () => { url: { secret } as unknown as string, }, ], - }).pipe(Effect.flip), - ); - const { cause, ...directDiagnostics } = error; - - NodeAssert.equal(error.operation, "decode-request-payload"); - NodeAssert.equal(error.method, "turn/start"); - NodeAssert.ok((error.issueCount ?? 0) > 0); - NodeAssert.ok(error.issueKinds?.includes("Pointer")); - NodeAssert.ok((error.maximumPathDepth ?? 0) > 0); - NodeAssert.ok(Schema.isSchemaError(cause)); - NodeAssert.doesNotMatch(error.message, new RegExp(secret)); - NodeAssert.doesNotMatch(JSON.stringify(directDiagnostics), new RegExp(secret)); - }); + }).pipe(Effect.flip); + const { cause, ...directDiagnostics } = error; + + NodeAssert.equal(error.operation, "decode-request-payload"); + NodeAssert.equal(error.method, "turn/start"); + NodeAssert.ok((error.issueCount ?? 0) > 0); + NodeAssert.ok(error.issueKinds?.includes("Pointer")); + NodeAssert.ok((error.maximumPathDepth ?? 0) > 0); + NodeAssert.ok(Schema.isSchemaError(cause)); + NodeAssert.doesNotMatch(error.message, new RegExp(secret)); + NodeAssert.doesNotMatch(encodeUnknownJson(directDiagnostics), new RegExp(secret)); + }), + ); - it("includes plan collaboration mode when requested", () => { - const params = Effect.runSync( - buildTurnStartParams({ + it.effect("includes plan collaboration mode when requested", () => + Effect.gen(function* () { + const params = yield* buildTurnStartParams({ threadId: "provider-thread-1", runtimeMode: "full-access", prompt: "Make a plan", model: "gpt-5.3-codex", effort: "medium", interactionMode: "plan", - }), - ); + }); - NodeAssert.deepStrictEqual(params, { - threadId: "provider-thread-1", - approvalPolicy: "never", - sandboxPolicy: { - type: "dangerFullAccess", - }, - input: [ - { - type: "text", - text: "Make a plan", + NodeAssert.deepStrictEqual(params, { + threadId: "provider-thread-1", + approvalPolicy: "never", + sandboxPolicy: { + type: "dangerFullAccess", }, - ], - model: "gpt-5.3-codex", - effort: "medium", - collaborationMode: { - mode: "plan", - settings: { - model: "gpt-5.3-codex", - reasoning_effort: "medium", - developer_instructions: buildCodexDeveloperInstructions("plan", { + input: [ + { + type: "text", + text: "Make a plan", + }, + ], + model: "gpt-5.3-codex", + effort: "medium", + collaborationMode: { + mode: "plan", + settings: { model: "gpt-5.3-codex", - reasoningEffort: "medium", - }), + reasoning_effort: "medium", + developer_instructions: buildCodexDeveloperInstructions("plan", { + model: "gpt-5.3-codex", + reasoningEffort: "medium", + }), + }, }, - }, - }); - }); + }); + }), + ); - it("includes default collaboration mode and image attachments", () => { - const params = Effect.runSync( - buildTurnStartParams({ + it.effect("includes default collaboration mode and image attachments", () => + Effect.gen(function* () { + const params = yield* buildTurnStartParams({ threadId: "provider-thread-1", runtimeMode: "auto-accept-edits", prompt: "Implement it", @@ -142,78 +146,164 @@ describe("buildTurnStartParams", () => { url: "data:image/png;base64,abc", }, ], - }), - ); + }); - NodeAssert.deepStrictEqual(params, { - threadId: "provider-thread-1", - approvalPolicy: "on-request", - sandboxPolicy: { - type: "workspaceWrite", - }, - input: [ - { - type: "text", - text: "Implement it", - }, - { - type: "image", - url: "data:image/png;base64,abc", + NodeAssert.deepStrictEqual(params, { + threadId: "provider-thread-1", + approvalPolicy: "on-request", + sandboxPolicy: { + type: "workspaceWrite", }, - ], - model: "gpt-5.3-codex", - collaborationMode: { - mode: "default", - settings: { - model: "gpt-5.3-codex", - reasoning_effort: "medium", - developer_instructions: buildCodexDeveloperInstructions("default", { + input: [ + { + type: "text", + text: "Implement it", + }, + { + type: "image", + url: "data:image/png;base64,abc", + }, + ], + model: "gpt-5.3-codex", + collaborationMode: { + mode: "default", + settings: { model: "gpt-5.3-codex", - reasoningEffort: "medium", - }), + reasoning_effort: "medium", + developer_instructions: buildCodexDeveloperInstructions("default", { + model: "gpt-5.3-codex", + reasoningEffort: "medium", + }), + }, }, - }, - }); - }); + }); + }), + ); - it("reports the same fallback model and effort in settings and instructions", () => { - const params = Effect.runSync( - buildTurnStartParams({ + it.effect("reports the same fallback model and effort in settings and instructions", () => + Effect.gen(function* () { + const params = yield* buildTurnStartParams({ threadId: "provider-thread-1", runtimeMode: "full-access", prompt: "Go", interactionMode: "default", - }), - ); + }); - const settings = params.collaborationMode?.settings; - NodeAssert.equal(settings?.model, DEFAULT_MODEL); - NodeAssert.equal(settings?.reasoning_effort, "medium"); - NodeAssert.ok(settings?.developer_instructions?.includes(`as ${DEFAULT_MODEL} with medium`)); - }); + const settings = params.collaborationMode?.settings; + NodeAssert.equal(settings?.model, DEFAULT_MODEL); + NodeAssert.equal(settings?.reasoning_effort, "medium"); + NodeAssert.ok(settings?.developer_instructions?.includes(`as ${DEFAULT_MODEL} with medium`)); + }), + ); - it("omits collaboration mode when interaction mode is absent", () => { - const params = Effect.runSync( - buildTurnStartParams({ + it.effect("omits collaboration mode when interaction mode is absent", () => + Effect.gen(function* () { + const params = yield* buildTurnStartParams({ threadId: "provider-thread-1", runtimeMode: "approval-required", prompt: "Review", - }), - ); + }); - NodeAssert.deepStrictEqual(params, { - threadId: "provider-thread-1", - approvalPolicy: "untrusted", - sandboxPolicy: { - type: "readOnly", - }, - input: [ - { - type: "text", - text: "Review", + NodeAssert.deepStrictEqual(params, { + threadId: "provider-thread-1", + approvalPolicy: "untrusted", + sandboxPolicy: { + type: "readOnly", + }, + input: [ + { + type: "text", + text: "Review", + }, + ], + }); + }), + ); + + it.effect("scopes the T3 MCP credential to each Codex thread config", () => + Effect.gen(function* () { + const params = (yield* openCodexThread({ + client: { + request: (_method, payload) => + Effect.succeed({ + ...makeThreadOpenResponse("provider-thread-1"), + requestPayload: payload, + } as never), }, - ], + threadId: ThreadId.make("thread-1"), + runtimeMode: "full-access", + cwd: "/tmp/project", + requestedModel: undefined, + serviceTier: undefined, + resumeThreadId: undefined, + mcpServer: { + endpoint: "http://127.0.0.1:3456/mcp", + authorizationHeader: "Bearer thread-secret", + }, + })) as unknown as { readonly requestPayload: { readonly config?: unknown } }; + + NodeAssert.deepStrictEqual(params.requestPayload.config, { + mcp_servers: { + "t3-code": { + url: "http://127.0.0.1:3456/mcp", + http_headers: { + Authorization: "Bearer thread-secret", + }, + }, + }, + }); + }), + ); +}); + +describe("Codex turn steering", () => { + it.effect("builds a native steer request with the active-turn precondition and message id", () => + Effect.gen(function* () { + const params = yield* buildTurnSteerParams({ + threadId: "provider-thread-1", + expectedTurnId: "turn-active", + clientUserMessageId: "message-2", + turnInput: [{ type: "text", text: "Use the other approach" }], + }); + + NodeAssert.deepStrictEqual(params, { + threadId: "provider-thread-1", + expectedTurnId: "turn-active", + clientUserMessageId: "message-2", + input: [{ type: "text", text: "Use the other approach" }], + }); + }), + ); + + it("falls back only when turn/steer is unavailable", () => { + NodeAssert.equal( + isTurnSteerUnavailableError( + new CodexErrors.CodexAppServerRequestError({ + code: -32601, + errorMessage: "Method not found: turn/steer", + }), + ), + true, + ); + NodeAssert.equal( + isTurnSteerUnavailableError( + new CodexErrors.CodexAppServerRequestError({ + code: -32603, + errorMessage: "active turn cannot be steered", + }), + ), + false, + ); + }); + + it("recognizes structured ActiveTurnNotSteerable errors", () => { + const error = new CodexErrors.CodexAppServerRequestError({ + code: -32600, + errorMessage: "Active turn is not steerable", + data: { codexErrorInfo: { activeTurnNotSteerable: { turnKind: "review" } } }, }); + + NodeAssert.equal(activeTurnNotSteerableKind(error), "review"); }); }); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 91938b5355d..647006e6bbb 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -39,6 +39,7 @@ import { buildCodexInitializeParams } from "./CodexProvider.ts"; import { expandHomePath } from "../../pathExpansion.ts"; import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts"; const decodeV2TurnStartResponse = Schema.decodeUnknownEffect(EffectCodexSchema.V2TurnStartResponse); +const decodeV2TurnSteerParams = Schema.decodeUnknownEffect(EffectCodexSchema.V2TurnSteerParams); const PROVIDER = ProviderDriverKind.make("codex"); @@ -71,6 +72,7 @@ const CodexUserInputAnswerObject = Schema.Struct({ }); const isCodexResumeCursorSchema = Schema.is(CodexResumeCursorSchema); const isCodexUserInputAnswerObject = Schema.is(CodexUserInputAnswerObject); +const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); // TODO: Verify `packages/effect-codex-app-server/scripts/generate.ts` so the generated // `V2TurnStartParams` schema includes `collaborationMode` directly. @@ -104,9 +106,30 @@ export interface CodexSessionRuntimeOptions { readonly serviceTier?: CodexServiceTier | undefined; readonly resumeCursor?: CodexResumeCursor; readonly appServerArgs?: ReadonlyArray; + readonly mcpServer?: CodexThreadMcpServerConfig; + readonly connection?: CodexAppServerConnection; +} + +export interface CodexThreadMcpServerConfig { + readonly endpoint: string; + readonly authorizationHeader: string; +} + +export interface CodexAppServerConnectionOptions { + readonly binaryPath: string; + readonly homePath?: string; + readonly environment?: NodeJS.ProcessEnv; + readonly cwd: string; + readonly appServerArgs?: ReadonlyArray; +} + +export interface CodexAppServerConnection { + readonly client: CodexClient.CodexAppServerClient["Service"]; + readonly exitCode: ChildProcessSpawner.ChildProcessHandle["exitCode"]; } export interface CodexSessionRuntimeSendTurnInput { + readonly clientUserMessageId?: string; readonly input?: string; readonly attachments?: ReadonlyArray<{ readonly type: "image"; @@ -156,6 +179,7 @@ export type CodexSessionRuntimeError = | CodexSessionRuntimePendingApprovalNotFoundError | CodexSessionRuntimePendingUserInputNotFoundError | CodexSessionRuntimeInvalidUserInputAnswersError + | CodexSessionRuntimeActiveTurnNotSteerableError | CodexSessionRuntimeThreadIdMissingError; export class CodexSessionRuntimePendingApprovalNotFoundError extends Schema.TaggedErrorClass()( @@ -202,6 +226,21 @@ export class CodexSessionRuntimeThreadIdMissingError extends Schema.TaggedErrorC } } +export class CodexSessionRuntimeActiveTurnNotSteerableError extends Schema.TaggedErrorClass()( + "CodexSessionRuntimeActiveTurnNotSteerableError", + { + turnId: TurnId, + turnKind: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.turnKind + ? `The active Codex ${this.turnKind} turn cannot be steered.` + : "The active Codex turn cannot be steered."; + } +} + interface PendingApproval { readonly requestId: ApprovalRequestId; readonly jsonRpcId: string; @@ -288,6 +327,7 @@ function buildThreadStartParams(input: { readonly runtimeMode: RuntimeMode; readonly model: string | undefined; readonly serviceTier: CodexServiceTier | undefined; + readonly mcpServer?: CodexThreadMcpServerConfig; }): EffectCodexSchema.V2ThreadStartParams { const config = runtimeModeToThreadConfig(input.runtimeMode); return { @@ -296,6 +336,20 @@ function buildThreadStartParams(input: { sandbox: config.sandbox, ...(input.model ? { model: input.model } : {}), ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), + ...(input.mcpServer + ? { + config: { + mcp_servers: { + "t3-code": { + url: input.mcpServer.endpoint, + http_headers: { + Authorization: input.mcpServer.authorizationHeader, + }, + }, + }, + }, + } + : {}), }; } @@ -396,6 +450,69 @@ export function buildTurnStartParams(input: { ); } +export function buildTurnSteerParams(input: { + readonly threadId: string; + readonly expectedTurnId: string; + readonly clientUserMessageId: string; + readonly turnInput: ReadonlyArray; +}): Effect.Effect< + EffectCodexSchema.V2TurnSteerParams, + CodexErrors.CodexAppServerProtocolParseError +> { + return decodeV2TurnSteerParams({ + threadId: input.threadId, + expectedTurnId: input.expectedTurnId, + clientUserMessageId: input.clientUserMessageId, + input: input.turnInput, + }).pipe( + Effect.mapError((cause) => + CodexErrors.CodexAppServerProtocolParseError.fromSchemaError( + "decode-request-payload", + cause, + { method: "turn/steer" }, + ), + ), + ); +} + +export function isTurnSteerUnavailableError(error: unknown): boolean { + return ( + isCodexAppServerRequestError(error) && + (error.code === -32601 || error.errorMessage.toLowerCase().includes("method not found")) + ); +} + +function findActiveTurnNotSteerable(value: unknown): string | undefined { + if (typeof value !== "object" || value === null) { + return undefined; + } + if ("activeTurnNotSteerable" in value) { + const detail = value.activeTurnNotSteerable; + if (typeof detail === "object" && detail !== null && "turnKind" in detail) { + return typeof detail.turnKind === "string" ? detail.turnKind : "active"; + } + return "active"; + } + for (const nested of Object.values(value)) { + const turnKind = findActiveTurnNotSteerable(nested); + if (turnKind) return turnKind; + } + return undefined; +} + +export function activeTurnNotSteerableKind(error: unknown): string | undefined { + if (!isCodexAppServerRequestError(error)) { + return undefined; + } + return ( + findActiveTurnNotSteerable(error.data) ?? + (error.errorMessage.toLowerCase().includes("active turn") && + error.errorMessage.toLowerCase().includes("steer") + ? "active" + : undefined) + ); +} + function classifyCodexStderrLine(rawLine: string): { readonly message: string } | null { const line = rawLine.replaceAll(ANSI_ESCAPE_REGEX, "").trim(); if (!line) { @@ -445,6 +562,7 @@ export const openCodexThread = (input: { readonly requestedModel: string | undefined; readonly serviceTier: CodexServiceTier | undefined; readonly resumeThreadId: string | undefined; + readonly mcpServer?: CodexThreadMcpServerConfig; }): Effect.Effect => { const resumeThreadId = input.resumeThreadId; const startParams = buildThreadStartParams({ @@ -452,6 +570,7 @@ export const openCodexThread = (input: { runtimeMode: input.runtimeMode, model: input.requestedModel, serviceTier: input.serviceTier, + ...(input.mcpServer ? { mcpServer: input.mcpServer } : {}), }); if (resumeThreadId === undefined) { @@ -690,31 +809,25 @@ function parseThreadSnapshot( }; } -export const makeCodexSessionRuntime = ( - options: CodexSessionRuntimeOptions, +export const makeCodexAppServerConnection = ( + options: CodexAppServerConnectionOptions, ): Effect.Effect< - CodexSessionRuntimeShape, + CodexAppServerConnection, CodexErrors.CodexAppServerError, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope + ChildProcessSpawner.ChildProcessSpawner | Scope.Scope > => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const runtimeScope = yield* Scope.Scope; - const crypto = yield* Crypto.Crypto; - const events = yield* Queue.unbounded(); - const pendingApprovalsRef = yield* Ref.make(new Map()); - const approvalCorrelationsRef = yield* Ref.make(new Map()); - const pendingUserInputsRef = yield* Ref.make(new Map()); - const collabReceiverTurnsRef = yield* Ref.make(new Map()); - const closedRef = yield* Ref.make(false); - - // `~` is not shell-expanded when env vars are set via - // `child_process.spawn`; `expandHomePath` lets a configured - // `CODEX_HOME=~/.codex_work` reach codex as an absolute path. + const connectionScope = yield* Scope.Scope; + // `~` is not shell-expanded when env vars are set via child_process.spawn. const resolvedHomePath = options.homePath ? expandHomePath(options.homePath) : undefined; const env = { ...options.environment, ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), + DO_NOT_TRACK: "1", + OTEL_SDK_DISABLED: "true", + CODEX_TELEMETRY_DISABLED: "1", + CODEX_ANALYTICS_ENABLED: "false", }; const extendEnv = options.environment === undefined; const spawnCommand = yield* resolveSpawnCommand( @@ -733,7 +846,7 @@ export const makeCodexSessionRuntime = ( }), ) .pipe( - Effect.provideService(Scope.Scope, runtimeScope), + Effect.provideService(Scope.Scope, connectionScope), Effect.mapError( (cause) => new CodexErrors.CodexAppServerSpawnError({ @@ -745,12 +858,77 @@ export const makeCodexSessionRuntime = ( const clientContext = yield* CodexClient.layerChildProcess(child).pipe( Layer.build, - Effect.provideService(Scope.Scope, runtimeScope), + Effect.provideService(Scope.Scope, connectionScope), ); const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( Effect.provide(clientContext), ); + + const stderrRemainderRef = yield* Ref.make(""); + yield* child.stderr.pipe( + Stream.decodeText(), + Stream.runForEach((chunk) => + Ref.modify(stderrRemainderRef, (current) => { + const combined = current + chunk; + const lines = combined.split("\n"); + const remainder = lines.pop() ?? ""; + return [lines.map((line) => line.replace(/\r$/, "")), remainder] as const; + }).pipe( + Effect.flatMap((lines) => + Effect.forEach( + lines, + (line) => { + const classified = classifyCodexStderrLine(line); + return classified + ? Effect.logWarning("Codex App Server stderr", { + message: classified.message, + }) + : Effect.void; + }, + { discard: true }, + ), + ), + ), + ), + Effect.forkIn(connectionScope), + ); + + yield* client.request("initialize", buildCodexInitializeParams()); + yield* client.notify("initialized", undefined); + return { + client, + exitCode: child.exitCode, + }; + }); + +export const makeCodexSessionRuntime = ( + options: CodexSessionRuntimeOptions, +): Effect.Effect< + CodexSessionRuntimeShape, + CodexErrors.CodexAppServerError, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const runtimeScope = yield* Scope.Scope; + const crypto = yield* Crypto.Crypto; + const events = yield* Queue.unbounded(); + const pendingApprovalsRef = yield* Ref.make(new Map()); + const approvalCorrelationsRef = yield* Ref.make(new Map()); + const pendingUserInputsRef = yield* Ref.make(new Map()); + const collabReceiverTurnsRef = yield* Ref.make(new Map()); + const closedRef = yield* Ref.make(false); + const connection = + options.connection ?? + (yield* makeCodexAppServerConnection({ + binaryPath: options.binaryPath, + ...(options.homePath ? { homePath: options.homePath } : {}), + ...(options.environment ? { environment: options.environment } : {}), + cwd: options.cwd, + ...(options.appServerArgs ? { appServerArgs: options.appServerArgs } : {}), + })); + const client = connection.client; const serverNotifications = yield* Queue.unbounded(); + let routedProviderThreadId = readResumeCursorThreadId(options.resumeCursor); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const randomUUIDv4 = (purpose: CodexErrors.CodexAppServerIdentifierPurpose) => crypto.randomUUIDv4.pipe( @@ -827,8 +1005,18 @@ export const makeCodexSessionRuntime = ( const payload = notification.params; const route = readRouteFields(notification); const collabReceiverTurns = yield* Ref.get(collabReceiverTurnsRef); + const notificationThreadId = readNotificationThreadId(notification); + if ( + notificationThreadId && + ((!routedProviderThreadId && !collabReceiverTurns.has(notificationThreadId)) || + (routedProviderThreadId && + notificationThreadId !== routedProviderThreadId && + !collabReceiverTurns.has(notificationThreadId))) + ) { + return; + } const childParentTurnId = (() => { - const providerConversationId = readNotificationThreadId(notification); + const providerConversationId = notificationThreadId; return providerConversationId ? collabReceiverTurns.get(providerConversationId) : undefined; @@ -882,245 +1070,257 @@ export const makeCodexSessionRuntime = ( }); }); - const currentSessionProviderThreadId = Effect.map(Ref.get(sessionRef), currentProviderThreadId); - - yield* client.handleServerNotification("thread/started", (payload) => - currentSessionProviderThreadId.pipe( - Effect.flatMap((providerThreadId) => { - if (providerThreadId && payload.thread.id !== providerThreadId) { - return Effect.void; - } - return updateSession(sessionRef, { - resumeCursor: { threadId: payload.thread.id }, - }); - }), - ), - ); - - yield* client.handleServerNotification("turn/started", (payload) => - currentSessionProviderThreadId.pipe( - Effect.flatMap((providerThreadId) => { - if (providerThreadId && payload.threadId !== providerThreadId) { - return Effect.void; - } - return updateSession(sessionRef, { - status: "running", - activeTurnId: TurnId.make(payload.turn.id), - }); + const registerSessionNotification = ( + method: M, + accepts: (payload: CodexRpc.ServerNotificationParamsByMethod[M]) => boolean, + handler: ( + payload: CodexRpc.ServerNotificationParamsByMethod[M], + ) => Effect.Effect, + ) => + client + .registerServerNotification(method, accepts, handler) + .pipe(Effect.flatMap((unregister) => Effect.addFinalizer(() => unregister))); + + yield* registerSessionNotification( + "thread/started", + (payload) => payload.thread.id === routedProviderThreadId, + (payload) => + updateSession(sessionRef, { + resumeCursor: { threadId: payload.thread.id }, }), - ), ); - yield* client.handleServerNotification("turn/completed", (payload) => - currentSessionProviderThreadId.pipe( - Effect.flatMap((providerThreadId) => { - if (providerThreadId && payload.threadId !== providerThreadId) { - return Effect.void; - } - const lastError = - payload.turn.status === "failed" && "error" in payload.turn && payload.turn.error - ? payload.turn.error.message - : undefined; - return updateSession(sessionRef, { - status: payload.turn.status === "failed" ? "error" : "ready", - activeTurnId: undefined, - ...(lastError ? { lastError } : {}), - }); + yield* registerSessionNotification( + "turn/started", + (payload) => payload.threadId === routedProviderThreadId, + (payload) => + updateSession(sessionRef, { + status: "running", + activeTurnId: TurnId.make(payload.turn.id), }), - ), ); - yield* client.handleServerNotification("error", (payload) => - currentSessionProviderThreadId.pipe( - Effect.flatMap((providerThreadId) => { - const payloadThreadId = payload.threadId; - if (providerThreadId && payloadThreadId && payloadThreadId !== providerThreadId) { - return Effect.void; - } - const errorMessage = payload.error.message; - const willRetry = payload.willRetry; - return updateSession(sessionRef, { - status: willRetry ? "running" : "error", - ...(errorMessage ? { lastError: errorMessage } : {}), - }); - }), - ), + yield* registerSessionNotification( + "turn/completed", + (payload) => payload.threadId === routedProviderThreadId, + (payload) => { + const lastError = + payload.turn.status === "failed" && "error" in payload.turn && payload.turn.error + ? payload.turn.error.message + : undefined; + return updateSession(sessionRef, { + status: payload.turn.status === "failed" ? "error" : "ready", + activeTurnId: undefined, + ...(lastError ? { lastError } : {}), + }); + }, ); - yield* client.handleServerRequest("item/commandExecution/requestApproval", (payload) => - Effect.gen(function* () { - const requestId = ApprovalRequestId.make(yield* randomUUIDv4("command-approval-request")); - const turnId = TurnId.make(payload.turnId); - const itemId = ProviderItemId.make(payload.itemId); - const decision = yield* Deferred.make(); - - yield* Ref.update(pendingApprovalsRef, (current) => { - const next = new Map(current); - next.set(requestId, { - requestId, - jsonRpcId: payload.approvalId ?? payload.itemId, - requestKind: "command", - turnId, - itemId, - decision, - }); - return next; - }); - yield* Ref.update(approvalCorrelationsRef, (current) => { - const next = new Map(current); - next.set(payload.approvalId ?? payload.itemId, { - requestId, - requestKind: "command", - turnId, - itemId, - }); - return next; + yield* registerSessionNotification( + "error", + (payload) => !payload.threadId || payload.threadId === routedProviderThreadId, + (payload) => { + const errorMessage = payload.error.message; + const willRetry = payload.willRetry; + return updateSession(sessionRef, { + status: willRetry ? "running" : "error", + ...(errorMessage ? { lastError: errorMessage } : {}), }); + }, + ); - yield* emitEvent({ - kind: "request", - threadId: options.threadId, - method: "item/commandExecution/requestApproval", - requestId, - requestKind: "command", - ...(turnId ? { turnId } : {}), - ...(itemId ? { itemId } : {}), - payload, - }); + yield* client + .registerServerRequest( + "item/commandExecution/requestApproval", + (payload) => payload.threadId === routedProviderThreadId, + (payload) => + Effect.gen(function* () { + const requestId = ApprovalRequestId.make( + yield* randomUUIDv4("command-approval-request"), + ); + const turnId = TurnId.make(payload.turnId); + const itemId = ProviderItemId.make(payload.itemId); + const decision = yield* Deferred.make(); - const resolved = yield* Deferred.await(decision).pipe( - Effect.ensuring( - Ref.update(pendingApprovalsRef, (current) => { + yield* Ref.update(pendingApprovalsRef, (current) => { const next = new Map(current); - next.delete(requestId); + next.set(requestId, { + requestId, + jsonRpcId: payload.approvalId ?? payload.itemId, + requestKind: "command", + turnId, + itemId, + decision, + }); return next; - }), - ), - ); - return { - decision: resolved, - } satisfies EffectCodexSchema.CommandExecutionRequestApprovalResponse; - }), - ); + }); + yield* Ref.update(approvalCorrelationsRef, (current) => { + const next = new Map(current); + next.set(payload.approvalId ?? payload.itemId, { + requestId, + requestKind: "command", + turnId, + itemId, + }); + return next; + }); - yield* client.handleServerRequest("item/fileChange/requestApproval", (payload) => - Effect.gen(function* () { - const requestId = ApprovalRequestId.make( - yield* randomUUIDv4("file-change-approval-request"), - ); - const turnId = TurnId.make(payload.turnId); - const itemId = ProviderItemId.make(payload.itemId); - const decision = yield* Deferred.make(); - - yield* Ref.update(pendingApprovalsRef, (current) => { - const next = new Map(current); - next.set(requestId, { - requestId, - jsonRpcId: payload.itemId, - requestKind: "file-change", - turnId, - itemId, - decision, - }); - return next; - }); - yield* Ref.update(approvalCorrelationsRef, (current) => { - const next = new Map(current); - next.set(payload.itemId, { - requestId, - requestKind: "file-change", - turnId, - itemId, - }); - return next; - }); + yield* emitEvent({ + kind: "request", + threadId: options.threadId, + method: "item/commandExecution/requestApproval", + requestId, + requestKind: "command", + ...(turnId ? { turnId } : {}), + ...(itemId ? { itemId } : {}), + payload, + }); - yield* emitEvent({ - kind: "request", - threadId: options.threadId, - method: "item/fileChange/requestApproval", - requestId, - requestKind: "file-change", - ...(turnId ? { turnId } : {}), - ...(itemId ? { itemId } : {}), - payload, - }); + const resolved = yield* Deferred.await(decision).pipe( + Effect.ensuring( + Ref.update(pendingApprovalsRef, (current) => { + const next = new Map(current); + next.delete(requestId); + return next; + }), + ), + ); + return { + decision: resolved, + } satisfies EffectCodexSchema.CommandExecutionRequestApprovalResponse; + }), + ) + .pipe(Effect.flatMap((unregister) => Effect.addFinalizer(() => unregister))); + + yield* client + .registerServerRequest( + "item/fileChange/requestApproval", + (payload) => payload.threadId === routedProviderThreadId, + (payload) => + Effect.gen(function* () { + const requestId = ApprovalRequestId.make( + yield* randomUUIDv4("file-change-approval-request"), + ); + const turnId = TurnId.make(payload.turnId); + const itemId = ProviderItemId.make(payload.itemId); + const decision = yield* Deferred.make(); - const resolved = yield* Deferred.await(decision).pipe( - Effect.ensuring( - Ref.update(pendingApprovalsRef, (current) => { + yield* Ref.update(pendingApprovalsRef, (current) => { const next = new Map(current); - next.delete(requestId); + next.set(requestId, { + requestId, + jsonRpcId: payload.itemId, + requestKind: "file-change", + turnId, + itemId, + decision, + }); return next; - }), - ), - ); - return { - decision: resolved, - } satisfies EffectCodexSchema.FileChangeRequestApprovalResponse; - }), - ); - - yield* client.handleServerRequest("item/tool/requestUserInput", (payload) => - Effect.gen(function* () { - const requestId = ApprovalRequestId.make(yield* randomUUIDv4("user-input-request")); - const turnId = TurnId.make(payload.turnId); - const itemId = ProviderItemId.make(payload.itemId); - const answers = yield* Deferred.make(); - - yield* Ref.update(pendingUserInputsRef, (current) => { - const next = new Map(current); - next.set(requestId, { - requestId, - turnId, - itemId, - answers, - }); - return next; - }); + }); + yield* Ref.update(approvalCorrelationsRef, (current) => { + const next = new Map(current); + next.set(payload.itemId, { + requestId, + requestKind: "file-change", + turnId, + itemId, + }); + return next; + }); - yield* emitEvent({ - kind: "request", - threadId: options.threadId, - method: "item/tool/requestUserInput", - requestId, - ...(turnId ? { turnId } : {}), - ...(itemId ? { itemId } : {}), - payload, - }); + yield* emitEvent({ + kind: "request", + threadId: options.threadId, + method: "item/fileChange/requestApproval", + requestId, + requestKind: "file-change", + ...(turnId ? { turnId } : {}), + ...(itemId ? { itemId } : {}), + payload, + }); - const resolvedAnswers = yield* Deferred.await(answers).pipe( - Effect.ensuring( - Ref.update(pendingUserInputsRef, (current) => { + const resolved = yield* Deferred.await(decision).pipe( + Effect.ensuring( + Ref.update(pendingApprovalsRef, (current) => { + const next = new Map(current); + next.delete(requestId); + return next; + }), + ), + ); + return { + decision: resolved, + } satisfies EffectCodexSchema.FileChangeRequestApprovalResponse; + }), + ) + .pipe(Effect.flatMap((unregister) => Effect.addFinalizer(() => unregister))); + + yield* client + .registerServerRequest( + "item/tool/requestUserInput", + (payload) => payload.threadId === routedProviderThreadId, + (payload) => + Effect.gen(function* () { + const requestId = ApprovalRequestId.make(yield* randomUUIDv4("user-input-request")); + const turnId = TurnId.make(payload.turnId); + const itemId = ProviderItemId.make(payload.itemId); + const answers = yield* Deferred.make(); + + yield* Ref.update(pendingUserInputsRef, (current) => { const next = new Map(current); - next.delete(requestId); + next.set(requestId, { + requestId, + turnId, + itemId, + answers, + }); return next; - }), - ), - ); - - return { - answers: yield* toCodexUserInputAnswers(resolvedAnswers).pipe( - Effect.mapError((error) => - CodexErrors.CodexAppServerRequestError.invalidParams(error.message, { - questionId: error.questionId, - }), - ), - ), - } satisfies EffectCodexSchema.ToolRequestUserInputResponse; - }), - ); + }); - yield* client.handleUnknownServerRequest((method) => - Effect.fail(CodexErrors.CodexAppServerRequestError.methodNotFound(method)), - ); + yield* emitEvent({ + kind: "request", + threadId: options.threadId, + method: "item/tool/requestUserInput", + requestId, + ...(turnId ? { turnId } : {}), + ...(itemId ? { itemId } : {}), + payload, + }); + + const resolvedAnswers = yield* Deferred.await(answers).pipe( + Effect.ensuring( + Ref.update(pendingUserInputsRef, (current) => { + const next = new Map(current); + next.delete(requestId); + return next; + }), + ), + ); + + return { + answers: yield* toCodexUserInputAnswers(resolvedAnswers).pipe( + Effect.mapError((error) => + CodexErrors.CodexAppServerRequestError.invalidParams(error.message, { + questionId: error.questionId, + }), + ), + ), + } satisfies EffectCodexSchema.ToolRequestUserInputResponse; + }), + ) + .pipe(Effect.flatMap((unregister) => Effect.addFinalizer(() => unregister))); const registerServerNotification = (method: M) => - client.handleServerNotification(method, (params) => - Queue.offer(serverNotifications, makeCodexServerNotification(method, params)).pipe( - Effect.asVoid, - ), - ); + client + .registerServerNotification( + method, + () => true, + (params) => + Queue.offer(serverNotifications, makeCodexServerNotification(method, params)).pipe( + Effect.asVoid, + ), + ) + .pipe(Effect.flatMap((unregister) => Effect.addFinalizer(() => unregister))); yield* Effect.forEach( Object.values( @@ -1135,40 +1335,7 @@ export const makeCodexSessionRuntime = ( Effect.forkIn(runtimeScope), ); - const stderrRemainderRef = yield* Ref.make(""); - yield* child.stderr.pipe( - Stream.decodeText(), - Stream.runForEach((chunk) => - Ref.modify(stderrRemainderRef, (current) => { - const combined = current + chunk; - const lines = combined.split("\n"); - const remainder = lines.pop() ?? ""; - return [lines.map((line) => line.replace(/\r$/, "")), remainder] as const; - }).pipe( - Effect.flatMap((lines) => - Effect.forEach( - lines, - (line) => { - const classified = classifyCodexStderrLine(line); - if (!classified) { - return Effect.void; - } - return emitEvent({ - kind: "notification", - threadId: options.threadId, - method: "process/stderr", - message: classified.message, - }); - }, - { discard: true }, - ), - ), - ), - ), - Effect.forkIn(runtimeScope), - ); - - yield* child.exitCode.pipe( + yield* connection.exitCode.pipe( Effect.flatMap((exitCode) => Ref.get(closedRef).pipe( Effect.flatMap((closed) => { @@ -1196,9 +1363,7 @@ export const makeCodexSessionRuntime = ( ); const start = Effect.fn("CodexSessionRuntime.start")(function* () { - yield* emitSessionEvent("session/connecting", "Starting Codex App Server session."); - yield* client.request("initialize", buildCodexInitializeParams()); - yield* client.notify("initialized", undefined); + yield* emitSessionEvent("session/connecting", "Opening Codex App Server thread."); const requestedModel = normalizeCodexModelSlug(options.model); @@ -1210,9 +1375,11 @@ export const makeCodexSessionRuntime = ( requestedModel, serviceTier: options.serviceTier, resumeThreadId: readResumeCursorThreadId(options.resumeCursor), + ...(options.mcpServer ? { mcpServer: options.mcpServer } : {}), }); const providerThreadId = opened.thread.id; + routedProviderThreadId = providerThreadId; const session = { ...(yield* Ref.get(sessionRef)), status: "ready", @@ -1222,7 +1389,7 @@ export const makeCodexSessionRuntime = ( updatedAt: yield* nowIso, } satisfies ProviderSession; yield* Ref.set(sessionRef, session); - yield* emitSessionEvent("session/ready", "Codex App Server session ready."); + yield* emitSessionEvent("session/ready", "Codex App Server thread ready."); return session; }); @@ -1263,7 +1430,7 @@ export const makeCodexSessionRuntime = ( sendTurn: (input) => Effect.gen(function* () { const providerThreadId = yield* readProviderThreadId; - if (hasConfiguredMcpServer(options.appServerArgs)) { + if (options.mcpServer || hasConfiguredMcpServer(options.appServerArgs)) { yield* client.request("config/mcpServer/reload", undefined).pipe( Effect.catch((cause) => Effect.logWarning("Failed to refresh Codex MCP tool catalog before turn.", { @@ -1285,17 +1452,80 @@ export const makeCodexSessionRuntime = ( ...(input.effort ? { effort: input.effort } : {}), ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), }); - const rawResponse = yield* client.raw.request("turn/start", params); - const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( - Effect.mapError((error) => - CodexErrors.CodexAppServerProtocolParseError.fromSchemaError( - "decode-response-payload", - error, - { method: "turn/start" }, - ), - ), - ); - const turnId = TurnId.make(response.turn.id); + const sessionBeforeSend = yield* Ref.get(sessionRef); + const activeTurnId = + sessionBeforeSend.status === "running" ? sessionBeforeSend.activeTurnId : undefined; + + const startTurn = (delivery: "started" | "queued") => + Effect.gen(function* () { + const rawResponse = yield* client.raw.request("turn/start", params); + const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( + Effect.mapError((error) => + CodexErrors.CodexAppServerProtocolParseError.fromSchemaError( + "decode-response-payload", + error, + { method: "turn/start" }, + ), + ), + ); + return { turnId: TurnId.make(response.turn.id), delivery } as const; + }); + + const accepted = activeTurnId + ? yield* Effect.gen(function* () { + const clientUserMessageId = + input.clientUserMessageId ?? (yield* randomUUIDv4("steer-message")); + const steerParams = yield* buildTurnSteerParams({ + threadId: providerThreadId, + expectedTurnId: activeTurnId, + clientUserMessageId, + turnInput: params.input, + }); + return yield* client.request("turn/steer", steerParams).pipe( + Effect.map((response) => ({ + turnId: TurnId.make(response.turnId), + delivery: "steered" as const, + })), + Effect.tap((result) => + emitEvent({ + kind: "notification", + threadId: options.threadId, + method: "turn/steered", + turnId: result.turnId, + payload: { clientUserMessageId }, + }), + ), + Effect.catchIf(isTurnSteerUnavailableError, () => + startTurn("queued").pipe( + Effect.tap((result) => + emitEvent({ + kind: "notification", + threadId: options.threadId, + method: "turn/queued", + turnId: result.turnId, + payload: { + clientUserMessageId, + reason: "turn/steer is unavailable", + }, + }), + ), + ), + ), + Effect.catchIf( + (error) => activeTurnNotSteerableKind(error) !== undefined, + (error) => + Effect.fail( + new CodexSessionRuntimeActiveTurnNotSteerableError({ + turnId: activeTurnId, + turnKind: activeTurnNotSteerableKind(error), + cause: error, + }), + ), + ), + ); + }) + : yield* startTurn("started"); + const { turnId, delivery } = accepted; yield* updateSession(sessionRef, { status: "running", activeTurnId: turnId, @@ -1305,6 +1535,7 @@ export const makeCodexSessionRuntime = ( return { threadId: options.threadId, turnId, + delivery, ...(resumedProviderThreadId ? { resumeCursor: { threadId: resumedProviderThreadId } } : {}), diff --git a/apps/server/src/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts index d69bab32feb..fcb31f6b4ea 100644 --- a/apps/server/src/telemetry/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/AnalyticsService.test.ts @@ -1,120 +1,13 @@ -import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; -import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as HttpServer from "effect/unstable/http/HttpServer"; -import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; -import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; -import * as ServerConfig from "../config.ts"; -import { getTelemetryIdentifier } from "./Identify.ts"; import * as AnalyticsService from "./AnalyticsService.ts"; -interface RecordedBatchRequest { - readonly path: string; - readonly body: { - readonly batch?: ReadonlyArray<{ - readonly event?: string; - readonly properties?: { - readonly index?: number; - readonly clientType?: string; - }; - }>; - } | null; -} - -interface RecordedBatchBody { - readonly batch: ReadonlyArray<{ - readonly event?: string; - readonly properties?: { - readonly index?: number; - readonly clientType?: string; - }; - }>; -} - -it.layer(NodeServices.layer)("AnalyticsService test", (it) => { - it.effect("flush drains all buffered events across multiple batches", () => - Effect.gen(function* () { - const capturedRequests: Array = []; - const serverConfigLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { - prefix: "t3-telemetry-base-", - }); - - const telemetryLayer = AnalyticsService.layer.pipe(Layer.provideMerge(serverConfigLayer)); - const configLayer = ConfigProvider.layer( - ConfigProvider.fromUnknown({ - T3CODE_TELEMETRY_ENABLED: true, - T3CODE_POSTHOG_KEY: "phc_test_key", - T3CODE_POSTHOG_HOST: "", - T3CODE_TELEMETRY_FLUSH_BATCH_SIZE: 20, - }), - ); - const batchServerLayer = HttpServer.serve( - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - if (request.method !== "POST") { - return HttpServerResponse.empty({ status: 404 }); - } - - const payload = yield* request.json.pipe( - Effect.map((body) => body as RecordedBatchRequest["body"]), - Effect.orElseSucceed(() => null), - ); - - capturedRequests.push({ path: request.url, body: payload }); - - return HttpServerResponse.jsonUnsafe({}); - }), - ); - const runtimeLayer = telemetryLayer.pipe( - Layer.provide(configLayer), - Layer.provideMerge(NodeHttpServer.layerTest), - ); - - yield* Effect.gen(function* () { - yield* Layer.launch(batchServerLayer).pipe(Effect.forkScoped); - const telemetryIdentifier = yield* getTelemetryIdentifier; - assert.equal(telemetryIdentifier !== null, true); - const analytics = yield* AnalyticsService.AnalyticsService; - - for (let index = 0; index < 45; index += 1) { - yield* analytics.record("test.flush.drain", { index }); - } - - yield* analytics.flush; - }).pipe(Effect.provide(runtimeLayer)); - - const batchRequests = capturedRequests.filter( - (request): request is RecordedBatchRequest & { readonly body: RecordedBatchBody } => - Array.isArray(request.body?.batch), - ); - assert.equal(batchRequests.length, 3); - assert.equal( - batchRequests.every((request) => request.path === "/batch/" || request.path === "/batch"), - true, - ); - const deliveredIndexes = batchRequests.flatMap((request) => - request.body.batch - .filter((event) => event.event === "test.flush.drain") - .map((event) => event.properties?.index) - .filter((index): index is number => typeof index === "number"), - ); - - const sorted = deliveredIndexes.toSorted((a, b) => a - b); - assert.equal(sorted.length, 45); - assert.deepEqual( - sorted, - Array.from({ length: 45 }, (_, index) => index), - ); - assert.equal( - batchRequests.every((request) => - request.body.batch.every((event) => event.properties?.clientType === "cli-web-client"), - ), - true, - ); - }), - ); -}); +it.effect("uses a permanent no-op analytics layer in workplace builds", () => + Effect.gen(function* () { + const analytics = yield* AnalyticsService.AnalyticsService; + yield* analytics.record("must.not.leave.device", { secret: "local-only" }); + yield* analytics.flush; + assert.ok(true); + }).pipe(Effect.provide(AnalyticsService.layer)), +); diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index 5fdc7bdeb19..c891d557085 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -1,186 +1,35 @@ /** - * Anonymous PostHog telemetry service. + * Workplace build analytics boundary. * - * Persists an installation-scoped anonymous identifier, buffers events in - * memory, and flushes batches over Effect's HTTP client. - * - * @module AnalyticsService + * The upstream project sends anonymous product events to PostHog. This fork + * deliberately keeps the service API so feature code remains easy to rebase, + * but the production layer is a compile-time no-op with no HTTP client, host, + * API key, identifier, buffer, timer, or network implementation in its + * dependency graph. */ -import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import * as Config from "effect/Config"; import * as Context from "effect/Context"; -import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Ref from "effect/Ref"; -import * as HttpClient from "effect/unstable/http/HttpClient"; -import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; -import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; - -import packageJson from "../../package.json" with { type: "json" }; -import * as ServerConfig from "../config.ts"; -import { getTelemetryIdentifier } from "./Identify.ts"; - -interface BufferedAnalyticsEvent { - readonly event: string; - readonly properties?: Readonly>; - readonly capturedAt: string; -} - -const TelemetryEnvConfig = Config.all({ - posthogKey: Config.string("T3CODE_POSTHOG_KEY").pipe( - Config.withDefault("phc_XOWci4oZP4VvLiEyrFqkFjP4CZn55mjYYBMREK5Wd6m"), - ), - posthogHost: Config.string("T3CODE_POSTHOG_HOST").pipe( - Config.withDefault("https://us.i.posthog.com"), - ), - enabled: Config.boolean("T3CODE_TELEMETRY_ENABLED").pipe(Config.withDefault(true)), - flushBatchSize: Config.number("T3CODE_TELEMETRY_FLUSH_BATCH_SIZE").pipe(Config.withDefault(20)), - maxBufferedEvents: Config.number("T3CODE_TELEMETRY_MAX_BUFFERED_EVENTS").pipe( - Config.withDefault(1_000), - ), - wslDistroName: Config.string("WSL_DISTRO_NAME").pipe(Config.option), -}); export class AnalyticsService extends Context.Service< AnalyticsService, { - /** Record an anonymous event for best-effort buffered delivery. */ readonly record: ( event: string, properties?: Readonly>, ) => Effect.Effect; - - /** Flush all currently queued telemetry events. */ readonly flush: Effect.Effect; } >()("t3/telemetry/AnalyticsService") { - /** No-op layer for callers that intentionally disable telemetry. */ - static readonly layerTest = Layer.succeed( - AnalyticsService, - AnalyticsService.of({ - record: () => Effect.void, - flush: Effect.void, - }), - ); -} - -export const make = Effect.gen(function* () { - const telemetryConfig = yield* TelemetryEnvConfig; - const httpClient = yield* HttpClient.HttpClient; - const serverConfig = yield* ServerConfig.ServerConfig; - const identifier = yield* getTelemetryIdentifier; - const bufferRef = yield* Ref.make>([]); - const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client"; - const hostPlatform = yield* HostProcessPlatform; - const hostArchitecture = yield* HostProcessArchitecture; - - const enqueueBufferedEvent = (event: string, properties?: Readonly>) => - Effect.flatMap(DateTime.now, (now) => - Ref.modify(bufferRef, (current) => { - const appended = [ - ...current, - { - event, - ...(properties ? { properties } : {}), - capturedAt: DateTime.formatIso(now), - } satisfies BufferedAnalyticsEvent, - ]; - - const next = - appended.length > telemetryConfig.maxBufferedEvents - ? appended.slice(appended.length - telemetryConfig.maxBufferedEvents) - : appended; - - return [ - { - size: next.length, - dropped: next.length !== appended.length, - } as const, - next, - ] as const; - }), - ); - - const sendBatch = Effect.fn("AnalyticsService.sendBatch")(function* ( - events: ReadonlyArray, - ) { - if (!telemetryConfig.enabled || !identifier) return; - - const payload = { - api_key: telemetryConfig.posthogKey, - batch: events.map((event) => ({ - event: event.event, - distinct_id: identifier, - properties: { - ...event.properties, - $process_person_profile: false, - platform: hostPlatform, - wsl: Option.getOrUndefined(telemetryConfig.wslDistroName), - arch: hostArchitecture, - t3CodeVersion: packageJson.version, - clientType, - }, - timestamp: event.capturedAt, - })), - }; - - yield* HttpClientRequest.post(`${telemetryConfig.posthogHost}/batch/`).pipe( - HttpClientRequest.bodyJson(payload), - Effect.flatMap(httpClient.execute), - Effect.flatMap(HttpClientResponse.filterStatusOk), - ); + static readonly disabled = AnalyticsService.of({ + record: () => Effect.void, + flush: Effect.void, }); - const flush: AnalyticsService["Service"]["flush"] = Effect.gen(function* () { - while (true) { - const batch = yield* Ref.modify(bufferRef, (current) => { - if (current.length === 0) { - return [[] as ReadonlyArray, current] as const; - } - const nextBatch = current.slice(0, telemetryConfig.flushBatchSize); - const remaining = current.slice(nextBatch.length); - return [nextBatch, remaining] as const; - }); - - if (batch.length === 0) { - return; - } - - yield* sendBatch(batch).pipe( - Effect.catch((error) => - Ref.update(bufferRef, (current) => [...batch, ...current]).pipe( - Effect.flatMap(() => Effect.fail(error)), - ), - ), - ); - } - }).pipe(Effect.catch((cause) => Effect.logError("Failed to flush telemetry", { cause }))); - - const record: AnalyticsService["Service"]["record"] = Effect.fn("AnalyticsService.record")( - function* (event, properties) { - if (!telemetryConfig.enabled || !identifier) return; - - const enqueueResult = yield* enqueueBufferedEvent(event, properties); - if (enqueueResult.dropped) { - yield* Effect.logDebug("analytics buffer full; dropping oldest event", { - size: enqueueResult.size, - event, - }); - } - }, - ); - - yield* Effect.forever(Effect.sleep(1000).pipe(Effect.flatMap(() => flush)), { - disableYield: true, - }).pipe(Effect.forkScoped); - - yield* Effect.addFinalizer(() => flush); - - return AnalyticsService.of({ record, flush }); -}); + static readonly layerTest = Layer.succeed(AnalyticsService, AnalyticsService.disabled); +} -export const layer = Layer.effect(AnalyticsService, make); +/** Analytics is permanently disabled in the workplace distribution. */ +export const layer = AnalyticsService.layerTest; export const layerTest = AnalyticsService.layerTest; diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 94fb007a7bc..229789d9618 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -5,6 +5,7 @@ import { EventId, IsoDateTime, ProviderItemId, + MessageId, ThreadId, TurnId, } from "./baseSchemas.ts"; @@ -66,6 +67,8 @@ export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type; export const ProviderSendTurnInput = Schema.Struct({ threadId: ThreadId, + /** Client-generated user message id used to reconcile native provider steering. */ + clientUserMessageId: Schema.optional(MessageId), input: Schema.optional( TrimmedNonEmptyString.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)), ), @@ -80,6 +83,8 @@ export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type; export const ProviderTurnStartResult = Schema.Struct({ threadId: ThreadId, turnId: TurnId, + /** How the provider accepted this message. */ + delivery: Schema.optional(Schema.Literals(["started", "steered", "queued"])), resumeCursor: Schema.optional(Schema.Unknown), }); export type ProviderTurnStartResult = typeof ProviderTurnStartResult.Type; diff --git a/packages/effect-codex-app-server/src/client.test.ts b/packages/effect-codex-app-server/src/client.test.ts index 3830c5fc5f6..f53050c6171 100644 --- a/packages/effect-codex-app-server/src/client.test.ts +++ b/packages/effect-codex-app-server/src/client.test.ts @@ -33,6 +33,8 @@ it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => { Effect.gen(function* () { const userInputRequests = yield* Ref.make>([]); const messageDeltas = yield* Ref.make>([]); + const misroutedRequests = yield* Ref.make(0); + const misroutedNotifications = yield* Ref.make(0); const handle = yield* makeHandle(); const scope = yield* Scope.make(); const clientLayer = CodexClient.layerChildProcess(handle); @@ -41,20 +43,40 @@ it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => { const result = yield* Effect.gen(function* () { const client = yield* CodexClient.CodexAppServerClient; - yield* client.handleServerRequest("item/tool/requestUserInput", (payload) => - Ref.update(userInputRequests, (current) => [...current, payload]).pipe( - Effect.as({ - answers: { - approved: { - answers: ["yes"], + const unregisterWrongRequestRoute = yield* client.registerServerRequest( + "item/tool/requestUserInput", + (payload) => payload.threadId === "thread-other", + () => + Ref.update(misroutedRequests, (count) => count + 1).pipe( + Effect.as({ + answers: {}, + }), + ), + ); + const unregisterRequestRoute = yield* client.registerServerRequest( + "item/tool/requestUserInput", + (payload) => payload.threadId === "thread-1", + (payload) => + Ref.update(userInputRequests, (current) => [...current, payload]).pipe( + Effect.as({ + answers: { + approved: { + answers: ["yes"], + }, }, - }, - }), - ), + }), + ), ); - yield* client.handleServerNotification("item/agentMessage/delta", (payload) => - Ref.update(messageDeltas, (current) => [...current, payload]), + const unregisterWrongNotificationRoute = yield* client.registerServerNotification( + "item/agentMessage/delta", + (payload) => payload.threadId === "thread-other", + () => Ref.update(misroutedNotifications, (count) => count + 1), + ); + const unregisterNotificationRoute = yield* client.registerServerNotification( + "item/agentMessage/delta", + (payload) => payload.threadId === "thread-1", + (payload) => Ref.update(messageDeltas, (current) => [...current, payload]), ); const initialized = yield* client.request("initialize", { @@ -85,6 +107,15 @@ it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => { const skills = yield* client.request("skills/list", { cwds: [peerCwd] }); assert.equal(skills.data.length, 1); assert.equal(skills.data[0]?.cwd, peerCwd); + yield* Effect.all( + [ + unregisterWrongRequestRoute, + unregisterRequestRoute, + unregisterWrongNotificationRoute, + unregisterNotificationRoute, + ], + { discard: true }, + ); return { account, @@ -93,6 +124,8 @@ it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => { }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void))); assert.equal(result.skills.data[0]?.skills.length, 0); + assert.equal(yield* Ref.get(misroutedRequests), 0); + assert.equal(yield* Ref.get(misroutedNotifications), 0); assert.deepEqual(yield* Ref.get(userInputRequests), [ { itemId: "item-approval-1", diff --git a/packages/effect-codex-app-server/src/client.ts b/packages/effect-codex-app-server/src/client.ts index c0cb5b1dc23..522e26a1abe 100644 --- a/packages/effect-codex-app-server/src/client.ts +++ b/packages/effect-codex-app-server/src/client.ts @@ -56,12 +56,29 @@ export class CodexAppServerClient extends Context.Service< CodexError.CodexAppServerError >, ) => Effect.Effect; + readonly registerServerRequest: ( + method: M, + accepts: (payload: CodexRpc.ServerRequestParamsByMethod[M]) => boolean, + handler: ( + payload: CodexRpc.ServerRequestParamsByMethod[M], + ) => Effect.Effect< + CodexRpc.ServerRequestResponsesByMethod[M], + CodexError.CodexAppServerError + >, + ) => Effect.Effect>; readonly handleServerNotification: ( method: M, handler: ( payload: CodexRpc.ServerNotificationParamsByMethod[M], ) => Effect.Effect, ) => Effect.Effect; + readonly registerServerNotification: ( + method: M, + accepts: (payload: CodexRpc.ServerNotificationParamsByMethod[M]) => boolean, + handler: ( + payload: CodexRpc.ServerNotificationParamsByMethod[M], + ) => Effect.Effect, + ) => Effect.Effect>; readonly handleUnknownServerRequest: ( handler: ( method: string, @@ -83,6 +100,14 @@ type ServerRequestHandler = ( type ServerNotificationHandler = ( payload: unknown, ) => Effect.Effect; +interface RoutedServerRequestHandler { + readonly accepts: (payload: unknown) => boolean; + readonly handler: ServerRequestHandler; +} +interface RoutedServerNotificationHandler { + readonly accepts: (payload: unknown) => boolean; + readonly handler: ServerNotificationHandler; +} export const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make")(function* ( stdio: Stdio.Stdio, @@ -91,6 +116,12 @@ export const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make ): Effect.fn.Return { const requestHandlers = new Map(); const notificationHandlers = new Map>(); + const routedRequestHandlers = new Map>(); + const routedNotificationHandlers = new Map< + string, + Map + >(); + let nextRegistrationId = 1; let unknownRequestHandler: | ((method: string, params: unknown) => Effect.Effect) | undefined; @@ -150,9 +181,16 @@ export const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make if (schema) { return decodeNotificationPayload(notification.method, schema, notification.params).pipe( - Effect.flatMap((decoded) => - Effect.forEach(handlers, (handler) => handler(decoded), { discard: true }), - ), + Effect.flatMap((decoded) => { + const routedHandlers = Array.from( + routedNotificationHandlers.get(notification.method)?.values() ?? [], + ).filter(({ accepts }) => accepts(decoded)); + return Effect.forEach( + [...handlers, ...routedHandlers.map(({ handler }) => handler)], + (handler) => handler(decoded), + { discard: true }, + ); + }), Effect.catch(() => Effect.void), ); } @@ -171,10 +209,14 @@ export const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make const method = request.method as CodexRpc.ServerRequestMethod; const payloadSchema = getServerRequestParamSchema(method); const responseSchema = getServerRequestResponseSchema(method); - const handler = requestHandlers.get(method); return decodeOptionalPayload(method, payloadSchema, request.params).pipe( - Effect.flatMap((decoded) => runHandler(handler, decoded, method)), + Effect.flatMap((decoded) => { + const routedHandler = Array.from(routedRequestHandlers.get(method)?.values() ?? []).find( + ({ accepts }) => accepts(decoded), + )?.handler; + return runHandler(routedHandler ?? requestHandlers.get(method), decoded, method); + }), Effect.flatMap((result) => encodeOptionalPayload(method, responseSchema, result)), ); } @@ -233,12 +275,46 @@ export const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make Effect.sync(() => { requestHandlers.set(method, handler as ServerRequestHandler); }), + registerServerRequest: (method, accepts, handler) => + Effect.sync(() => { + const registrationId = nextRegistrationId++; + const current = routedRequestHandlers.get(method) ?? new Map(); + current.set(registrationId, { + accepts: accepts as (payload: unknown) => boolean, + handler: handler as ServerRequestHandler, + }); + routedRequestHandlers.set(method, current); + return Effect.sync(() => { + const registered = routedRequestHandlers.get(method); + registered?.delete(registrationId); + if (registered?.size === 0) { + routedRequestHandlers.delete(method); + } + }); + }), handleServerNotification: (method, handler) => Effect.sync(() => { const current = notificationHandlers.get(method) ?? []; current.push(handler as ServerNotificationHandler); notificationHandlers.set(method, current); }), + registerServerNotification: (method, accepts, handler) => + Effect.sync(() => { + const registrationId = nextRegistrationId++; + const current = routedNotificationHandlers.get(method) ?? new Map(); + current.set(registrationId, { + accepts: accepts as (payload: unknown) => boolean, + handler: handler as ServerNotificationHandler, + }); + routedNotificationHandlers.set(method, current); + return Effect.sync(() => { + const registered = routedNotificationHandlers.get(method); + registered?.delete(registrationId); + if (registered?.size === 0) { + routedNotificationHandlers.delete(method); + } + }); + }), handleUnknownServerRequest: (handler) => Effect.sync(() => { unknownRequestHandler = handler; diff --git a/packages/effect-codex-app-server/src/errors.ts b/packages/effect-codex-app-server/src/errors.ts index f0e0945d352..844f8803911 100644 --- a/packages/effect-codex-app-server/src/errors.ts +++ b/packages/effect-codex-app-server/src/errors.ts @@ -119,6 +119,7 @@ export const CodexAppServerIdentifierPurpose = Schema.Literals([ "command-approval-request", "file-change-approval-request", "user-input-request", + "steer-message", ]); export type CodexAppServerIdentifierPurpose = typeof CodexAppServerIdentifierPurpose.Type; From 02665b77e356d775dbdce78b40a1ac5f0d85b0a4 Mon Sep 17 00:00:00 2001 From: Costas Panay Date: Fri, 17 Jul 2026 23:27:13 -0700 Subject: [PATCH 02/28] feat: enforce workplace security boundaries Disable cloud auth, managed tunnels, Tailscale exposure, and external updater execution; enforce loopback and audited GitHub/Copilot HTTP policy; add typed MCP elicitation, dynamic tool, and granular permission callbacks; copy official-style worktree include files; and document fork operations, SBOM, and reproducible builds. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f047b252-1029-4093-9a8e-53456b7dd740 --- .github/workflows/workplace-target-guard.yml | 22 ++ apps/desktop/package.json | 3 - apps/desktop/src/app/DesktopClerk.test.ts | 143 +-------- apps/desktop/src/app/DesktopClerk.ts | 101 +------ .../src/backend/DesktopServerExposure.test.ts | 144 ++------- .../src/backend/DesktopServerExposure.ts | 11 +- .../src/electron/ElectronUpdater.test.ts | 140 +-------- apps/desktop/src/electron/ElectronUpdater.ts | 120 +++----- apps/desktop/src/preload.ts | 3 - apps/desktop/vite.config.ts | 6 - .../mobile/src/features/cloud/publicConfig.ts | 3 +- apps/server/src/cli/connect.ts | 2 + .../src/cloud/ManagedEndpointRuntime.test.ts | 11 + .../src/cloud/ManagedEndpointRuntime.ts | 8 + apps/server/src/cloud/publicConfig.ts | 7 +- .../src/provider/Layers/CodexAdapter.ts | 4 + .../Layers/CodexSessionRuntime.test.ts | 61 ++++ .../provider/Layers/CodexSessionRuntime.ts | 275 ++++++++++++++++++ apps/server/src/server.ts | 79 +---- apps/server/src/vcs/GitVcsDriverCore.test.ts | 31 +- apps/server/src/vcs/GitVcsDriverCore.ts | 90 ++++++ apps/web/src/cloud/publicConfig.test.ts | 2 +- apps/web/src/cloud/publicConfig.ts | 3 +- .../chat/ComposerPendingApprovalPanel.tsx | 4 +- apps/web/src/main.tsx | 23 +- apps/web/src/session-logic.ts | 10 +- docs/operations/workplace-fork.md | 76 +++++ package.json | 2 + packages/contracts/src/orchestration.ts | 7 +- packages/contracts/src/providerRuntime.ts | 1 + packages/contracts/src/relayClient.ts | 5 + packages/shared/package.json | 4 + packages/shared/src/outboundPolicy.test.ts | 49 ++++ packages/shared/src/outboundPolicy.ts | 75 +++++ packages/shared/src/relayClient.test.ts | 9 + packages/shared/src/relayClient.ts | 21 ++ pnpm-lock.yaml | 9 - scripts/reproducible-build.ts | 24 ++ 38 files changed, 891 insertions(+), 697 deletions(-) create mode 100644 .github/workflows/workplace-target-guard.yml create mode 100644 docs/operations/workplace-fork.md create mode 100644 packages/shared/src/outboundPolicy.test.ts create mode 100644 packages/shared/src/outboundPolicy.ts create mode 100644 scripts/reproducible-build.ts diff --git a/.github/workflows/workplace-target-guard.yml b/.github/workflows/workplace-target-guard.yml new file mode 100644 index 00000000000..050c023fd52 --- /dev/null +++ b/.github/workflows/workplace-target-guard.yml @@ -0,0 +1,22 @@ +name: Workplace target guard + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + +jobs: + require-workplace-target: + if: github.repository == 'SlowGreek/t3code' + runs-on: ubuntu-latest + steps: + - name: Require codex-workplace base + env: + BASE_REF: ${{ github.base_ref }} + run: | + if [ "$BASE_REF" != "codex-workplace" ]; then + echo "::error::Fork product changes must target codex-workplace, not $BASE_REF." + exit 1 + fi diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ceac5792bbb..74afc3601b2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -12,8 +12,6 @@ "smoke-test": "node scripts/smoke-test.mjs" }, "dependencies": { - "@clerk/electron": "catalog:", - "@clerk/electron-passkeys": "catalog:", "@effect/platform-node": "catalog:", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", @@ -23,7 +21,6 @@ "effect": "catalog:", "electron": "41.5.0", "electron-store": "^8.2.0", - "electron-updater": "^6.6.2", "playwright-core": "1.60.0", "react-grab": "^0.1.32" }, diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 9b5ed56d1f3..9bef1990d2e 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -1,149 +1,12 @@ import { assert, describe, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { beforeEach, vi } from "vite-plus/test"; - -const { createClerkBridgeMock, storageAdapter, storageMock } = vi.hoisted(() => ({ - createClerkBridgeMock: vi.fn(), - storageAdapter: { - getItem: vi.fn(), - setItem: vi.fn(), - removeItem: vi.fn(), - }, - storageMock: vi.fn(), -})); - -vi.mock("@clerk/electron", () => ({ - createClerkBridge: createClerkBridgeMock, -})); - -vi.mock("@clerk/electron/storage", () => ({ - storage: storageMock, -})); import * as DesktopClerk from "./DesktopClerk.ts"; -import * as DesktopEnvironment from "./DesktopEnvironment.ts"; - -const makeDesktopClerkLayer = (isDevelopment = true) => { - const environment = DesktopEnvironment.DesktopEnvironment.of({ - stateDir: "/tmp/t3-state", - isDevelopment, - } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); - - return DesktopClerk.layer.pipe( - Layer.provide(Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment)), - ); -}; describe("DesktopClerk", () => { - beforeEach(() => { - createClerkBridgeMock.mockReset(); - storageMock.mockReset(); - }); - - it("derives the Clerk Frontend API hostname used by the desktop CSP", () => { + it("permanently excludes Clerk hosts from the desktop CSP", () => { const publishableKey = `pk_test_${btoa("clerk.t3.codes$")}`; - assert.equal( - DesktopClerk.resolveDesktopClerkFrontendApiHostname(publishableKey), - "clerk.t3.codes", - ); - assert.equal(DesktopClerk.resolveDesktopClerkFrontendApiHostname(""), undefined); - assert.equal(DesktopClerk.resolveDesktopClerkFrontendApiHostname("invalid"), undefined); - }); - - it.effect("acquires and releases the SDK bridge with the layer", () => { - const cleanup = vi.fn(); - storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockReturnValue({ cleanup }); - - return Effect.gen(function* () { - yield* Effect.scoped(Layer.build(makeDesktopClerkLayer())); - - assert.deepEqual(createClerkBridgeMock.mock.calls, [ - [ - { - storage: storageAdapter, - passkeys: true, - renderer: { scheme: "t3code-dev", host: "app" }, - }, - ], - ]); - assert.equal(cleanup.mock.calls.length, 1); - storageMock.mockClear(); - createClerkBridgeMock.mockClear(); - }); - }); - - it.effect("preserves bridge initialization failures", () => { - const cause = new Error("bridge initialization failed"); - storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockImplementationOnce(() => { - throw cause; - }); - - return Effect.gen(function* () { - const error = yield* Effect.scoped(Layer.build(makeDesktopClerkLayer())).pipe(Effect.flip); - - assert.instanceOf(error, DesktopClerk.DesktopClerkBridgeInitializationError); - assert.equal(error.stateDir, "/tmp/t3-state"); - assert.equal(error.isDevelopment, true); - assert.strictEqual(error.cause, cause); - assert.equal( - error.message, - 'Failed to initialize the desktop Clerk bridge for state directory "/tmp/t3-state" (development: true).', - ); - }); - }); - - it.effect("preserves bridge cleanup failures", () => { - const cause = new Error("bridge cleanup failed"); - storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockReturnValue({ - cleanup: () => { - throw cause; - }, - }); - - return Effect.gen(function* () { - const exit = yield* Effect.exit(Effect.scoped(Layer.build(makeDesktopClerkLayer(false)))); - - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, DesktopClerk.DesktopClerkBridgeCleanupError); - assert.equal(error.stateDir, "/tmp/t3-state"); - assert.equal(error.isDevelopment, false); - assert.strictEqual(error.cause, cause); - assert.equal( - error.message, - 'Failed to clean up the desktop Clerk bridge for state directory "/tmp/t3-state" (development: false).', - ); - } - }); - }); - - it.each([ - { isDevelopment: true, scheme: "t3code-dev" }, - { isDevelopment: false, scheme: "t3code" }, - ])("configures the SDK with the $scheme renderer origin", ({ isDevelopment, scheme }) => { - const bridge = { cleanup: vi.fn() }; - storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockReturnValue(bridge); - - assert.equal(DesktopClerk.createDesktopClerkBridge("/tmp/t3-state", isDevelopment), bridge); - assert.deepEqual(storageMock.mock.calls, [[{ path: "/tmp/t3-state" }]]); - assert.deepEqual(createClerkBridgeMock.mock.calls, [ - [ - { - storage: storageAdapter, - passkeys: true, - renderer: { scheme, host: "app" }, - }, - ], - ]); - storageMock.mockClear(); - createClerkBridgeMock.mockClear(); + assert.equal(DesktopClerk.resolveDesktopClerkFrontendApiHostname(publishableKey), undefined); + assert.equal(DesktopClerk.desktopClerkFrontendApiHostname, undefined); }); }); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 0e283f8dd0c..b8431e3709c 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -1,46 +1,16 @@ -import { createClerkBridge } from "@clerk/electron"; -import { storage } from "@clerk/electron/storage"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; -import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; import * as ElectronApp from "../electron/ElectronApp.ts"; -import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import * as DesktopEnvironment from "./DesktopEnvironment.ts"; - -declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; - -export class DesktopClerkBridgeInitializationError extends Schema.TaggedErrorClass()( - "DesktopClerkBridgeInitializationError", - { - stateDir: Schema.String, - isDevelopment: Schema.Boolean, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to initialize the desktop Clerk bridge for state directory "${this.stateDir}" (development: ${this.isDevelopment}).`; - } -} - -export class DesktopClerkBridgeCleanupError extends Schema.TaggedErrorClass()( - "DesktopClerkBridgeCleanupError", - { - stateDir: Schema.String, - isDevelopment: Schema.Boolean, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to clean up the desktop Clerk bridge for state directory "${this.stateDir}" (development: ${this.isDevelopment}).`; - } -} +/** + * Compatibility service retaining desktop single-instance behavior after the + * workplace distribution removed the Clerk/T3 Connect SDK bridge. + */ export class DesktopClerk extends Context.Service< DesktopClerk, { @@ -53,60 +23,15 @@ export class DesktopClerk extends Context.Service< >()("@t3tools/desktop/app/DesktopClerk") {} export function resolveDesktopClerkFrontendApiHostname( - publishableKey: string | undefined, -): string | undefined { - const normalizedKey = publishableKey?.trim(); - if (!normalizedKey) return undefined; - - try { - return clerkFrontendApiHostnameFromPublishableKey(normalizedKey); - } catch { - return undefined; - } -} - -export const desktopClerkFrontendApiHostname = resolveDesktopClerkFrontendApiHostname( - typeof __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__ === "undefined" - ? undefined - : __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__, -); - -export function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) { - return createClerkBridge({ - storage: storage({ path: stateDir }), - passkeys: true, - renderer: { - scheme: ElectronProtocol.getDesktopScheme(isDevelopment), - host: ElectronProtocol.DESKTOP_HOST, - }, - }); + _publishableKey: string | undefined, +): undefined { + return undefined; } -export const make = Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - yield* Effect.acquireRelease( - Effect.try({ - try: () => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment), - catch: (cause) => - new DesktopClerkBridgeInitializationError({ - stateDir: environment.stateDir, - isDevelopment: environment.isDevelopment, - cause, - }), - }), - (bridge) => - Effect.try({ - try: () => bridge.cleanup(), - catch: (cause) => - new DesktopClerkBridgeCleanupError({ - stateDir: environment.stateDir, - isDevelopment: environment.isDevelopment, - cause, - }), - }).pipe(Effect.orDie), - ); +export const desktopClerkFrontendApiHostname = undefined; - return DesktopClerk.of({ +export const make = Effect.succeed( + DesktopClerk.of({ configure: Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; const electronWindow = yield* ElectronWindow.ElectronWindow; @@ -128,8 +53,8 @@ export const make = Effect.gen(function* () { }), ); }); - }).pipe(Effect.withSpan("desktop.clerk.configure")), - }); -}); + }).pipe(Effect.withSpan("desktop.singleInstance.configure")), + }), +); export const layer = Layer.effect(DesktopClerk, make); diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index 1a107c5c856..36d1ba10c6c 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -179,7 +179,7 @@ describe("DesktopServerExposure", () => { ), ); - it.effect("persists network-accessible mode and updates backend binding state", () => + it.effect("rejects network-accessible mode even when a LAN address exists", () => withHarness( lanNetworkInterfaces, Effect.gen(function* () { @@ -189,22 +189,15 @@ describe("DesktopServerExposure", () => { yield* settings.load; yield* serverExposure.configureFromSettings({ port: 4173 }); - const change = yield* serverExposure.setMode("network-accessible"); - assert.equal(change.requiresRelaunch, true); - assert.deepEqual(change.state, { - mode: "network-accessible", - endpointUrl: "http://192.168.1.20:4173", - advertisedHost: "192.168.1.20", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }); + const error = yield* serverExposure.setMode("network-accessible").pipe(Effect.flip); + assert.equal(error._tag, "DesktopServerExposureNoNetworkAddressError"); const backendConfig = yield* serverExposure.backendConfig; - assert.equal(backendConfig.bindHost, "0.0.0.0"); + assert.equal(backendConfig.bindHost, "127.0.0.1"); assert.equal(backendConfig.httpBaseUrl.href, "http://127.0.0.1:4173/"); const persisted = yield* settings.get; - assert.equal(persisted.serverExposureMode, "network-accessible"); + assert.equal(persisted.serverExposureMode, "local-only"); }), ), ); @@ -224,17 +217,17 @@ describe("DesktopServerExposure", () => { port: 8443, }); assert.equal(changed.requiresRelaunch, true); - assert.equal(changed.state.tailscaleServeEnabled, true); + assert.equal(changed.state.tailscaleServeEnabled, false); assert.equal(changed.state.tailscaleServePort, 8443); const unchanged = yield* serverExposure.setTailscaleServeEnabled({ - enabled: true, + enabled: false, port: 8443, }); assert.equal(unchanged.requiresRelaunch, false); const persisted = yield* settings.get; - assert.equal(persisted.tailscaleServeEnabled, true); + assert.equal(persisted.tailscaleServeEnabled, false); assert.equal(persisted.tailscaleServePort, 8443); }), ), @@ -266,22 +259,6 @@ describe("DesktopServerExposure", () => { const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; yield* serverExposure.configureFromSettings({ port: 4173 }); - const modeError = yield* serverExposure.setMode("network-accessible").pipe(Effect.flip); - assert.instanceOf( - modeError, - DesktopServerExposure.DesktopServerExposureModePersistenceError, - ); - assert.isTrue(DesktopServerExposure.isDesktopServerExposureSetModeError(modeError)); - assert.isTrue(DesktopServerExposure.isDesktopServerExposureError(modeError)); - assert.equal(modeError.mode, "network-accessible"); - assert.strictEqual(modeError.cause, settingsFailure); - assert.strictEqual(modeError.cause.cause, diskFailure); - assert.equal( - modeError.message, - "Failed to persist desktop server exposure mode network-accessible.", - ); - assert.notInclude(modeError.message, diskFailure.message); - const tailscaleError = yield* serverExposure .setTailscaleServeEnabled({ enabled: true, port: 8443 }) .pipe(Effect.flip); @@ -290,13 +267,13 @@ describe("DesktopServerExposure", () => { DesktopServerExposure.DesktopTailscaleServePersistenceError, ); assert.isTrue(DesktopServerExposure.isDesktopServerExposureError(tailscaleError)); - assert.equal(tailscaleError.enabled, true); + assert.equal(tailscaleError.enabled, false); assert.equal(tailscaleError.port, 8443); assert.strictEqual(tailscaleError.cause, settingsFailure); assert.strictEqual(tailscaleError.cause.cause, diskFailure); assert.equal( tailscaleError.message, - "Failed to persist desktop Tailscale Serve settings (enabled: true, port: 8443).", + "Failed to persist desktop Tailscale Serve settings (enabled: false, port: 8443).", ); assert.notInclude(tailscaleError.message, diskFailure.message); }), @@ -312,12 +289,11 @@ describe("DesktopServerExposure", () => { Effect.gen(function* () { const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; yield* serverExposure.configureFromSettings({ port: 4173 }); - yield* serverExposure.setMode("network-accessible"); const endpoints = yield* serverExposure.getAdvertisedEndpoints; assert.deepEqual( endpoints.map((endpoint) => endpoint.httpBaseUrl), - ["http://127.0.0.1:4173/", "http://192.168.1.20:4173/", "http://100.90.1.2:4173/"], + ["http://127.0.0.1:4173/"], ); }), ), @@ -350,15 +326,13 @@ describe("DesktopServerExposure", () => { Effect.gen(function* () { const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; yield* serverExposure.configureFromSettings({ port: 4173 }); - const change = yield* serverExposure.setMode("network-accessible"); - - assert.equal(change.state.advertisedHost, "10.0.0.7"); - assert.equal(change.state.endpointUrl, "http://10.0.0.7:4173"); + const error = yield* serverExposure.setMode("network-accessible").pipe(Effect.flip); + assert.equal(error._tag, "DesktopServerExposureNoNetworkAddressError"); const endpoints = yield* serverExposure.getAdvertisedEndpoints; assert.deepEqual( endpoints.map((endpoint) => endpoint.httpBaseUrl), - ["http://127.0.0.1:4173/", "http://10.0.0.7:4173/", "https://public.example.test/"], + ["http://127.0.0.1:4173/"], ); }), { @@ -368,98 +342,18 @@ describe("DesktopServerExposure", () => { ), ); - it.effect("advertises loopback, LAN, and configured manual endpoints from runtime state", () => + it.effect("advertises only loopback regardless of endpoint environment overrides", () => withHarness( lanNetworkInterfaces, Effect.gen(function* () { const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; yield* serverExposure.configureFromSettings({ port: 3773 }); - yield* serverExposure.setMode("network-accessible"); const endpoints = yield* serverExposure.getAdvertisedEndpoints; - assert.deepEqual(endpoints, [ - { - id: "desktop-loopback:3773", - label: "This machine", - provider: { - id: "desktop-core", - label: "Desktop", - kind: "core", - isAddon: false, - }, - httpBaseUrl: "http://127.0.0.1:3773/", - wsBaseUrl: "ws://127.0.0.1:3773/", - reachability: "loopback", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "desktop-core", - status: "available", - description: "Loopback endpoint for this desktop app.", - }, - { - id: "desktop-lan:http://192.168.1.20:3773", - label: "Local network", - provider: { - id: "desktop-core", - label: "Desktop", - kind: "core", - isAddon: false, - }, - httpBaseUrl: "http://192.168.1.20:3773/", - wsBaseUrl: "ws://192.168.1.20:3773/", - reachability: "lan", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "desktop-core", - status: "available", - isDefault: true, - description: "Reachable from devices on the same network.", - }, - { - id: "manual:https://desktop.example.ts.net", - label: "Custom HTTPS", - provider: { - id: "manual", - label: "Manual", - kind: "manual", - isAddon: false, - }, - httpBaseUrl: "https://desktop.example.ts.net/", - wsBaseUrl: "wss://desktop.example.ts.net/", - reachability: "public", - compatibility: { - hostedHttpsApp: "compatible", - desktopApp: "compatible", - }, - source: "user", - status: "unknown", - description: "User-configured HTTPS endpoint for this desktop backend.", - }, - { - id: "manual:http://desktop.example.test:3773", - label: "Custom endpoint", - provider: { - id: "manual", - label: "Manual", - kind: "manual", - isAddon: false, - }, - httpBaseUrl: "http://desktop.example.test:3773/", - wsBaseUrl: "ws://desktop.example.test:3773/", - reachability: "public", - compatibility: { - hostedHttpsApp: "mixed-content-blocked", - desktopApp: "compatible", - }, - source: "user", - status: "unknown", - description: "User-configured endpoint for this desktop backend.", - }, - ]); + assert.deepEqual( + endpoints.map((endpoint) => endpoint.httpBaseUrl), + ["http://127.0.0.1:3773/"], + ); }), { T3CODE_DESKTOP_HTTPS_ENDPOINTS: diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index 3289bfa99b1..c61e1893f1d 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -313,7 +313,7 @@ function runtimeStateFromResolvedExposure(input: { httpBaseUrl: new URL(input.exposure.localHttpUrl), endpointUrl: Option.fromNullishOr(input.exposure.endpointUrl), advertisedHost: Option.fromNullishOr(input.exposure.advertisedHost), - tailscaleServeEnabled: input.settings.tailscaleServeEnabled, + tailscaleServeEnabled: false, tailscaleServePort: input.settings.tailscaleServePort, }; } @@ -447,16 +447,21 @@ export const make = Effect.gen(function* () { enabled: input.enabled, ...(input.port === undefined ? {} : { port: input.port }), }); + if (input.enabled) { + yield* Effect.logWarning( + "Tailscale Serve is disabled because workplace backends are loopback-only.", + ); + } const result = yield* desktopSettings .setTailscaleServe({ - enabled: input.enabled, + enabled: false, port: Option.fromNullishOr(input.port), }) .pipe( Effect.mapError( (cause) => new DesktopTailscaleServePersistenceError({ - enabled: input.enabled, + enabled: false, port: input.port ?? null, cause, }), diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts index c2acc9ce120..336df3daa34 100644 --- a/apps/desktop/src/electron/ElectronUpdater.test.ts +++ b/apps/desktop/src/electron/ElectronUpdater.test.ts @@ -1,142 +1,26 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import { beforeEach, vi } from "vite-plus/test"; - -const { autoUpdaterMock } = vi.hoisted(() => ({ - autoUpdaterMock: { - allowDowngrade: false, - allowPrerelease: false, - autoDownload: true, - autoInstallOnAppQuit: true, - channel: "latest", - disableDifferentialDownload: false, - fullChangelog: false, - checkForUpdates: vi.fn(() => Promise.resolve(null)), - downloadUpdate: vi.fn(() => Promise.resolve([])), - on: vi.fn(), - quitAndInstall: vi.fn(), - removeListener: vi.fn(), - setFeedURL: vi.fn(), - }, -})); - -vi.mock("electron-updater", () => ({ - autoUpdater: autoUpdaterMock, -})); import * as ElectronUpdater from "./ElectronUpdater.ts"; describe("ElectronUpdater", () => { - beforeEach(() => { - autoUpdaterMock.allowDowngrade = false; - autoUpdaterMock.allowPrerelease = false; - autoUpdaterMock.autoDownload = true; - autoUpdaterMock.autoInstallOnAppQuit = true; - autoUpdaterMock.channel = "latest"; - autoUpdaterMock.disableDifferentialDownload = false; - autoUpdaterMock.fullChangelog = false; - autoUpdaterMock.checkForUpdates.mockClear(); - autoUpdaterMock.checkForUpdates.mockImplementation(() => Promise.resolve(null)); - autoUpdaterMock.downloadUpdate.mockClear(); - autoUpdaterMock.downloadUpdate.mockImplementation(() => Promise.resolve([])); - autoUpdaterMock.on.mockClear(); - autoUpdaterMock.quitAndInstall.mockClear(); - autoUpdaterMock.removeListener.mockClear(); - autoUpdaterMock.setFeedURL.mockClear(); - }); - - it.effect("scopes updater event listeners", () => + it.effect("keeps update network actions explicitly disabled", () => Effect.gen(function* () { - const listener = vi.fn(); - - yield* Effect.scoped( - Effect.gen(function* () { - const updater = yield* ElectronUpdater.ElectronUpdater; - yield* updater.on("update-available", listener); - }), + assert.isFalse(yield* ElectronUpdater.make.allowDowngrade); + assert.equal( + (yield* ElectronUpdater.make.checkForUpdates.pipe(Effect.flip))._tag, + "ElectronUpdaterCheckForUpdatesError", ); - - assert.deepEqual(autoUpdaterMock.on.mock.calls, [["update-available", listener]]); - assert.deepEqual(autoUpdaterMock.removeListener.mock.calls, [["update-available", listener]]); - }).pipe(Effect.provide(ElectronUpdater.layer)), - ); - - it.effect("wraps rejected update checks in the method-specific typed error", () => - Effect.gen(function* () { - const cause = new Error("network unavailable"); - autoUpdaterMock.checkForUpdates.mockImplementationOnce(() => Promise.reject(cause)); - const updater = yield* ElectronUpdater.ElectronUpdater; - autoUpdaterMock.channel = "beta"; - - const error = yield* updater.checkForUpdates.pipe(Effect.flip); - - assert.instanceOf(error, ElectronUpdater.ElectronUpdaterCheckForUpdatesError); - assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); - assert.equal(error.channel, "beta"); - assert.strictEqual(error.cause, cause); - assert.equal(error.message, "Electron updater failed to check for updates on channel beta."); - assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronUpdater.layer)), - ); - - it.effect("preserves the execution-time channel on download failures", () => - Effect.gen(function* () { - const cause = new Error("download unavailable"); - autoUpdaterMock.downloadUpdate.mockImplementationOnce(() => Promise.reject(cause)); - const updater = yield* ElectronUpdater.ElectronUpdater; - autoUpdaterMock.channel = "nightly"; - - const error = yield* updater.downloadUpdate.pipe(Effect.flip); - - assert.instanceOf(error, ElectronUpdater.ElectronUpdaterDownloadUpdateError); - assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); - assert.equal(error.channel, "nightly"); - assert.strictEqual(error.cause, cause); assert.equal( - error.message, - "Electron updater failed to download the update on channel nightly.", + (yield* ElectronUpdater.make.downloadUpdate.pipe(Effect.flip))._tag, + "ElectronUpdaterDownloadUpdateError", ); - assert.notInclude(error.message, cause.message); - }).pipe(Effect.provide(ElectronUpdater.layer)), - ); - - it.effect("sets full changelog mode", () => - Effect.gen(function* () { - const updater = yield* ElectronUpdater.ElectronUpdater; - - yield* updater.setFullChangelog(true); - assert.equal(autoUpdaterMock.fullChangelog, true); - - yield* updater.setFullChangelog(false); - assert.equal(autoUpdaterMock.fullChangelog, false); - }).pipe(Effect.provide(ElectronUpdater.layer)), - ); - - it.effect("preserves quit-and-install flags and the execution-time channel", () => - Effect.gen(function* () { - const cause = new Error("quit and install failed"); - autoUpdaterMock.quitAndInstall.mockImplementationOnce(() => { - throw cause; - }); - const updater = yield* ElectronUpdater.ElectronUpdater; - autoUpdaterMock.channel = "alpha"; - - const error = yield* updater - .quitAndInstall({ isSilent: true, isForceRunAfter: false }) - .pipe(Effect.flip); - - assert.instanceOf(error, ElectronUpdater.ElectronUpdaterQuitAndInstallError); - assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); - assert.equal(error.channel, "alpha"); - assert.equal(error.isSilent, true); - assert.equal(error.isForceRunAfter, false); - assert.strictEqual(error.cause, cause); assert.equal( - error.message, - "Electron updater failed to quit and install the update on channel alpha (silent: true, force run after: false).", + (yield* ElectronUpdater.make + .quitAndInstall({ isSilent: false, isForceRunAfter: false }) + .pipe(Effect.flip))._tag, + "ElectronUpdaterQuitAndInstallError", ); - assert.notInclude(error.message, cause.message); - assert.deepEqual(autoUpdaterMock.quitAndInstall.mock.calls, [[true, false]]); - }).pipe(Effect.provide(ElectronUpdater.layer)), + }), ); }); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index 4157d29a9df..95d45289dc1 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -4,11 +4,11 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; -import { autoUpdater } from "electron-updater"; - -type AutoUpdater = typeof autoUpdater; - -export type ElectronUpdaterFeedUrl = Parameters[0]; +export interface ElectronUpdaterFeedUrl { + readonly provider: string; + readonly url: string; + readonly [key: string]: unknown; +} export class ElectronUpdaterCheckForUpdatesError extends Schema.TaggedErrorClass()( "ElectronUpdaterCheckForUpdatesError", @@ -81,92 +81,40 @@ export class ElectronUpdater extends Context.Service< } >()("@t3tools/desktop/electron/ElectronUpdater") {} +const disabledCause = new Error("Automatic updates are disabled in the workplace distribution."); + export const make = ElectronUpdater.of({ - setFeedURL: (options) => - Effect.suspend(() => { - autoUpdater.setFeedURL(options); - return Effect.void; - }), - setAutoDownload: (value) => - Effect.suspend(() => { - autoUpdater.autoDownload = value; - return Effect.void; - }), - setAutoInstallOnAppQuit: (value) => - Effect.suspend(() => { - autoUpdater.autoInstallOnAppQuit = value; - return Effect.void; + setFeedURL: () => Effect.void, + setAutoDownload: () => Effect.void, + setAutoInstallOnAppQuit: () => Effect.void, + setChannel: () => Effect.void, + setAllowPrerelease: () => Effect.void, + allowDowngrade: Effect.succeed(false), + setAllowDowngrade: () => Effect.void, + setFullChangelog: () => Effect.void, + setDisableDifferentialDownload: () => Effect.void, + checkForUpdates: Effect.fail( + new ElectronUpdaterCheckForUpdatesError({ + channel: null, + cause: disabledCause, }), - setChannel: (channel) => - Effect.suspend(() => { - autoUpdater.channel = channel; - return Effect.void; + ), + downloadUpdate: Effect.fail( + new ElectronUpdaterDownloadUpdateError({ + channel: null, + cause: disabledCause, }), - setAllowPrerelease: (value) => - Effect.suspend(() => { - autoUpdater.allowPrerelease = value; - return Effect.void; - }), - allowDowngrade: Effect.sync(() => autoUpdater.allowDowngrade), - setAllowDowngrade: (value) => - Effect.suspend(() => { - autoUpdater.allowDowngrade = value; - return Effect.void; - }), - setFullChangelog: (value) => - Effect.suspend(() => { - autoUpdater.fullChangelog = value; - return Effect.void; - }), - setDisableDifferentialDownload: (value) => - Effect.suspend(() => { - autoUpdater.disableDifferentialDownload = value; - return Effect.void; - }), - checkForUpdates: Effect.suspend(() => { - const channel = autoUpdater.channel; - return Effect.tryPromise({ - try: () => autoUpdater.checkForUpdates(), - catch: (cause) => new ElectronUpdaterCheckForUpdatesError({ channel, cause }), - }).pipe(Effect.asVoid); - }), - downloadUpdate: Effect.suspend(() => { - const channel = autoUpdater.channel; - return Effect.tryPromise({ - try: () => autoUpdater.downloadUpdate(), - catch: (cause) => new ElectronUpdaterDownloadUpdateError({ channel, cause }), - }).pipe(Effect.asVoid); - }), + ), quitAndInstall: ({ isSilent, isForceRunAfter }) => - Effect.suspend(() => { - const channel = autoUpdater.channel; - return Effect.try({ - try: () => autoUpdater.quitAndInstall(isSilent, isForceRunAfter), - catch: (cause) => - new ElectronUpdaterQuitAndInstallError({ - channel, - isSilent, - isForceRunAfter, - cause, - }), - }); - }), - on: (eventName, listener) => { - const eventTarget = autoUpdater as unknown as { - on: (eventName: string, listener: (...args: Array) => void) => void; - removeListener: (eventName: string, listener: (...args: Array) => void) => void; - }; - const untypedListener = listener as unknown as (...args: Array) => void; - return Effect.acquireRelease( - Effect.sync(() => { - eventTarget.on(eventName, untypedListener); + Effect.fail( + new ElectronUpdaterQuitAndInstallError({ + channel: null, + isSilent, + isForceRunAfter, + cause: disabledCause, }), - () => - Effect.sync(() => { - eventTarget.removeListener(eventName, untypedListener); - }), - ).pipe(Effect.asVoid); - }, + ), + on: () => Effect.void, }); export const layer = Layer.succeed(ElectronUpdater, make); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 9f92cfcf2fa..addd6872c87 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -4,13 +4,10 @@ import type { DesktopPreviewRecordingFrame, DesktopPreviewTabState, } from "@t3tools/contracts"; -import { exposeClerkBridge } from "@clerk/electron/preload"; import { contextBridge, ipcRenderer } from "electron"; import * as IpcChannels from "./ipc/channels.ts"; -exposeClerkBridge({ passkeys: true }); - function unwrapEnsureSshEnvironmentResult(result: unknown) { if ( typeof result === "object" && diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 96e089b9183..dceefc14e9e 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -56,12 +56,6 @@ export default defineConfig({ outExtensions: () => ({ js: ".cjs" }), define: publicConfigDefine, entry: ["src/preload.ts"], - deps: { - // Sandboxed Electron preloads cannot reliably resolve package imports - // from inside the packaged ASAR. Bundle Clerk's preload bridge into the - // preload artifact instead of leaving a runtime require() behind. - alwaysBundle: (id) => id === "@clerk/electron" || id.startsWith("@clerk/electron/"), - }, }, { format: "cjs", diff --git a/apps/mobile/src/features/cloud/publicConfig.ts b/apps/mobile/src/features/cloud/publicConfig.ts index 93a78fa4f44..c6bdc1ca70b 100644 --- a/apps/mobile/src/features/cloud/publicConfig.ts +++ b/apps/mobile/src/features/cloud/publicConfig.ts @@ -74,8 +74,7 @@ export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig } export function hasCloudPublicConfig(): boolean { - const config = resolveCloudPublicConfig(); - return Boolean(config.clerk.publishableKey && config.clerk.jwtTemplate && config.relay.url); + return false; } type Configured = { diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index d09d0814222..b5a057a82bc 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -85,6 +85,8 @@ function formatRelayClientStatus(executable: RelayClient.RelayClientStatus): Rea } case "missing": return [" Relay client: not installed"]; + case "disabled": + return [" Relay client: disabled by workplace security policy"]; case "unsupported": return [ ` Relay client: unsupported on ${executable.platform}-${executable.arch}`, diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index e0d5924fcc2..34c5e11ead5 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -80,6 +80,17 @@ function makeHandle(input: { } describe("CloudManagedEndpointRuntime", () => { + it.effect("keeps the workplace layer disabled for every persisted connector config", () => + Effect.gen(function* () { + const runtime = yield* ManagedEndpointRuntime.CloudManagedEndpointRuntime; + const status = yield* runtime.applyConfig({ + providerKind: "cloudflare_tunnel", + connectorToken: "ignored", + }); + expect(status).toEqual({ status: "disabled" }); + }).pipe(Effect.provide(ManagedEndpointRuntime.layerDisabled)), + ); + it("classifies Cloudflare connection and warning output", () => { expect( ManagedEndpointRuntime.classifyRelayClientOutput( diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index a1d7112a929..240273c4c0b 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -317,3 +317,11 @@ export const make = Effect.gen(function* () { }); export const layer = Layer.effect(CloudManagedEndpointRuntime, make); + +/** Cloud connector processes are permanently unavailable in workplace builds. */ +export const layerDisabled = Layer.succeed( + CloudManagedEndpointRuntime, + CloudManagedEndpointRuntime.of({ + applyConfig: () => Effect.succeed({ status: "disabled" }), + }), +); diff --git a/apps/server/src/cloud/publicConfig.ts b/apps/server/src/cloud/publicConfig.ts index 176b31d7566..6693784d90f 100644 --- a/apps/server/src/cloud/publicConfig.ts +++ b/apps/server/src/cloud/publicConfig.ts @@ -160,8 +160,5 @@ export function makeCloudCliOAuthConfig({ export const cloudCliOAuthConfig = makeCloudCliOAuthConfig(); -export const hasCloudPublicConfig = Boolean( - (normalizeSecureRelayUrl(process.env.T3CODE_RELAY_URL ?? "") ?? buildTimeRelayUrl) && - (process.env.T3CODE_CLERK_PUBLISHABLE_KEY?.trim() || buildTimeClerkPublishableKey) && - (process.env.T3CODE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() || buildTimeClerkCliOAuthClientId), -); +/** T3 Connect is not part of the workplace distribution and cannot be enabled by environment. */ +export const hasCloudPublicConfig = false; diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 6e457cbf5bc..db452e595ad 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -307,6 +307,8 @@ function toRequestTypeFromMethod(method: string): CanonicalRequestType { return "apply_patch_approval"; case "execCommandApproval": return "exec_command_approval"; + case "item/permissions/requestApproval": + return "permissions_approval"; case "item/tool/requestUserInput": return "tool_user_input"; case "item/tool/call": @@ -326,6 +328,8 @@ function toRequestTypeFromKind(kind: ProviderRequestKind | undefined): Canonical return "file_read_approval"; case "file-change": return "file_change_approval"; + case "permissions": + return "permissions_approval"; default: return "unknown"; } diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 6576f4c80a5..c85bf51b90e 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -20,6 +20,7 @@ import { hasConfiguredMcpServer, isTurnSteerUnavailableError, isRecoverableThreadResumeError, + mcpElicitationQuestions, openCodexThread, } from "./CodexSessionRuntime.ts"; const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); @@ -307,6 +308,66 @@ describe("Codex turn steering", () => { }); }); +describe("MCP elicitation", () => { + it("projects mixed form fields into the existing question UI model", () => { + const questions = mcpElicitationQuestions({ + mode: "form", + message: "Configure deployment", + serverName: "deploy", + threadId: "provider-thread-1", + requestedSchema: { + type: "object", + properties: { + name: { + type: "string", + title: "Name", + description: "Deployment name", + }, + regions: { + type: "array", + title: "Regions", + items: { type: "string", enum: ["us-east", "eu-west"] }, + }, + confirm: { + type: "boolean", + title: "Confirm", + }, + }, + }, + }); + + NodeAssert.deepStrictEqual(questions, [ + { + id: "name", + header: "Name", + question: "Deployment name", + options: [], + multiSelect: false, + }, + { + id: "regions", + header: "Regions", + question: "Configure deployment", + options: [ + { label: "us-east", description: "us-east" }, + { label: "eu-west", description: "eu-west" }, + ], + multiSelect: true, + }, + { + id: "confirm", + header: "Confirm", + question: "Configure deployment", + options: [ + { label: "Yes", description: "Yes" }, + { label: "No", description: "No" }, + ], + multiSelect: false, + }, + ]); + }); +}); + describe("buildCodexDeveloperInstructions", () => { it("appends runtime info after the mode instructions", () => { const instructions = buildCodexDeveloperInstructions("default", { diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 647006e6bbb..c313555b0d8 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -108,8 +108,13 @@ export interface CodexSessionRuntimeOptions { readonly appServerArgs?: ReadonlyArray; readonly mcpServer?: CodexThreadMcpServerConfig; readonly connection?: CodexAppServerConnection; + readonly clientTools?: ReadonlyMap; } +export type CodexDynamicClientTool = ( + input: EffectCodexSchema.DynamicToolCallParams, +) => Effect.Effect; + export interface CodexThreadMcpServerConfig { readonly endpoint: string; readonly authorizationHeader: string; @@ -779,6 +784,91 @@ function toCodexUserInputAnswers( ).pipe(Effect.map((entries) => Object.fromEntries(entries))); } +function readMcpElicitationFieldLabel( + fieldId: string, + field: EffectCodexSchema.McpServerElicitationRequestParams__McpElicitationPrimitiveSchema, +): string { + return "title" in field && typeof field.title === "string" && field.title.trim() + ? field.title + : fieldId; +} + +function mcpElicitationOptions( + field: EffectCodexSchema.McpServerElicitationRequestParams__McpElicitationPrimitiveSchema, +): ReadonlyArray<{ readonly label: string; readonly description: string }> { + if ("enum" in field && Array.isArray(field.enum)) { + return field.enum + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => ({ label: entry, description: entry })); + } + if ( + "items" in field && + field.items && + typeof field.items === "object" && + "enum" in field.items && + Array.isArray(field.items.enum) + ) { + return field.items.enum + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => ({ label: entry, description: entry })); + } + if ("type" in field && field.type === "boolean") { + return [ + { label: "Yes", description: "Yes" }, + { label: "No", description: "No" }, + ]; + } + return []; +} + +export function mcpElicitationQuestions( + payload: EffectCodexSchema.McpServerElicitationRequestParams, +): ReadonlyArray<{ + readonly id: string; + readonly header: string; + readonly question: string; + readonly options: ReadonlyArray<{ readonly label: string; readonly description: string }>; + readonly multiSelect: boolean; +}> { + if (payload.mode === "url") { + return [ + { + id: "action", + header: payload.serverName, + question: payload.message, + options: [ + { label: "Open", description: payload.url }, + { label: "Decline", description: "Do not continue." }, + ], + multiSelect: false, + }, + ]; + } + return Object.entries(payload.requestedSchema.properties).map(([fieldId, field]) => ({ + id: fieldId, + header: readMcpElicitationFieldLabel(fieldId, field), + question: + "description" in field && typeof field.description === "string" + ? field.description + : payload.message, + options: mcpElicitationOptions(field), + multiSelect: "type" in field && field.type === "array", + })); +} + +function normalizeMcpElicitationContent( + answers: ProviderUserInputAnswers, +): Readonly> { + return Object.fromEntries( + Object.entries(answers).map(([key, value]) => { + if (isCodexUserInputAnswerObject(value)) { + return [key, value.answers.length === 1 ? value.answers[0] : value.answers]; + } + return [key, value]; + }), + ); +} + function currentProviderThreadId(session: ProviderSession): string | undefined { return readResumeCursorThreadId(session.resumeCursor); } @@ -1255,6 +1345,70 @@ export const makeCodexSessionRuntime = ( ) .pipe(Effect.flatMap((unregister) => Effect.addFinalizer(() => unregister))); + yield* client + .registerServerRequest( + "item/permissions/requestApproval", + (payload) => payload.threadId === routedProviderThreadId, + (payload) => + Effect.gen(function* () { + const requestId = ApprovalRequestId.make( + yield* randomUUIDv4("command-approval-request"), + ); + const turnId = TurnId.make(payload.turnId); + const itemId = ProviderItemId.make(payload.itemId); + const decision = yield* Deferred.make(); + + yield* Ref.update(pendingApprovalsRef, (current) => { + const next = new Map(current); + next.set(requestId, { + requestId, + jsonRpcId: payload.itemId, + requestKind: "permissions", + turnId, + itemId, + decision, + }); + return next; + }); + yield* Ref.update(approvalCorrelationsRef, (current) => { + const next = new Map(current); + next.set(payload.itemId, { + requestId, + requestKind: "permissions", + turnId, + itemId, + }); + return next; + }); + yield* emitEvent({ + kind: "request", + threadId: options.threadId, + method: "item/permissions/requestApproval", + requestId, + requestKind: "permissions", + turnId, + itemId, + payload, + }); + + const resolved = yield* Deferred.await(decision).pipe( + Effect.ensuring( + Ref.update(pendingApprovalsRef, (current) => { + const next = new Map(current); + next.delete(requestId); + return next; + }), + ), + ); + return { + permissions: + resolved === "accept" || resolved === "acceptForSession" ? payload.permissions : {}, + scope: resolved === "acceptForSession" ? "session" : "turn", + } satisfies EffectCodexSchema.PermissionsRequestApprovalResponse; + }), + ) + .pipe(Effect.flatMap((unregister) => Effect.addFinalizer(() => unregister))); + yield* client .registerServerRequest( "item/tool/requestUserInput", @@ -1310,6 +1464,127 @@ export const makeCodexSessionRuntime = ( ) .pipe(Effect.flatMap((unregister) => Effect.addFinalizer(() => unregister))); + yield* client + .registerServerRequest( + "mcpServer/elicitation/request", + (payload) => payload.threadId === routedProviderThreadId, + (payload) => + Effect.gen(function* () { + const requestId = ApprovalRequestId.make(yield* randomUUIDv4("user-input-request")); + const turnId = payload.turnId ? TurnId.make(payload.turnId) : undefined; + const answers = yield* Deferred.make(); + + yield* Ref.update(pendingUserInputsRef, (current) => { + const next = new Map(current); + next.set(requestId, { + requestId, + turnId, + itemId: undefined, + answers, + }); + return next; + }); + yield* emitEvent({ + kind: "request", + threadId: options.threadId, + method: "mcpServer/elicitation/request", + requestId, + ...(turnId ? { turnId } : {}), + payload, + }); + yield* emitEvent({ + kind: "request", + threadId: options.threadId, + method: "item/tool/requestUserInput", + requestId, + ...(turnId ? { turnId } : {}), + payload: { + itemId: `mcp-elicitation:${requestId}`, + threadId: routedProviderThreadId ?? options.threadId, + turnId: payload.turnId ?? `mcp-elicitation:${requestId}`, + questions: mcpElicitationQuestions(payload), + mcpElicitation: payload, + }, + }); + + const resolvedAnswers = yield* Deferred.await(answers).pipe( + Effect.ensuring( + Ref.update(pendingUserInputsRef, (current) => { + const next = new Map(current); + next.delete(requestId); + return next; + }), + ), + ); + const content = normalizeMcpElicitationContent(resolvedAnswers); + const urlAction = + payload.mode === "url" && typeof content.action === "string" + ? content.action.toLowerCase() + : undefined; + const action = + Object.keys(content).length === 0 + ? ("cancel" as const) + : urlAction === "decline" + ? ("decline" as const) + : ("accept" as const); + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + method: "mcpServer/elicitation/resolved", + requestId, + ...(turnId ? { turnId } : {}), + payload: { action, ...(action === "accept" ? { content } : {}) }, + }); + return { + action, + ...(action === "accept" ? { content } : {}), + } satisfies EffectCodexSchema.McpServerElicitationRequestResponse; + }), + ) + .pipe(Effect.flatMap((unregister) => Effect.addFinalizer(() => unregister))); + + yield* client + .registerServerRequest( + "item/tool/call", + (payload) => payload.threadId === routedProviderThreadId, + (payload) => + Effect.gen(function* () { + const requestId = ApprovalRequestId.make(yield* randomUUIDv4("user-input-request")); + const turnId = TurnId.make(payload.turnId); + yield* emitEvent({ + kind: "request", + threadId: options.threadId, + method: "item/tool/call", + requestId, + turnId, + payload, + }); + const key = payload.namespace ? `${payload.namespace}/${payload.tool}` : payload.tool; + const handler = options.clientTools?.get(key) ?? options.clientTools?.get(payload.tool); + const response = handler + ? yield* handler(payload) + : { + success: false, + contentItems: [ + { + type: "inputText" as const, + text: `No trusted client tool is registered for '${key}'.`, + }, + ], + }; + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + method: "item/tool/call/resolved", + requestId, + turnId, + payload: response, + }); + return response; + }), + ) + .pipe(Effect.flatMap((unregister) => Effect.addFinalizer(() => unregister))); + const registerServerNotification = (method: M) => client .registerServerNotification( diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 0c632d8486c..cd67e1be693 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,7 +1,7 @@ import { EnvironmentHttpApi } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; +import { FetchHttpClient, HttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import * as ServerConfig from "./config.ts"; @@ -91,7 +91,7 @@ import { import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; import * as NetService from "@t3tools/shared/Net"; import * as RelayClient from "@t3tools/shared/relayClient"; -import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; +import { restrictWorkplaceHttpClient } from "@t3tools/shared/outboundPolicy"; // Effect's default preemptive shutdown waits 20s before finalizing request scopes. // T3's primary transport is long-lived WebSocket RPC, whose Effect scope finalizer @@ -111,12 +111,12 @@ const PtyAdapterLive = Layer.unwrap( }), ); -const RelayClientLive = Layer.unwrap( - Effect.gen(function* () { - const config = yield* ServerConfig.ServerConfig; - return RelayClient.layerCloudflared({ baseDir: config.baseDir }); - }), -); +const RelayClientLive = RelayClient.layerDisabled; + +const WorkplaceHttpClientLive = Layer.effect( + HttpClient.HttpClient, + HttpClient.HttpClient.pipe(Effect.map(restrictWorkplaceHttpClient)), +).pipe(Layer.provide(FetchHttpClient.layer)); const HttpServerLive = Layer.unwrap( Effect.gen(function* () { @@ -127,7 +127,7 @@ const HttpServerLive = Layer.unwrap( ); return BunHttpServer.layer({ port: config.port, - hostname: config.host ?? "127.0.0.1", + hostname: "127.0.0.1", gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, }); } else { @@ -136,7 +136,7 @@ const HttpServerLive = Layer.unwrap( Effect.promise(() => import("node:http")), ]); return NodeHttpServer.layer(NodeHttp.createServer, { - host: config.host ?? "127.0.0.1", + host: "127.0.0.1", port: config.port, gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, }); @@ -273,10 +273,7 @@ const AuthLayerLive = EnvironmentAuth.layer.pipe( const CloudManagedEndpointRuntimeLive = Layer.mergeAll( RelayClientLive, - CloudManagedEndpointRuntime.layer.pipe( - Layer.provide(ServerSecretStore.layer), - Layer.provide(RelayClientLive), - ), + CloudManagedEndpointRuntime.layerDisabled, ); const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( @@ -394,57 +391,7 @@ export const makeServerLayer = Layer.unwrap( () => clearPersistedServerRuntimeState(config.serverRuntimeStatePath), ), ); - const tailscaleServeLayer = config.tailscaleServeEnabled - ? Layer.effectDiscard( - Effect.acquireRelease( - Effect.gen(function* () { - const server = yield* HttpServer.HttpServer; - const address = server.address; - if (typeof address === "string" || !("port" in address)) { - return null; - } - - const localPort = address.port; - return yield* ensureTailscaleServe({ - localPort, - servePort: config.tailscaleServePort, - localHost: "127.0.0.1", - }).pipe( - Effect.as({ localPort, servePort: config.tailscaleServePort }), - Effect.tap(() => - Effect.logInfo("Tailscale Serve configured", { - localPort, - servePort: config.tailscaleServePort, - }), - ), - Effect.catch((cause) => - Effect.logWarning("Failed to configure Tailscale Serve", { - cause, - localPort, - servePort: config.tailscaleServePort, - }).pipe(Effect.as(null)), - ), - ); - }), - (configured) => - configured - ? disableTailscaleServe({ servePort: configured.servePort }).pipe( - Effect.tap(() => - Effect.logInfo("Tailscale Serve disabled", { - servePort: configured.servePort, - }), - ), - Effect.catch((cause) => - Effect.logWarning("Failed to disable Tailscale Serve", { - cause, - servePort: configured.servePort, - }), - ), - ) - : Effect.void, - ), - ) - : Layer.empty; + const tailscaleServeLayer = Layer.empty; const cloudDesiredLinkReconcileLayer = Layer.effectDiscard( Effect.gen(function* () { if (!hasCloudPublicConfig) return; @@ -482,7 +429,7 @@ export const makeServerLayer = Layer.unwrap( Layer.provideMerge(serverRelayBrokerTracingLayer), Layer.provideMerge(HttpServerLive), Layer.provide(ObservabilityLive), - Layer.provideMerge(FetchHttpClient.layer), + Layer.provideMerge(WorkplaceHttpClientLive), Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(PlatformServicesLive), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 8c627525b4c..8fca639feb1 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -12,7 +12,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; -import { splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts"; +import { parseWorktreeIncludeFile, splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { @@ -23,6 +23,20 @@ const TestLayer = GitVcsDriver.layer.pipe( Layer.provideMerge(NodeServices.layer), ); +describe("parseWorktreeIncludeFile", () => { + it("normalizes comments, blanks, and duplicate relative entries", () => { + assert.deepEqual( + parseWorktreeIncludeFile(` +# Local-only worktree files +.env.local +config/private.json +.env.local +`), + [".env.local", "config/private.json"], + ); + }); +}); + const makeNonRepositoryHandle = () => ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), @@ -651,6 +665,9 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { "feature-worktree", ); const driver = yield* GitVcsDriver.GitVcsDriver; + yield* writeTextFile(cwd, ".worktreeinclude", ".env.local\n"); + yield* writeTextFile(cwd, ".env.local", "LOCAL_ONLY=1\n"); + yield* writeTextFile(cwd, "AGENTS.override.md", "Local agent guidance\n"); const created = yield* driver.createWorktree({ cwd, @@ -662,9 +679,17 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.equal(created.worktree.path, worktreePath); assert.equal(created.worktree.refName, "feature/worktree"); assert.equal(yield* git(worktreePath, ["branch", "--show-current"]), "feature/worktree"); - - yield* driver.removeWorktree({ cwd, path: worktreePath }); const fileSystem = yield* FileSystem.FileSystem; + assert.equal( + yield* fileSystem.readFileString(pathService.join(worktreePath, ".env.local")), + "LOCAL_ONLY=1\n", + ); + assert.equal( + yield* fileSystem.readFileString(pathService.join(worktreePath, "AGENTS.override.md")), + "Local agent guidance\n", + ); + + yield* driver.removeWorktree({ cwd, path: worktreePath, force: true }); assert.equal(yield* fileSystem.exists(worktreePath), false); }), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 6293ff7c29c..05e19724557 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -40,6 +40,19 @@ import { ServerConfig } from "../config.ts"; const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; +const WORKTREE_INCLUDE_FILE = ".worktreeinclude"; +const WORKTREE_ALWAYS_COPY_FILES = ["AGENTS.override.md"] as const; + +export function parseWorktreeIncludeFile(content: string): ReadonlyArray { + return [ + ...new Set( + content + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("#")), + ), + ]; +} const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000; const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000; const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; @@ -2272,6 +2285,83 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* fallbackErrorDetail: "git worktree add failed", }); + const fileOperationError = (operation: string, detail: string, cause: unknown) => + new GitCommandError({ + operation, + command: "copy-worktree-includes", + cwd: input.cwd, + detail, + cause, + }); + const readOptionalFile = (filePath: string) => + fileSystem.readFileString(filePath, "utf8").pipe( + Effect.map(Option.some), + Effect.catch((cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(Option.none()) + : Effect.fail( + fileOperationError( + "GitVcsDriver.createWorktree.readIncludes", + `Failed to read optional worktree include file '${filePath}'.`, + cause, + ), + ), + ), + ); + const includeFile = yield* readOptionalFile(path.join(input.cwd, WORKTREE_INCLUDE_FILE)); + const includedPaths = new Set([ + ...WORKTREE_ALWAYS_COPY_FILES, + ...(Option.isSome(includeFile) ? parseWorktreeIncludeFile(includeFile.value) : []), + ]); + + for (const includedPath of includedPaths) { + const pathSegments = includedPath.split(/[\\/]/u); + if ( + path.isAbsolute(includedPath) || + pathSegments.includes("..") || + pathSegments.every((segment) => segment === "" || segment === ".") + ) { + return yield* fileOperationError( + "GitVcsDriver.createWorktree.validateIncludes", + `Worktree include path must be repository-relative: '${includedPath}'.`, + new Error("Invalid worktree include path."), + ); + } + + const sourcePath = path.join(input.cwd, includedPath); + const sourceInfo = yield* fileSystem.stat(sourcePath).pipe( + Effect.map(Option.some), + Effect.catch((cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(Option.none()) + : Effect.fail( + fileOperationError( + "GitVcsDriver.createWorktree.inspectInclude", + `Failed to inspect worktree include '${includedPath}'.`, + cause, + ), + ), + ), + ); + if (Option.isNone(sourceInfo)) continue; + + const targetPath = path.join(worktreePath, includedPath); + yield* fileSystem.makeDirectory(path.dirname(targetPath), { recursive: true }).pipe( + Effect.andThen( + sourceInfo.value.type === "Directory" + ? fileSystem.copy(sourcePath, targetPath, { overwrite: true }) + : fileSystem.copyFile(sourcePath, targetPath), + ), + Effect.mapError((cause) => + fileOperationError( + "GitVcsDriver.createWorktree.copyInclude", + `Failed to copy worktree include '${includedPath}'.`, + cause, + ), + ), + ); + } + if (input.newRefName && input.baseRefName) { const remoteNames = yield* listRemoteNames(input.cwd).pipe(Effect.orElseSucceed(() => [])); const parsedBaseRef = parseRemoteRefWithRemoteNames( diff --git a/apps/web/src/cloud/publicConfig.test.ts b/apps/web/src/cloud/publicConfig.test.ts index d42aa34baa2..c035ec27ae1 100644 --- a/apps/web/src/cloud/publicConfig.test.ts +++ b/apps/web/src/cloud/publicConfig.test.ts @@ -24,7 +24,7 @@ describe("hasCloudPublicConfig", () => { expect(hasCloudPublicConfig()).toBe(false); vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test"); - expect(hasCloudPublicConfig()).toBe(true); + expect(hasCloudPublicConfig()).toBe(false); }); it("rejects an insecure relay URL", () => { diff --git a/apps/web/src/cloud/publicConfig.ts b/apps/web/src/cloud/publicConfig.ts index d9d0e5f44cb..4f0125d6168 100644 --- a/apps/web/src/cloud/publicConfig.ts +++ b/apps/web/src/cloud/publicConfig.ts @@ -70,8 +70,7 @@ export function resolveRelayTracingConfig() { } export function hasCloudPublicConfig(): boolean { - const config = resolveCloudPublicConfig(); - return Boolean(config.clerkPublishableKey && config.clerkJwtTemplate && config.relayUrl); + return false; } export function resolveRelayClerkTokenOptions() { diff --git a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx index 569fd108a4a..51513842d2d 100644 --- a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx @@ -15,7 +15,9 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova ? "Command approval requested" : approval.requestKind === "file-read" ? "File-read approval requested" - : "File-change approval requested"; + : approval.requestKind === "file-change" + ? "File-change approval requested" + : "Additional permissions requested"; return (
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index c03cc65f654..e9affcbef10 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,8 +1,5 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import { ClerkProvider } from "@clerk/react"; -import { passkeys } from "@clerk/electron/passkeys"; -import { ClerkProvider as ElectronClerkProvider } from "@clerk/electron/react"; import { createHashHistory, createBrowserHistory } from "@tanstack/react-router"; import "@fontsource-variable/dm-sans/index.css"; @@ -12,8 +9,6 @@ import "@xterm/xterm/css/xterm.css"; import "./index.css"; import { isElectron } from "./env"; -import { ManagedRelayAuthProvider } from "./cloud/managedAuth"; -import { hasCloudPublicConfig } from "./cloud/publicConfig"; import { getRouter } from "./router"; import { syncDocumentElectronPlatformClasses, @@ -31,24 +26,8 @@ if (isElectron) { syncDocumentWindowControlsOverlayClass(); } -const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; - const app = ; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - {clerkPublishableKey && hasCloudPublicConfig() ? ( - isElectron ? ( - - {app} - - ) : ( - - {app} - - ) - ) : ( - app - )} - , + {app}, ); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 5d5051f748e..f2bebe132ac 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -88,7 +88,7 @@ interface DerivedWorkLogEntry extends WorkLogEntry { export interface PendingApproval { requestId: ApprovalRequestId; - requestKind: "command" | "file-read" | "file-change"; + requestKind: "command" | "file-read" | "file-change" | "permissions"; createdAt: string; detail?: string; } @@ -331,6 +331,8 @@ function requestKindFromRequestType(requestType: unknown): PendingApproval["requ case "file_change_approval": case "apply_patch_approval": return "file-change"; + case "permissions_approval": + return "permissions"; default: return null; } @@ -371,7 +373,8 @@ export function derivePendingApprovals( payload && (payload.requestKind === "command" || payload.requestKind === "file-read" || - payload.requestKind === "file-change") + payload.requestKind === "file-change" || + payload.requestKind === "permissions") ? payload.requestKind : payload ? requestKindFromRequestType(payload.requestType) @@ -1225,7 +1228,8 @@ function extractWorkLogRequestKind( if ( payload?.requestKind === "command" || payload?.requestKind === "file-read" || - payload?.requestKind === "file-change" + payload?.requestKind === "file-change" || + payload?.requestKind === "permissions" ) { return payload.requestKind; } diff --git a/docs/operations/workplace-fork.md b/docs/operations/workplace-fork.md new file mode 100644 index 00000000000..60337b929c1 --- /dev/null +++ b/docs/operations/workplace-fork.md @@ -0,0 +1,76 @@ +# Workplace fork operations + +The `SlowGreek/t3code` repository keeps two intentionally different branch roles: + +- `main` is a clean mirror of `pingdotgg/t3code` `upstream/main`. Never merge fork product work into it. +- `codex-workplace` is the integration branch for workplace-only product and security changes. Every fork pull request targets this branch. + +The `Workplace target guard` workflow rejects pull requests in this repository whose base is not `codex-workplace`. + +## Safe upstream sync + +```bash +git fetch upstream +git switch main +git merge --ff-only upstream/main +git push origin main +git switch codex-workplace +git merge main +git push origin codex-workplace +``` + +Do not force-push either protected branch. Resolve workplace integration conflicts on `codex-workplace`; never resolve them by adding fork behavior to `main`. + +## Runtime architecture + +Each configured local Codex environment owns one long-lived `codex app-server` process. T3 opens, resumes, and multiplexes native Codex threads over that connection. Native notifications and callbacks are routed by Codex `threadId`; closing a T3 thread removes its routed handlers but does not terminate the environment connection. The app-server remains authoritative for thread state and history. + +T3-only metadata (workspace presentation, local checkpoints, and GUI state) remains in T3 persistence. It must not replace or synthesize Codex lifecycle state when the app-server provides an authoritative operation. + +When T3 creates a Git worktree it copies ignored `AGENTS.override.md` automatically. A repository may list additional untracked local files or directories in `.worktreeinclude`, one repository-relative path per line. Blank lines and `#` comments are ignored; absolute and parent-traversal paths are rejected. + +## Security posture + +- Desktop and server listeners bind to `127.0.0.1`. +- Product analytics is a compile-time no-op with no analytics HTTP implementation. +- Codex subprocesses receive `DO_NOT_TRACK=1`, `OTEL_SDK_DISABLED=true`, `CODEX_TELEMETRY_DISABLED=1`, and `CODEX_ANALYTICS_ENABLED=false`. +- T3 Connect and Clerk provider initialization are disabled. Environment variables and embedded public keys cannot re-enable them. +- Cloudflare connector discovery, download, installation, and runtime layers are replaced with explicit disabled services. +- `electron-updater` is not a desktop runtime dependency. Update actions remain visibly disabled. +- T3 MCP credentials are scoped to a Codex thread and passed in that thread's native config rather than shared process environment variables. +- Step 11 automation scheduling and voice/realtime UX are intentionally deferred. Realtime protocol events remain observable, but this fork does not advertise a voice product surface. + +## Outbound endpoints + +Application-owned network clients may contact only loopback services and the following GitHub/Copilot host families when their corresponding feature is used: + +| Category | Hosts | +| ----------------------------- | ------------------------------------------------------------------------------------- | +| GitHub API and authentication | `github.com`, `api.github.com` | +| GitHub content | `*.githubusercontent.com`, `*.githubassets.com` | +| GitHub Copilot | `api.githubcopilot.com`, `*.githubcopilot.com`, `copilot-proxy.githubusercontent.com` | +| Local sidecars | `127.0.0.1`, `localhost`, `::1` | + +Provider subprocesses and user-started terminal commands are separate trust boundaries: their network behavior is controlled by the provider sandbox and user permissions, not by T3's application HTTP client. + +Outbound audit records are local-only and must contain category, normalized hostname, decision, and timestamp. They must never contain URL paths, query strings, request bodies, authorization headers, tokens, prompts, repository contents, or user identifiers. + +## Reproducible builds and SBOM + +Use the lockfile and the repository-pinned toolchain: + +```bash +pnpm install --frozen-lockfile +pnpm build:reproducible +SOURCE_DATE_EPOCH="$(git show -s --format=%ct HEAD)" pnpm run sbom +``` + +`build:reproducible` fixes locale, timezone, CI mode, and `SOURCE_DATE_EPOCH` to the commit timestamp before running the workspace build. `sbom` uses pnpm's lockfile-only SPDX generator and writes `dist/sbom.spdx.json` without executing dependency lifecycle scripts. + +Before release, run: + +```bash +vp check +vp run typecheck +vp test +``` diff --git a/package.json b/package.json index 3c0a8cc6b77..5af32a167f5 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "icons:export": "node scripts/export-brand-icons.ts", "icons:check": "node scripts/export-brand-icons.ts --check", "build": "vp run --filter './apps/*' --filter './packages/*' --filter './oxlint-plugin-t3code' --filter './scripts' build", + "build:reproducible": "node scripts/reproducible-build.ts", + "sbom": "pnpm sbom --sbom-format spdx --lockfile-only --prod --out dist/sbom.spdx.json", "build:marketing": "vp run --filter @t3tools/marketing build", "build:desktop": "vp run --filter @t3tools/desktop --filter t3 build", "typecheck": "vp run -r --concurrency-limit 2 typecheck", diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 7e9c421b5df..cefd75874b2 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -124,7 +124,12 @@ export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; export const ProviderInteractionMode = Schema.Literals(["default", "plan"]); export type ProviderInteractionMode = typeof ProviderInteractionMode.Type; export const DEFAULT_PROVIDER_INTERACTION_MODE: ProviderInteractionMode = "default"; -export const ProviderRequestKind = Schema.Literals(["command", "file-read", "file-change"]); +export const ProviderRequestKind = Schema.Literals([ + "command", + "file-read", + "file-change", + "permissions", +]); export type ProviderRequestKind = typeof ProviderRequestKind.Type; export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]); export type AssistantDeliveryMode = typeof AssistantDeliveryMode.Type; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index eb2563eff00..2f6522b2742 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -138,6 +138,7 @@ export const CanonicalRequestType = Schema.Literals([ "file_change_approval", "apply_patch_approval", "exec_command_approval", + "permissions_approval", "tool_user_input", "dynamic_tool_call", "auth_tokens_refresh", diff --git a/packages/contracts/src/relayClient.ts b/packages/contracts/src/relayClient.ts index e78078d1eed..7aebc114978 100644 --- a/packages/contracts/src/relayClient.ts +++ b/packages/contracts/src/relayClient.ts @@ -11,6 +11,10 @@ export const RelayClientStatusSchema = Schema.Union([ status: Schema.Literal("missing"), version: Schema.String, }), + Schema.Struct({ + status: Schema.Literal("disabled"), + version: Schema.String, + }), Schema.Struct({ status: Schema.Literal("unsupported"), platform: Schema.String, @@ -44,6 +48,7 @@ export const RelayClientInstallProgressEventSchema = Schema.Union([ export type RelayClientInstallProgressEvent = typeof RelayClientInstallProgressEventSchema.Type; export const RelayClientInstallFailureReasonSchema = Schema.Literals([ + "disabled", "download_failed", "invalid_checksum", "install_locked", diff --git a/packages/shared/package.json b/packages/shared/package.json index bf68ce766ef..49455b136a7 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -186,6 +186,10 @@ "./httpReadiness": { "types": "./src/httpReadiness.ts", "import": "./src/httpReadiness.ts" + }, + "./outboundPolicy": { + "types": "./src/outboundPolicy.ts", + "import": "./src/outboundPolicy.ts" } }, "scripts": { diff --git a/packages/shared/src/outboundPolicy.test.ts b/packages/shared/src/outboundPolicy.test.ts new file mode 100644 index 00000000000..2582588ab57 --- /dev/null +++ b/packages/shared/src/outboundPolicy.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import { classifyWorkplaceOutboundUrl, restrictWorkplaceHttpClient } from "./outboundPolicy.ts"; + +describe("workplace outbound policy", () => { + it("allows only loopback and documented GitHub host families", () => { + expect(classifyWorkplaceOutboundUrl("http://127.0.0.1:3773/ws").category).toBe("loopback"); + expect(classifyWorkplaceOutboundUrl("https://api.github.com/repos/o/r").category).toBe( + "github", + ); + expect( + classifyWorkplaceOutboundUrl("https://copilot-proxy.githubusercontent.com/v1/engines") + .category, + ).toBe("copilot"); + expect(classifyWorkplaceOutboundUrl("https://github.com.evil.example/path")).toEqual({ + allowed: false, + category: "rejected", + hostname: "github.com.evil.example", + }); + expect(classifyWorkplaceOutboundUrl("https://example.com")).toEqual({ + allowed: false, + category: "rejected", + hostname: "example.com", + }); + }); + + it.effect("rejects disallowed hosts before the transport executes", () => + Effect.gen(function* () { + let executions = 0; + const client = restrictWorkplaceHttpClient( + HttpClient.make((request) => + Effect.sync(() => { + executions += 1; + return HttpClientResponse.fromWeb(request, new Response()); + }), + ), + ); + const error = yield* client + .execute(HttpClientRequest.get("https://example.com/private?token=secret")) + .pipe(Effect.flip); + + expect(error._tag).toBe("HttpClientError"); + expect(error.reason._tag).toBe("TransportError"); + expect(executions).toBe(0); + }), + ); +}); diff --git a/packages/shared/src/outboundPolicy.ts b/packages/shared/src/outboundPolicy.ts new file mode 100644 index 00000000000..f7c96f00808 --- /dev/null +++ b/packages/shared/src/outboundPolicy.ts @@ -0,0 +1,75 @@ +import * as Effect from "effect/Effect"; +import { HttpClient, HttpClientError, type HttpClientRequest } from "effect/unstable/http"; + +export const WORKPLACE_OUTBOUND_HOSTS = [ + "github.com", + "api.github.com", + "api.githubcopilot.com", + "copilot-proxy.githubusercontent.com", +] as const; + +const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]); +const ALLOWED_SUFFIXES = [".githubcopilot.com", ".githubusercontent.com", ".githubassets.com"]; + +export type OutboundCategory = "loopback" | "github" | "copilot" | "rejected"; + +export interface OutboundDecision { + readonly allowed: boolean; + readonly category: OutboundCategory; + readonly hostname: string; +} + +export function classifyWorkplaceOutboundUrl(value: string | URL): OutboundDecision { + let url: URL; + try { + url = value instanceof URL ? value : new URL(value); + } catch { + return { allowed: false, category: "rejected", hostname: "" }; + } + + const hostname = url.hostname.toLowerCase().replace(/\.$/u, ""); + if (LOOPBACK_HOSTS.has(hostname)) { + return { allowed: true, category: "loopback", hostname }; + } + if ( + hostname === "api.githubcopilot.com" || + hostname === "copilot-proxy.githubusercontent.com" || + hostname.endsWith(".githubcopilot.com") + ) { + return { allowed: true, category: "copilot", hostname }; + } + if ( + hostname === "github.com" || + hostname === "api.github.com" || + ALLOWED_SUFFIXES.some((suffix) => hostname.endsWith(suffix)) + ) { + return { allowed: true, category: "github", hostname }; + } + return { allowed: false, category: "rejected", hostname }; +} + +function rejectRequest(request: HttpClientRequest.HttpClientRequest, hostname: string) { + return new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + description: `Workplace outbound policy rejected host '${hostname}'.`, + }), + }); +} + +/** Restricts application-owned Effect HTTP traffic and emits metadata-only local audit records. */ +export function restrictWorkplaceHttpClient(client: HttpClient.HttpClient): HttpClient.HttpClient { + return client.pipe( + HttpClient.mapRequestEffect((request) => { + const decision = classifyWorkplaceOutboundUrl(request.url); + const audit = Effect.logInfo("workplace outbound request", { + category: decision.category, + hostname: decision.hostname, + decision: decision.allowed ? "allowed" : "rejected", + }); + return decision.allowed + ? audit.pipe(Effect.as(request)) + : audit.pipe(Effect.andThen(Effect.fail(rejectRequest(request, decision.hostname)))); + }), + ); +} diff --git a/packages/shared/src/relayClient.test.ts b/packages/shared/src/relayClient.test.ts index df39ef0b161..7cd4c4e3125 100644 --- a/packages/shared/src/relayClient.test.ts +++ b/packages/shared/src/relayClient.test.ts @@ -15,6 +15,7 @@ import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts"; import { RelayClientInstallError, CLOUDFLARED_VERSION, + disabled, makeCloudflaredRelayClient, } from "./relayClient.ts"; @@ -63,6 +64,14 @@ const makeSpawnerLayer = (commands: Array) => ); describe("RelayClient", () => { + it.effect("exposes an explicit disabled workplace implementation", () => + Effect.gen(function* () { + expect((yield* disabled.resolve).status).toBe("disabled"); + const error = yield* disabled.install.pipe(Effect.flip); + expect(error.reason).toBe("disabled"); + }), + ); + it.effect("resolves explicit overrides before managed and PATH executables", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index 0a56e45191c..70a7128b26a 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -35,6 +35,10 @@ export type RelayClientStatus = readonly status: "missing"; readonly version: string; } + | { + readonly status: "disabled"; + readonly version: string; + } | { readonly status: "unsupported"; readonly platform: NodeJS.Platform; @@ -47,6 +51,7 @@ export type AvailableRelayClient = Extract( "@t3tools/shared/relayClient", ) {} +const disabledInstallError = new RelayClientInstallError({ + reason: "disabled", + message: "Cloud relay clients are disabled in the workplace distribution.", +}); + +export const disabled = RelayClient.of({ + resolve: Effect.succeed({ + status: "disabled", + version: CLOUDFLARED_VERSION, + }), + install: Effect.fail(disabledInstallError), + installWithProgress: () => Effect.fail(disabledInstallError), +}); + +export const layerDisabled = Layer.succeed(RelayClient, disabled); + function executableFileName(platform: NodeJS.Platform): string { return platform === "win32" ? "cloudflared.exe" : "cloudflared"; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0271c378ae..96f7f5eba5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,12 +108,6 @@ importers: apps/desktop: dependencies: - '@clerk/electron': - specifier: 0.0.11 - version: 0.0.11(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@clerk/electron-passkeys': - specifier: 0.0.3 - version: 0.0.3 '@effect/platform-node': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) @@ -141,9 +135,6 @@ importers: electron-store: specifier: ^8.2.0 version: 8.2.0 - electron-updater: - specifier: ^6.6.2 - version: 6.8.3 playwright-core: specifier: 1.60.0 version: 1.60.0 diff --git a/scripts/reproducible-build.ts b/scripts/reproducible-build.ts new file mode 100644 index 00000000000..2ba7a82dd84 --- /dev/null +++ b/scripts/reproducible-build.ts @@ -0,0 +1,24 @@ +// @effect-diagnostics nodeBuiltinImport:off - Build bootstrap fixes host process environment before invoking the Effect-based build. +import * as NodeChildProcess from "node:child_process"; + +const sourceDateEpoch = NodeChildProcess.execFileSync( + "git", + ["show", "-s", "--format=%ct", "HEAD"], + { + encoding: "utf8", + }, +).trim(); +const result = NodeChildProcess.spawnSync("vp", ["run", "build"], { + stdio: "inherit", + env: { + ...process.env, + CI: "1", + LANG: "C", + LC_ALL: "C", + SOURCE_DATE_EPOCH: sourceDateEpoch, + TZ: "UTC", + }, +}); + +if (result.error) throw result.error; +process.exitCode = result.status ?? 1; From a1e6bfb5fbfe0f532cf04248f186a4c2c49dd3e2 Mon Sep 17 00:00:00 2001 From: Costas Panay Date: Fri, 17 Jul 2026 23:34:43 -0700 Subject: [PATCH 03/28] feat: synchronize Codex lifecycle and worktree recovery Propagate active thread archive, unarchive, delete, rename, and compact operations to Codex app-server, and add snapshot-before-remove plus restore support for managed worktrees. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f047b252-1029-4093-9a8e-53456b7dd740 --- .../Layers/CheckpointReactor.test.ts | 1 + .../Layers/ProviderCommandReactor.test.ts | 1 + .../Layers/ProviderRuntimeIngestion.test.ts | 1 + .../Layers/ThreadDeletionReactor.ts | 52 ++++++++++++-- .../src/provider/Layers/CodexAdapter.test.ts | 26 +++++++ .../src/provider/Layers/CodexAdapter.ts | 14 ++++ .../provider/Layers/CodexSessionRuntime.ts | 32 +++++++++ .../src/provider/Layers/ProviderService.ts | 20 ++++++ .../Layers/ProviderSessionReaper.test.ts | 1 + .../src/provider/Services/ProviderAdapter.ts | 13 ++++ .../src/provider/Services/ProviderService.ts | 10 ++- apps/server/src/vcs/GitVcsDriverCore.test.ts | 25 ++++++- apps/server/src/vcs/GitVcsDriverCore.ts | 71 +++++++++++++++++++ docs/operations/workplace-fork.md | 2 + packages/contracts/src/git.ts | 2 + 15 files changed, 262 insertions(+), 9 deletions(-) diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 707c87c43c9..7f323b1ff85 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -109,6 +109,7 @@ function createProviderServiceHarness( respondToRequest: () => unsupported(), respondToUserInput: () => unsupported(), stopSession: () => unsupported(), + syncThreadLifecycle: () => Effect.void, listSessions, getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), getInstanceInfo: (instanceId) => diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index ce464565dc5..f1573639cd4 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -301,6 +301,7 @@ describe("ProviderCommandReactor", () => { respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"], respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"], stopSession: stopSession as ProviderServiceShape["stopSession"], + syncThreadLifecycle: () => Effect.void, listSessions: () => Effect.succeed(runtimeSessions), getCapabilities: (_provider) => Effect.succeed({ diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 001ba388949..2968c171919 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -104,6 +104,7 @@ function createProviderServiceHarness() { respondToRequest: () => unsupported(), respondToUserInput: () => unsupported(), stopSession: () => unsupported(), + syncThreadLifecycle: () => Effect.void, listSessions: () => Effect.succeed([...runtimeSessions]), getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), getInstanceInfo: (instanceId) => { diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts index 7d8a24069a3..ed1d0b274a1 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts @@ -14,6 +14,12 @@ import { } from "../Services/ThreadDeletionReactor.ts"; type ThreadDeletedEvent = Extract; +type ThreadLifecycleEvent = Extract< + OrchestrationEvent, + { + type: "thread.deleted" | "thread.archived" | "thread.unarchived" | "thread.meta-updated"; + } +>; export const logCleanupCauseUnlessInterrupted = ({ effect, @@ -56,14 +62,41 @@ const make = Effect.gen(function* () { }); const processThreadDeleted = Effect.fn("processThreadDeleted")(function* ( - event: ThreadDeletedEvent, + event: ThreadLifecycleEvent, ) { const { threadId } = event.payload; - yield* stopProviderSession(threadId); - yield* closeThreadTerminals(threadId); + switch (event.type) { + case "thread.archived": + yield* providerService.syncThreadLifecycle({ + threadId, + action: { type: "archive" }, + }); + return; + case "thread.unarchived": + yield* providerService.syncThreadLifecycle({ + threadId, + action: { type: "unarchive" }, + }); + return; + case "thread.meta-updated": + if (event.payload.title) { + yield* providerService.syncThreadLifecycle({ + threadId, + action: { type: "name", name: event.payload.title }, + }); + } + return; + case "thread.deleted": + yield* providerService.syncThreadLifecycle({ + threadId, + action: { type: "delete" }, + }); + yield* stopProviderSession(threadId); + yield* closeThreadTerminals(threadId); + } }); - const processThreadDeletedSafely = (event: ThreadDeletedEvent) => + const processThreadDeletedSafely = (event: ThreadLifecycleEvent) => processThreadDeleted(event).pipe( Effect.catchCause((cause) => { if (Cause.hasInterruptsOnly(cause)) { @@ -82,10 +115,15 @@ const make = Effect.gen(function* () { const start: ThreadDeletionReactorShape["start"] = Effect.fn("start")(function* () { yield* Effect.forkScoped( Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { - if (event.type !== "thread.deleted") { - return Effect.void; + switch (event.type) { + case "thread.deleted": + case "thread.archived": + case "thread.unarchived": + case "thread.meta-updated": + return worker.enqueue(event); + default: + return Effect.void; } - return worker.enqueue(event); }), ); }); diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 9e5daf3fdaa..3f2f0d44c51 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -104,6 +104,11 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape { }), ); + public readonly syncThreadLifecycleImpl = vi.fn( + (_action: Parameters[0]): Promise => + Promise.resolve(), + ); + public readonly respondToRequestImpl = vi.fn( (_requestId: ApprovalRequestId, _decision: ProviderApprovalDecision): Promise => Promise.resolve(undefined), @@ -142,6 +147,10 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape { return Effect.promise(() => this.rollbackThreadImpl(numTurns)); } + syncThreadLifecycle(action: Parameters[0]) { + return Effect.promise(() => this.syncThreadLifecycleImpl(action)); + } + respondToRequest(requestId: ApprovalRequestId, decision: ProviderApprovalDecision) { return Effect.promise(() => this.respondToRequestImpl(requestId, decision)); } @@ -492,6 +501,23 @@ function startLifecycleRuntime() { } lifecycleLayer("CodexAdapterLive lifecycle", (it) => { + it.effect("routes native thread lifecycle actions to the active Codex runtime", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + runtime.syncThreadLifecycleImpl.mockClear(); + NodeAssert.ok(adapter.syncThreadLifecycle); + + yield* adapter.syncThreadLifecycle(asThreadId("thread-1"), { + type: "name", + name: "Renamed thread", + }); + + NodeAssert.deepStrictEqual(runtime.syncThreadLifecycleImpl.mock.calls, [ + [{ type: "name", name: "Renamed thread" }], + ]); + }), + ); + it.effect("maps completed agent message items to canonical item.completed events", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index db452e595ad..d917d65e13f 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1670,6 +1670,19 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ); }; + const syncThreadLifecycle: NonNullable = ( + threadId, + action, + ) => + requireSession(threadId).pipe( + Effect.flatMap((session) => session.runtime.syncThreadLifecycle(action)), + Effect.mapError((cause) => + cause._tag === "ProviderAdapterSessionNotFoundError" + ? cause + : mapCodexRuntimeError(threadId, `thread/${action.type}`, cause), + ), + ); + const respondToRequest: CodexAdapterShape["respondToRequest"] = (threadId, requestId, decision) => requireSession(threadId).pipe( Effect.flatMap((session) => session.runtime.respondToRequest(requestId, decision)), @@ -1758,6 +1771,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( interruptTurn, readThread, rollbackThread, + syncThreadLifecycle, respondToRequest, respondToUserInput, stopSession, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index c313555b0d8..d7436497440 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -167,6 +167,14 @@ export interface CodexSessionRuntimeShape { readonly rollbackThread: ( numTurns: number, ) => Effect.Effect; + readonly syncThreadLifecycle: ( + action: + | { readonly type: "archive" } + | { readonly type: "unarchive" } + | { readonly type: "delete" } + | { readonly type: "name"; readonly name: string } + | { readonly type: "compact" }, + ) => Effect.Effect; readonly respondToRequest: ( requestId: ApprovalRequestId, decision: ProviderApprovalDecision, @@ -1850,6 +1858,30 @@ export const makeCodexSessionRuntime = ( }); return parseThreadSnapshot(response); }), + syncThreadLifecycle: (action) => + Effect.gen(function* () { + const providerThreadId = yield* readProviderThreadId; + switch (action.type) { + case "archive": + yield* client.request("thread/archive", { threadId: providerThreadId }); + return; + case "unarchive": + yield* client.request("thread/unarchive", { threadId: providerThreadId }); + return; + case "delete": + yield* client.request("thread/delete", { threadId: providerThreadId }); + return; + case "name": + yield* client.request("thread/name/set", { + threadId: providerThreadId, + name: action.name, + }); + return; + case "compact": + yield* client.request("thread/compact/start", { threadId: providerThreadId }); + return; + } + }), respondToRequest: (requestId, decision) => Effect.gen(function* () { const pending = (yield* Ref.get(pendingApprovalsRef)).get(requestId); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 2eaaeb8ce3c..96eb422a4dd 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -874,6 +874,25 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }, ); + const syncThreadLifecycle: ProviderServiceMethod<"syncThreadLifecycle"> = Effect.fn( + "syncThreadLifecycle", + )(function* (input) { + const routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.syncThreadLifecycle", + allowRecovery: false, + }); + if (!routed.isActive || !routed.adapter.syncThreadLifecycle) { + yield* Effect.logDebug("provider thread lifecycle sync unavailable", { + threadId: input.threadId, + action: input.action.type, + provider: routed.adapter.provider, + }); + return; + } + yield* routed.adapter.syncThreadLifecycle(routed.threadId, input.action); + }); + const listSessions: ProviderServiceMethod<"listSessions"> = Effect.fn("listSessions")( function* () { const currentAdapters = yield* getAdapterEntries; @@ -1079,6 +1098,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( getCapabilities, getInstanceInfo, rollbackConversation, + syncThreadLifecycle, // Each access creates a fresh PubSub subscription so that multiple // consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each // independently receive all runtime events. diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 166a88ef17b..283b75677bf 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -156,6 +156,7 @@ describe("ProviderSessionReaper", () => { respondToRequest: () => unsupported(), respondToUserInput: () => unsupported(), stopSession, + syncThreadLifecycle: () => Effect.void, listSessions: () => Effect.succeed([]), getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), getInstanceInfo: (instanceId) => { diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index 01eeae7b7bd..d7682cbbe8d 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -42,6 +42,13 @@ export interface ProviderThreadSnapshot { readonly turns: ReadonlyArray; } +export type ProviderThreadLifecycleAction = + | { readonly type: "archive" } + | { readonly type: "unarchive" } + | { readonly type: "delete" } + | { readonly type: "name"; readonly name: string } + | { readonly type: "compact" }; + export interface ProviderAdapterShape { /** * Provider kind implemented by this adapter. @@ -114,6 +121,12 @@ export interface ProviderAdapterShape { numTurns: number, ) => Effect.Effect; + /** Optional native lifecycle synchronization for authoritative provider threads. */ + readonly syncThreadLifecycle?: ( + threadId: ThreadId, + action: ProviderThreadLifecycleAction, + ) => Effect.Effect; + /** * Stop all sessions owned by this adapter. */ diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index 4d4cb4fa01a..fcbef4a112f 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -29,7 +29,10 @@ import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; import type { ProviderServiceError } from "../Errors.ts"; -import type { ProviderAdapterCapabilities } from "./ProviderAdapter.ts"; +import type { + ProviderAdapterCapabilities, + ProviderThreadLifecycleAction, +} from "./ProviderAdapter.ts"; import type { ProviderInstanceRoutingInfo } from "./ProviderAdapterRegistry.ts"; /** @@ -105,6 +108,11 @@ export interface ProviderServiceShape { readonly numTurns: number; }) => Effect.Effect; + readonly syncThreadLifecycle: (input: { + readonly threadId: ThreadId; + readonly action: ProviderThreadLifecycleAction; + }) => Effect.Effect; + /** * Canonical provider runtime event stream. * diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 8fca639feb1..f542240a044 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -664,6 +664,10 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* makeTmpDir("git-worktrees-"), "feature-worktree", ); + const snapshotPath = pathService.join( + yield* makeTmpDir("git-worktree-snapshots-"), + "feature-worktree", + ); const driver = yield* GitVcsDriver.GitVcsDriver; yield* writeTextFile(cwd, ".worktreeinclude", ".env.local\n"); yield* writeTextFile(cwd, ".env.local", "LOCAL_ONLY=1\n"); @@ -688,9 +692,28 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* fileSystem.readFileString(pathService.join(worktreePath, "AGENTS.override.md")), "Local agent guidance\n", ); + yield* writeTextFile(worktreePath, "draft.txt", "uncommitted draft\n"); - yield* driver.removeWorktree({ cwd, path: worktreePath, force: true }); + yield* driver.removeWorktree({ + cwd, + path: worktreePath, + force: true, + snapshotPath, + }); assert.equal(yield* fileSystem.exists(worktreePath), false); + + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: "feature/worktree", + restoreSnapshotPath: snapshotPath, + }); + assert.equal( + yield* fileSystem.readFileString(pathService.join(worktreePath, "draft.txt")), + "uncommitted draft\n", + ); + assert.equal(yield* git(worktreePath, ["branch", "--show-current"]), "feature/worktree"); + yield* driver.removeWorktree({ cwd, path: worktreePath, force: true }); }), ); }); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 05e19724557..53b41f7775e 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2328,6 +2328,49 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); } + if (input.restoreSnapshotPath) { + const snapshotInfo = yield* fileSystem + .stat(input.restoreSnapshotPath) + .pipe( + Effect.mapError((cause) => + fileOperationError( + "GitVcsDriver.createWorktree.inspectSnapshot", + `Failed to inspect worktree snapshot '${input.restoreSnapshotPath}'.`, + cause, + ), + ), + ); + if (snapshotInfo.type !== "Directory") { + return yield* fileOperationError( + "GitVcsDriver.createWorktree.restoreSnapshot", + `Worktree snapshot is not a directory: '${input.restoreSnapshotPath}'.`, + new Error("Invalid worktree snapshot."), + ); + } + const gitFilePath = path.join(worktreePath, ".git"); + const gitMetadata = yield* fileSystem + .readFileString(gitFilePath, "utf8") + .pipe( + Effect.mapError((cause) => + fileOperationError( + "GitVcsDriver.createWorktree.readGitMetadata", + "Failed to preserve linked-worktree Git metadata before snapshot restore.", + cause, + ), + ), + ); + yield* fileSystem.copy(input.restoreSnapshotPath, worktreePath, { overwrite: true }).pipe( + Effect.andThen(fileSystem.writeFileString(gitFilePath, gitMetadata)), + Effect.mapError((cause) => + fileOperationError( + "GitVcsDriver.createWorktree.restoreSnapshot", + `Failed to restore worktree snapshot '${input.restoreSnapshotPath}'.`, + cause, + ), + ), + ); + } + const sourcePath = path.join(input.cwd, includedPath); const sourceInfo = yield* fileSystem.stat(sourcePath).pipe( Effect.map(Option.some), @@ -2479,6 +2522,34 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const removeWorktree: GitVcsDriver.GitVcsDriver["Service"]["removeWorktree"] = Effect.fn( "removeWorktree", )(function* (input) { + if (input.snapshotPath) { + const relativeSnapshotPath = path.relative(input.path, input.snapshotPath); + if ( + relativeSnapshotPath === "" || + (!relativeSnapshotPath.startsWith("..") && !path.isAbsolute(relativeSnapshotPath)) + ) { + return yield* new GitCommandError({ + operation: "GitVcsDriver.removeWorktree.snapshot", + command: "snapshot-worktree", + cwd: input.cwd, + detail: "Worktree snapshot path must be outside the worktree being removed.", + cause: new Error("Invalid worktree snapshot path."), + }); + } + yield* fileSystem.remove(input.snapshotPath, { recursive: true, force: true }).pipe( + Effect.andThen(fileSystem.copy(input.path, input.snapshotPath, { overwrite: true })), + Effect.mapError( + (cause) => + new GitCommandError({ + operation: "GitVcsDriver.removeWorktree.snapshot", + command: "snapshot-worktree", + cwd: input.cwd, + detail: `Failed to snapshot worktree to '${input.snapshotPath}'.`, + cause, + }), + ), + ); + } const args = ["worktree", "remove"]; if (input.force) { args.push("--force"); diff --git a/docs/operations/workplace-fork.md b/docs/operations/workplace-fork.md index 60337b929c1..4c70e34cede 100644 --- a/docs/operations/workplace-fork.md +++ b/docs/operations/workplace-fork.md @@ -29,6 +29,8 @@ T3-only metadata (workspace presentation, local checkpoints, and GUI state) rema When T3 creates a Git worktree it copies ignored `AGENTS.override.md` automatically. A repository may list additional untracked local files or directories in `.worktreeinclude`, one repository-relative path per line. Blank lines and `#` comments are ignored; absolute and parent-traversal paths are rejected. +Managed cleanup callers can pass `snapshotPath` when removing a worktree. T3 copies the complete worktree before removal and rejects snapshot destinations inside the worktree. A subsequent create can pass `restoreSnapshotPath`; T3 restores the snapshot while preserving the new linked-worktree `.git` metadata. + ## Security posture - Desktop and server listeners bind to `127.0.0.1`. diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index aa5cdf8432b..b8389e46cf6 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -139,6 +139,7 @@ export const VcsCreateWorktreeInput = Schema.Struct({ newRefName: Schema.optional(TrimmedNonEmptyStringSchema), baseRefName: Schema.optional(TrimmedNonEmptyStringSchema), path: Schema.NullOr(TrimmedNonEmptyStringSchema), + restoreSnapshotPath: Schema.optional(TrimmedNonEmptyStringSchema), }); export type VcsCreateWorktreeInput = typeof VcsCreateWorktreeInput.Type; @@ -160,6 +161,7 @@ export const VcsRemoveWorktreeInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, path: TrimmedNonEmptyStringSchema, force: Schema.optional(Schema.Boolean), + snapshotPath: Schema.optional(TrimmedNonEmptyStringSchema), }); export type VcsRemoveWorktreeInput = typeof VcsRemoveWorktreeInput.Type; From d472bb97857e2a34d2eeb3f22d5d9edd14a61cd7 Mon Sep 17 00:00:00 2001 From: Costas Panay Date: Fri, 17 Jul 2026 23:37:42 -0700 Subject: [PATCH 04/28] feat: preserve worktree state during handoff Optionally apply the source checkout's tracked diff to new worktrees, snapshot orphaned worktrees before UI cleanup, and expose restore inputs for lossless recovery. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f047b252-1029-4093-9a8e-53456b7dd740 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 8 +++++++- apps/server/src/vcs/GitVcsDriverCore.ts | 19 +++++++++++++++++++ apps/web/src/hooks/useThreadActions.ts | 11 ++++++++++- docs/operations/workplace-fork.md | 2 +- packages/contracts/src/git.ts | 1 + 5 files changed, 38 insertions(+), 3 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index f542240a044..8c1f4260326 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -669,6 +669,8 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { "feature-worktree", ); const driver = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + yield* writeTextFile(cwd, "README.md", "# locally modified\n"); yield* writeTextFile(cwd, ".worktreeinclude", ".env.local\n"); yield* writeTextFile(cwd, ".env.local", "LOCAL_ONLY=1\n"); yield* writeTextFile(cwd, "AGENTS.override.md", "Local agent guidance\n"); @@ -678,12 +680,16 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { path: worktreePath, refName: initialBranch, newRefName: "feature/worktree", + applyCurrentChanges: true, }); assert.equal(created.worktree.path, worktreePath); assert.equal(created.worktree.refName, "feature/worktree"); assert.equal(yield* git(worktreePath, ["branch", "--show-current"]), "feature/worktree"); - const fileSystem = yield* FileSystem.FileSystem; + assert.equal( + yield* fileSystem.readFileString(pathService.join(worktreePath, "README.md")), + "# locally modified\n", + ); assert.equal( yield* fileSystem.readFileString(pathService.join(worktreePath, ".env.local")), "LOCAL_ONLY=1\n", diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 53b41f7775e..a6e2ea7a779 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2285,6 +2285,25 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* fallbackErrorDetail: "git worktree add failed", }); + if (input.applyCurrentChanges) { + const patch = yield* runGitStdout("GitVcsDriver.createWorktree.captureChanges", input.cwd, [ + "diff", + "--binary", + "HEAD", + ]); + if (patch.length > 0) { + yield* executeGit( + "GitVcsDriver.createWorktree.applyChanges", + worktreePath, + ["apply", "--whitespace=nowarn", "-"], + { + stdin: patch, + fallbackErrorDetail: "failed to apply current changes to worktree", + }, + ); + } + } + const fileOperationError = (operation: string, detail: string, cause: unknown) => new GitCommandError({ operation, diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 07655ad30d7..3d68a744265 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -200,7 +200,7 @@ export function useThreadActions() { "This thread is the only one linked to this worktree:", displayWorktreePath ?? orphanedWorktreePath, "", - "Delete the worktree too?", + "Snapshot and remove the worktree too?", ].join("\n"), ), ); @@ -288,12 +288,14 @@ export function useThreadActions() { return deleteResult; } + const snapshotPath = `${orphanedWorktreePath}.t3-snapshot-${threadRef.threadId}`; const removeResult = await removeWorktree({ environmentId: threadRef.environmentId, input: { cwd: threadProject.workspaceRoot, path: orphanedWorktreePath, force: true, + snapshotPath, }, }); const refreshResult = @@ -327,6 +329,13 @@ export function useThreadActions() { ); return cleanupFailure; } + toastManager.add( + stackedThreadToast({ + type: "success", + title: "Worktree snapshot saved", + description: `Saved recoverable state to ${snapshotPath}.`, + }), + ); return deleteResult; }, [ diff --git a/docs/operations/workplace-fork.md b/docs/operations/workplace-fork.md index 4c70e34cede..a9e7253bf72 100644 --- a/docs/operations/workplace-fork.md +++ b/docs/operations/workplace-fork.md @@ -27,7 +27,7 @@ Each configured local Codex environment owns one long-lived `codex app-server` p T3-only metadata (workspace presentation, local checkpoints, and GUI state) remains in T3 persistence. It must not replace or synthesize Codex lifecycle state when the app-server provides an authoritative operation. -When T3 creates a Git worktree it copies ignored `AGENTS.override.md` automatically. A repository may list additional untracked local files or directories in `.worktreeinclude`, one repository-relative path per line. Blank lines and `#` comments are ignored; absolute and parent-traversal paths are rejected. +When T3 creates a Git worktree it can apply the source checkout's current tracked diff with `applyCurrentChanges`. It also copies ignored `AGENTS.override.md` automatically. A repository may list additional untracked local files or directories in `.worktreeinclude`, one repository-relative path per line. Blank lines and `#` comments are ignored; absolute and parent-traversal paths are rejected. Managed cleanup callers can pass `snapshotPath` when removing a worktree. T3 copies the complete worktree before removal and rejects snapshot destinations inside the worktree. A subsequent create can pass `restoreSnapshotPath`; T3 restores the snapshot while preserving the new linked-worktree `.git` metadata. diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index b8389e46cf6..eb96a92e3c1 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -139,6 +139,7 @@ export const VcsCreateWorktreeInput = Schema.Struct({ newRefName: Schema.optional(TrimmedNonEmptyStringSchema), baseRefName: Schema.optional(TrimmedNonEmptyStringSchema), path: Schema.NullOr(TrimmedNonEmptyStringSchema), + applyCurrentChanges: Schema.optional(Schema.Boolean), restoreSnapshotPath: Schema.optional(TrimmedNonEmptyStringSchema), }); export type VcsCreateWorktreeInput = typeof VcsCreateWorktreeInput.Type; From 4017f4aae71d1f2472e56624f7817b374c133daa Mon Sep 17 00:00:00 2001 From: Costas Panay Date: Fri, 17 Jul 2026 23:44:12 -0700 Subject: [PATCH 05/28] feat: add native manual thread compaction Expose a command-palette action that dispatches through orchestration to the active provider and invokes Codex thread/compact/start, while hiding the action during running turns. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f047b252-1029-4093-9a8e-53456b7dd740 --- .../Layers/ProviderCommandReactor.ts | 10 +++++++- apps/server/src/orchestration/decider.ts | 21 +++++++++++++++++ apps/web/src/components/CommandPalette.tsx | 17 ++++++++++++++ apps/web/src/hooks/useThreadActions.ts | 16 ++++++++++++- .../src/operations/commands.test.ts | 23 ++++++++++++++++++- .../client-runtime/src/operations/commands.ts | 14 +++++++++++ .../src/state/threadCommands.ts | 9 ++++++++ packages/contracts/src/orchestration.ts | 20 ++++++++++++++++ 8 files changed, 127 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 6dc9aa898b4..8bd0179ac59 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -54,7 +54,8 @@ type ProviderIntentEvent = Extract< | "thread.turn-interrupt-requested" | "thread.approval-response-requested" | "thread.user-input-response-requested" - | "thread.session-stop-requested"; + | "thread.session-stop-requested" + | "thread.compact-requested"; } >; @@ -1043,6 +1044,12 @@ const make = Effect.gen(function* () { case "thread.user-input-response-requested": yield* processUserInputResponseRequested(event); return; + case "thread.compact-requested": + yield* providerService.syncThreadLifecycle({ + threadId: event.payload.threadId, + action: { type: "compact" }, + }); + return; case "thread.session-stop-requested": yield* processSessionStopRequested(event); return; @@ -1072,6 +1079,7 @@ const make = Effect.gen(function* () { event.type === "thread.turn-interrupt-requested" || event.type === "thread.approval-response-requested" || event.type === "thread.user-input-response-requested" || + event.type === "thread.compact-requested" || event.type === "thread.session-stop-requested" ) { return yield* worker.enqueue(event); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 1730494ecc6..16610928e2a 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -599,6 +599,27 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.compact": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.compact-requested", + payload: { + threadId: command.threadId, + createdAt: command.createdAt, + }, + }; + } + case "thread.session.set": { yield* requireThread({ readModel, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 8dccf984457..37ea78e736f 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -29,6 +29,7 @@ import { FolderPlusIcon, LinkIcon, MessageSquareIcon, + Minimize2Icon, SettingsIcon, SquarePenIcon, } from "lucide-react"; @@ -49,6 +50,7 @@ import { OpenAddProjectCommandPaletteProvider } from "../commandPaletteContext"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useThreadActions } from "../hooks/useThreadActions"; import { useClientSettings } from "../hooks/useSettings"; import { readLocalApi } from "../localApi"; import { desktopLocalBackendId } from "../connection/desktopLocal"; @@ -473,6 +475,7 @@ function OpenCommandPaletteDialog(props: { const primaryEnvironment = usePrimaryEnvironment(); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); + const { compactThread } = useThreadActions(); const projects = useProjects(); const threads = useThreadShells(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); @@ -1045,6 +1048,20 @@ function OpenCommandPaletteDialog(props: { }); } + if (activeThread && activeThread.session?.status !== "running") { + actionItems.push({ + kind: "action", + value: "action:compact-thread", + searchTerms: ["compact thread", "context", "summarize", "codex"], + title: "Compact current thread", + description: "Ask Codex to compact this thread's context.", + icon: , + run: async () => { + await compactThread(scopeThreadRef(activeThread.environmentId, activeThread.id)); + }, + }); + } + actionItems.push({ kind: "action", value: "action:settings", diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 3d68a744265..0c2880a026a 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -51,6 +51,9 @@ export function useThreadActions() { reportFailure: false, }); const stopThreadSession = useAtomCommand(threadEnvironment.stopSession); + const compactThreadMutation = useAtomCommand(threadEnvironment.compact, { + reportFailure: false, + }); const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { reportFailure: false, }); @@ -354,6 +357,15 @@ export function useThreadActions() { ], ); + const compactThread = useCallback( + (target: ScopedThreadRef) => + compactThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }), + [compactThreadMutation], + ); + const confirmAndDeleteThread = useCallback( async (target: ScopedThreadRef) => { const localApi = readLocalApi(); @@ -369,6 +381,7 @@ export function useThreadActions() { ].join("\n"), ), ); + if (confirmationResult._tag === "Failure") { return confirmationResult; } @@ -388,7 +401,8 @@ export function useThreadActions() { unarchiveThread, deleteThread, confirmAndDeleteThread, + compactThread, }), - [archiveThread, confirmAndDeleteThread, deleteThread, unarchiveThread], + [archiveThread, compactThread, confirmAndDeleteThread, deleteThread, unarchiveThread], ); } diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 5cc3f0c1a86..d2ec499fd00 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -21,7 +21,7 @@ import { import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; -import { archiveThread, createProject, stopThreadSession } from "./commands.ts"; +import { archiveThread, compactThread, createProject, stopThreadSession } from "./commands.ts"; const TEST_CRYPTO_LAYER = Layer.succeed( Crypto.Crypto, @@ -115,6 +115,27 @@ describe("environment commands", () => { }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), ); + it.effect("dispatches manual Codex compaction with generated metadata", () => + Effect.gen(function* () { + const dispatched: ClientOrchestrationCommand[] = []; + const supervisor = yield* makeSupervisor(dispatched); + + yield* compactThread({ + threadId: ThreadId.make("thread-compact"), + createdAt: "2026-06-06T00:02:00.000Z", + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + + expect(dispatched).toEqual([ + { + type: "thread.compact", + commandId: "00000000-0000-4000-8000-000000000000", + threadId: "thread-compact", + createdAt: "2026-06-06T00:02:00.000Z", + }, + ]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); + it.effect("does not add timestamps to commands without createdAt", () => Effect.gen(function* () { const dispatched: ClientOrchestrationCommand[] = []; diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index a0c3cbe771f..fa63deb3279 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -44,6 +44,7 @@ export type RespondToThreadApprovalInput = CommandInput<"thread.approval.respond export type RespondToThreadUserInputInput = CommandInput<"thread.user-input.respond">; export type RevertThreadCheckpointInput = CommandInput<"thread.checkpoint.revert">; export type StopThreadSessionInput = CommandInput<"thread.session.stop">; +export type CompactThreadInput = CommandInput<"thread.compact">; type DispatchTag = typeof ORCHESTRATION_WS_METHODS.dispatchCommand; type CommandEffect = Effect.Effect< @@ -247,6 +248,7 @@ export const stopThreadSession: (input: StopThreadSessionInput) => CommandEffect "EnvironmentCommands.stopThreadSession", )(function* (input) { const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ ...input, type: "thread.session.stop", @@ -254,3 +256,15 @@ export const stopThreadSession: (input: StopThreadSessionInput) => CommandEffect createdAt: metadata.createdAt, }); }); + +export const compactThread: (input: CompactThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.compactThread", +)(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.compact", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); +}); diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index aab5110e9cf..635b818e303 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -5,6 +5,7 @@ import { createAtomCommandScheduler, createEnvironmentCommand } from "./runtime. import { type ArchiveThreadInput, type CreateThreadInput, + type CompactThreadInput, type DeleteThreadInput, type InterruptThreadTurnInput, type RespondToThreadApprovalInput, @@ -17,6 +18,7 @@ import { type UnarchiveThreadInput, type UpdateThreadMetadataInput, archiveThread, + compactThread, createThread, deleteThread, interruptThreadTurn, @@ -34,6 +36,7 @@ import type { EnvironmentRegistry } from "../connection/registry.ts"; export type { ArchiveThreadInput, + CompactThreadInput, CreateThreadInput, DeleteThreadInput, InterruptThreadTurnInput, @@ -136,5 +139,11 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + compact: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:compact", + execute: (input: CompactThreadInput) => compactThread(input), + scheduler, + concurrency, + }), }; } diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index cefd75874b2..17c637e5283 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -684,6 +684,13 @@ const ThreadSessionStopCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const ThreadCompactCommand = Schema.Struct({ + type: Schema.Literal("thread.compact"), + commandId: CommandId, + threadId: ThreadId, + createdAt: IsoDateTime, +}); + const DispatchableClientOrchestrationCommand = Schema.Union([ ProjectCreateCommand, ProjectMetaUpdateCommand, @@ -701,6 +708,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, ThreadSessionStopCommand, + ThreadCompactCommand, ]); export type DispatchableClientOrchestrationCommand = typeof DispatchableClientOrchestrationCommand.Type; @@ -722,6 +730,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, ThreadSessionStopCommand, + ThreadCompactCommand, ]); export type ClientOrchestrationCommand = typeof ClientOrchestrationCommand.Type; @@ -826,6 +835,7 @@ export const OrchestrationEventType = Schema.Literals([ "thread.checkpoint-revert-requested", "thread.reverted", "thread.session-stop-requested", + "thread.compact-requested", "thread.session-set", "thread.proposed-plan-upserted", "thread.turn-diff-completed", @@ -978,6 +988,11 @@ export const ThreadSessionStopRequestedPayload = Schema.Struct({ createdAt: IsoDateTime, }); +export const ThreadCompactRequestedPayload = Schema.Struct({ + threadId: ThreadId, + createdAt: IsoDateTime, +}); + export const ThreadSessionSetPayload = Schema.Struct({ threadId: ThreadId, session: OrchestrationSession, @@ -1116,6 +1131,11 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.session-stop-requested"), payload: ThreadSessionStopRequestedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.compact-requested"), + payload: ThreadCompactRequestedPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.session-set"), From 9e7e5f14be52b08fcda8078863219513bbeb6d87 Mon Sep 17 00:00:00 2001 From: Costas Panay Date: Fri, 17 Jul 2026 23:50:02 -0700 Subject: [PATCH 06/28] test: align desktop checks with disabled telemetry and updates Replace obsolete external-updater behavior tests with the permanent workplace-off policy and assert OTLP remains disabled despite environment input. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f047b252-1029-4093-9a8e-53456b7dd740 --- .../src/app/DesktopEnvironment.test.ts | 2 +- .../src/updates/DesktopUpdates.test.ts | 616 +----------------- apps/desktop/src/updates/DesktopUpdates.ts | 2 +- 3 files changed, 14 insertions(+), 606 deletions(-) diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 92da3f887ac..e5493755edd 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -73,7 +73,7 @@ describe("DesktopEnvironment", () => { assert.deepEqual(environment.devRemoteT3ServerEntryPath, Option.some("/remote/server.mjs")); assert.deepEqual(environment.configuredBackendPort, Option.some(4949)); assert.deepEqual(environment.commitHashOverride, Option.some("0123456789abcdef")); - assert.deepEqual(environment.otlpTracesUrl, Option.some("http://127.0.0.1:4318/v1/traces")); + assert.deepEqual(environment.otlpTracesUrl, Option.none()); assert.equal(environment.otlpExportIntervalMs, 2500); }), ); diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 5ae92bbee96..7ce75241586 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -1,609 +1,17 @@ -import { assert, describe, it } from "@effect/vitest"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import type { DesktopUpdateState } from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import * as Deferred from "effect/Deferred"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; -import * as Logger from "effect/Logger"; -import * as Option from "effect/Option"; -import * as References from "effect/References"; -import * as Ref from "effect/Ref"; -import * as TestClock from "effect/testing/TestClock"; +import { describe, expect, it } from "vite-plus/test"; -import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; -import * as DesktopConfig from "../app/DesktopConfig.ts"; -import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; -import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; -import * as DesktopState from "../app/DesktopState.ts"; -import * as DesktopUpdates from "./DesktopUpdates.ts"; +import { getAutoUpdateDisabledReason } from "./DesktopUpdates.ts"; -interface UpdatesHarnessOptions { - readonly checkForUpdates?: Effect.Effect< - void, - ElectronUpdater.ElectronUpdaterCheckForUpdatesError - >; - readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError; - readonly setDisableDifferentialDownload?: Effect.Effect; - readonly stopBackend?: Effect.Effect; - readonly env?: Record; -} - -const flushCallbacks = Effect.yieldNow; - -function makeHarness(options: UpdatesHarnessOptions = {}) { - let checkCount = 0; - let allowDowngrade = false; - let fullChangelog = false; - const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; - const listeners = new Map void>>(); - const sentStates: DesktopUpdateState[] = []; - - const addListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => { - const eventListeners = listeners.get(eventName) ?? new Set(); - eventListeners.add(listener); - listeners.set(eventName, eventListeners); - }; - - const removeListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => { - const eventListeners = listeners.get(eventName); - if (!eventListeners) { - return; - } - eventListeners.delete(listener); - if (eventListeners.size === 0) { - listeners.delete(eventName); - } - }; - - const updaterLayer = Layer.succeed(ElectronUpdater.ElectronUpdater, { - setFeedURL: (options) => - Effect.sync(() => { - feedUrls.push(options); - }), - setAutoDownload: () => Effect.void, - setAutoInstallOnAppQuit: () => Effect.void, - setChannel: () => Effect.void, - setAllowPrerelease: () => Effect.void, - allowDowngrade: Effect.sync(() => allowDowngrade), - setAllowDowngrade: (value) => - Effect.sync(() => { - allowDowngrade = value; - }), - setFullChangelog: (value) => - Effect.sync(() => { - fullChangelog = value; - }), - setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void, - checkForUpdates: Effect.sync(() => { - checkCount += 1; - }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)), - downloadUpdate: Effect.void, - quitAndInstall: () => Effect.void, - on: (eventName, listener) => - Effect.acquireRelease( - Effect.sync(() => { - addListener(eventName, listener as unknown as (...args: readonly unknown[]) => void); - }), - () => - Effect.sync(() => { - removeListener(eventName, listener as unknown as (...args: readonly unknown[]) => void); - }), - ).pipe(Effect.asVoid), - } satisfies ElectronUpdater.ElectronUpdater["Service"]); - - const windowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { - create: () => Effect.die("unexpected BrowserWindow creation"), - main: Effect.succeed(Option.none()), - currentMainOrFirst: Effect.succeed(Option.none()), - focusedMainOrFirst: Effect.succeed(Option.none()), - setMain: () => Effect.void, - clearMain: () => Effect.void, - reveal: () => Effect.void, - sendAll: (_channel, state) => - Effect.sync(() => { - sentStates.push(state as DesktopUpdateState); - }), - destroyAll: Effect.void, - syncAllAppearance: () => Effect.void, - } satisfies ElectronWindow.ElectronWindow["Service"]); - - const stubBackendInstance: DesktopBackendPool.DesktopBackendInstance = { - id: DesktopBackendPool.PRIMARY_INSTANCE_ID, - label: Effect.succeed("Windows"), - start: Effect.void, - stop: () => options.stopBackend ?? Effect.void, - currentConfig: Effect.succeed(Option.none()), - snapshot: Effect.succeed({ - desiredRunning: false, - ready: false, - activePid: Option.none(), - restartAttempt: 0, - restartScheduled: false, - }), - waitForReady: () => Effect.succeed(true), - }; - const backendLayer = DesktopBackendPool.layerTest([stubBackendInstance]); - - const environmentLayer = DesktopEnvironment.layer({ - dirname: "/repo/apps/desktop/src", - homeDirectory: `/tmp/t3-desktop-updates-home-${process.pid}`, - platform: "darwin", - processArch: "x64", - appVersion: "1.2.3", - appPath: "/repo", - isPackaged: true, - resourcesPath: "/missing/resources", - runningUnderArm64Translation: false, - }).pipe( - Layer.provide( - Layer.mergeAll( - NodeServices.layer, - DesktopConfig.layerTest({ - T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, - T3CODE_DESKTOP_MOCK_UPDATES: "true", - T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141", - ...options.env, - }), - ), - ), - ); - - const setUpdateChannelError = options.setUpdateChannelError; - const settingsLayer = setUpdateChannelError - ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, { - get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), - load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), - setServerExposureMode: () => Effect.die("unexpected server exposure update"), - setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), - setUpdateChannel: () => Effect.fail(setUpdateChannelError), - setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), - setWslDistro: () => Effect.die("unexpected WSL distro change"), - setWslOnly: () => Effect.die("unexpected WSL-only toggle"), - applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), - applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), - } satisfies DesktopAppSettings.DesktopAppSettings["Service"]) - : DesktopAppSettings.layer; - - const layer = DesktopUpdates.layer.pipe( - Layer.provideMerge(updaterLayer), - Layer.provideMerge(windowLayer), - Layer.provideMerge(backendLayer), - Layer.provideMerge(DesktopState.layer), - Layer.provideMerge(settingsLayer), - Layer.provideMerge( - DesktopConfig.layerTest({ - T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, - T3CODE_DESKTOP_MOCK_UPDATES: "true", - T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141", - ...options.env, - }), - ), - Layer.provideMerge(environmentLayer), - Layer.provideMerge(NodeServices.layer), - ); - - return { - layer, - checkCount: () => checkCount, - feedUrls: () => feedUrls, - fullChangelog: () => fullChangelog, - listenerCount: () => - Array.from(listeners.values()).reduce( - (total, eventListeners) => total + eventListeners.size, - 0, - ), - sentStates, - emit: (eventName: string, payload?: unknown) => { - for (const listener of listeners.get(eventName) ?? []) { - listener(payload); - } - }, - }; -} - -describe("DesktopUpdates", () => { - it("preserves complete causes for update poller and event failures", () => { - const cause = Cause.combine( - Cause.fail(new Error("updater failed")), - Cause.die(new Error("updater defect")), - ); - const pollerError = new DesktopUpdates.DesktopUpdatePollerError({ - poller: "startup", - cause, - }); - const eventError = new DesktopUpdates.DesktopUpdateEventHandlingError({ - event: "download-progress", - cause, - }); - const reportedError = new DesktopUpdates.DesktopUpdaterReportedError({ - operation: "download", - cause, - }); - const unexpectedActionError = new DesktopUpdates.DesktopUpdateUnexpectedActionError({ - action: "install", - cause, - }); - - assert.strictEqual(pollerError.cause, cause); - assert.equal(pollerError.poller, "startup"); - assert.equal(pollerError.message, "Desktop update startup poller failed."); - assert.strictEqual(eventError.cause, cause); - assert.equal(eventError.event, "download-progress"); - assert.equal(eventError.message, "Failed to handle desktop update download-progress event."); - assert.strictEqual(reportedError.cause, cause); - assert.equal(reportedError.operation, "download"); - assert.equal(reportedError.message, "Desktop updater download operation reported an error."); - assert.strictEqual(unexpectedActionError.cause, cause); - assert.equal(unexpectedActionError.action, "install"); - assert.equal( - unexpectedActionError.message, - "Desktop update install action failed unexpectedly.", - ); - }); - - it.effect("configures the updater and runs startup checks on the test clock", () => { - const harness = makeHarness(); - - return Effect.gen(function* () { - yield* Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - const state = yield* updates.getState; - assert.equal(state.enabled, true); - assert.equal(state.status, "idle"); - assert.deepEqual(harness.feedUrls(), [ - { provider: "generic", url: "http://localhost:4141" }, - ]); - assert.equal(harness.listenerCount(), 6); - assert.equal(harness.checkCount(), 0); - - yield* TestClock.adjust(Duration.millis(15_000)); - assert.equal(harness.checkCount(), 1); - }), - ); - - assert.equal(harness.listenerCount(), 0); - }).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - - it.effect("updates and broadcasts state from updater events", () => { - const harness = makeHarness(); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - harness.emit("update-available", { version: "1.2.4" }); - yield* flushCallbacks; - - const state = yield* updates.getState; - assert.equal(state.status, "available"); - assert.equal(state.availableVersion, "1.2.4"); - assert.isNotNull(state.checkedAt); - assert.equal(harness.sentStates.at(-1)?.status, "available"); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - - it.effect("enables nightly full changelog release notes and broadcasts summaries", () => { - const harness = makeHarness(); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - yield* updates.setChannel("nightly"); - assert.equal(harness.fullChangelog(), true); - - harness.emit("update-available", { - version: "1.2.4-nightly.20260709.766", - releaseNotes: [ - { - version: "1.2.4-nightly.20260709.766", - note: `

What's Changed

Full Changelog

`, - }, - { - version: "1.2.4-nightly.20260709.765", - note: "- [codex] Upgrade Clerk stack by @juliusmarminge in #3821", - }, - ], - }); - yield* flushCallbacks; - - const state = yield* updates.getState; - assert.equal(state.status, "available"); - assert.deepEqual(state.releaseNotes, [ - { - version: "1.2.4-nightly.20260709.766", - items: ["feat(client): persist offline environment data by @juliusmarminge in #3795"], - }, - { - version: "1.2.4-nightly.20260709.765", - items: ["[codex] Upgrade Clerk stack by @juliusmarminge in #3821"], - }, - ]); - assert.deepEqual(harness.sentStates.at(-1)?.releaseNotes, state.releaseNotes); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - - it.effect("keeps raw updater event failures out of update state", () => { - const harness = makeHarness(); - const cause = new Error( - "request failed for https://user:secret@example.com/update?token=secret", - ); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - harness.emit("error", cause); - yield* flushCallbacks; - - const state = yield* updates.getState; - assert.equal(state.status, "error"); - assert.equal(state.message, "Desktop updater background operation reported an error."); - assert.notInclude(state.message ?? "", "secret"); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - - it.effect("logs bounded updater failure context without exposing the cause", () => { - const cause = new Error( - "request failed for https://user:secret@example.com/update?token=secret", - ); - const updaterError = new ElectronUpdater.ElectronUpdaterCheckForUpdatesError({ - channel: null, - cause, - }); - const harness = makeHarness({ checkForUpdates: Effect.fail(updaterError) }); - const loggedAnnotations: Array> = []; - const logger = Logger.make(({ fiber }) => { - const annotations = fiber.getRef(References.CurrentLogAnnotations); - if (annotations.errorTag === "ElectronUpdaterCheckForUpdatesError") { - loggedAnnotations.push(annotations); - } - }); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - yield* updates.check("manual"); - - const state = yield* updates.getState; - const loggedAnnotation = loggedAnnotations.at(-1); - assert.isDefined(loggedAnnotation); - assert.equal(loggedAnnotation.errorTag, "ElectronUpdaterCheckForUpdatesError"); - assert.isNull(loggedAnnotation.channel); - assert.notProperty(loggedAnnotation, "error"); - assert.notInclude(Object.values(loggedAnnotation).map(String).join(" "), "secret"); - assert.equal( - state.message, - "Electron updater failed to check for updates on channel default.", - ); - assert.notInclude(state.message ?? "", "secret"); - }), - ).pipe( - Effect.provide( - Layer.mergeAll( - TestClock.layer(), - harness.layer, - Logger.layer([logger], { mergeWithExisting: false }), - ), - ), - ); - }); - - it.effect("recovers download state after an unexpected setup failure", () => { - let disableDifferentialCalls = 0; - const harness = makeHarness({ - setDisableDifferentialDownload: Effect.suspend(() => { - disableDifferentialCalls += 1; - return disableDifferentialCalls === 1 - ? Effect.void - : Effect.die(new Error("download setup failed")); - }), - }); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - harness.emit("update-available", { version: "1.2.4" }); - yield* flushCallbacks; - - const result = yield* updates.download; - assert.isTrue(result.accepted); - assert.isFalse(result.completed); - - const failedState = yield* updates.getState; - assert.equal(failedState.status, "available"); - assert.equal(failedState.errorContext, "download"); - assert.equal(failedState.message, "Desktop update download action failed unexpectedly."); - - const changedState = yield* updates.setChannel("nightly"); - assert.equal(changedState.channel, "nightly"); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - - it.effect("restores download state and permits retry after interruption", () => - Effect.gen(function* () { - const actionStarted = yield* Deferred.make(); - let disableDifferentialCalls = 0; - const harness = makeHarness({ - setDisableDifferentialDownload: Effect.suspend(() => { - disableDifferentialCalls += 1; - if (disableDifferentialCalls === 1) { - return Effect.void; - } - if (disableDifferentialCalls === 2) { - return Deferred.succeed(actionStarted, undefined).pipe(Effect.andThen(Effect.never)); - } - return Effect.void; - }), - }); - - yield* Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - harness.emit("update-available", { version: "1.2.4" }); - yield* flushCallbacks; - - const downloadFiber = yield* updates.download.pipe(Effect.forkScoped); - yield* Deferred.await(actionStarted); - yield* Fiber.interrupt(downloadFiber); - - const interruptedState = yield* updates.getState; - assert.equal(interruptedState.status, "available"); - assert.isNull(interruptedState.message); - - const retry = yield* updates.download; - assert.isTrue(retry.accepted); - assert.isTrue(retry.completed); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }), - ); - - it.effect("clears quitting state after an unexpected install setup failure", () => { - const harness = makeHarness({ - stopBackend: Effect.die(new Error("backend stop failed")), - }); - - return Effect.scoped( - Effect.gen(function* () { - const desktopState = yield* DesktopState.DesktopState; - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - harness.emit("update-downloaded", { version: "1.2.4" }); - yield* flushCallbacks; - - const result = yield* updates.install; - assert.isTrue(result.accepted); - assert.isFalse(result.completed); - assert.isFalse(yield* Ref.get(desktopState.quitting)); - - const failedState = yield* updates.getState; - assert.equal(failedState.status, "downloaded"); - assert.equal(failedState.errorContext, "install"); - assert.equal(failedState.message, "Desktop update install action failed unexpectedly."); - - const changedState = yield* updates.setChannel("nightly"); - assert.equal(changedState.channel, "nightly"); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - - it.effect("persists channel changes through the settings service", () => { - const harness = makeHarness(); - - return Effect.scoped( - Effect.gen(function* () { - const settings = yield* DesktopAppSettings.DesktopAppSettings; - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - const state = yield* updates.setChannel("nightly"); - const persistedSettings = yield* settings.get; - - assert.equal(state.channel, "nightly"); - assert.equal(persistedSettings.updateChannel, "nightly"); - assert.equal(persistedSettings.updateChannelConfiguredByUser, true); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - - it.effect("does not persist an unchanged update channel as a user preference", () => { - const harness = makeHarness(); - - return Effect.scoped( - Effect.gen(function* () { - const settings = yield* DesktopAppSettings.DesktopAppSettings; - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - const state = yield* updates.setChannel("latest"); - const persistedSettings = yield* settings.get; - - assert.equal(state.channel, "latest"); - assert.equal(persistedSettings.updateChannel, "latest"); - assert.equal(persistedSettings.updateChannelConfiguredByUser, false); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }); - - it.effect("fails channel changes with a typed error while a check is in progress", () => - Effect.gen(function* () { - const checkStarted = yield* Deferred.make(); - const releaseCheck = yield* Deferred.make(); - const harness = makeHarness({ - checkForUpdates: Deferred.succeed(checkStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseCheck)), - ), - }); - - yield* Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - const checkFiber = yield* updates.check("manual").pipe(Effect.forkScoped); - yield* Deferred.await(checkStarted); - - const exit = yield* Effect.exit(updates.setChannel("nightly")); - assert.equal(exit._tag, "Failure"); - if (exit._tag === "Failure") { - const error = Cause.squash(exit.cause); - assert.instanceOf(error, DesktopUpdates.DesktopUpdateActionInProgressError); - assert.equal(error.action, "check"); - assert.equal(error.requestedChannel, "nightly"); - } - - yield* Deferred.succeed(releaseCheck, undefined); - yield* Fiber.join(checkFiber); - }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); - }), - ); - - it.effect("preserves settings failure context when an update channel cannot be persisted", () => { - const diskFailure = new Error("disk exploded"); - const settingsFailure = new DesktopAppSettings.DesktopSettingsWriteError({ - operation: "replace-settings-file", - path: "/tmp/settings.json", - cause: diskFailure, - }); - const harness = makeHarness({ setUpdateChannelError: settingsFailure }); - - return Effect.scoped( - Effect.gen(function* () { - const updates = yield* DesktopUpdates.DesktopUpdates; - yield* updates.configure; - - const error = yield* updates.setChannel("nightly").pipe(Effect.flip); - - assert.instanceOf(error, DesktopUpdates.DesktopUpdateChannelPersistenceError); - assert.isTrue(DesktopUpdates.isDesktopUpdateSetChannelError(error)); - assert.equal(error.channel, "nightly"); - assert.strictEqual(error.cause, settingsFailure); - assert.strictEqual(error.cause.cause, diskFailure); - assert.equal(error.message, "Failed to persist the nightly desktop update channel."); - assert.notInclude(error.message, diskFailure.message); +describe("DesktopUpdates workplace policy", () => { + it("keeps packaged production updates disabled even when a feed exists", () => { + expect( + getAutoUpdateDisabledReason({ + isDevelopment: false, + isPackaged: true, + platform: "darwin", + disabledByEnv: true, + hasUpdateFeedConfig: true, }), - ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + ).toBe("Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting."); }); }); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 7357907e178..b8c79e80897 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -217,7 +217,7 @@ function shouldBroadcastDownloadProgress( return nextStep !== previousStep || nextPercent === 100; } -function getAutoUpdateDisabledReason(args: { +export function getAutoUpdateDisabledReason(args: { isDevelopment: boolean; isPackaged: boolean; platform: NodeJS.Platform; From 59f6569afcf21c2542ced5d58dae4cb60ba3b4ba Mon Sep 17 00:00:00 2001 From: Costas Panay Date: Fri, 17 Jul 2026 23:53:15 -0700 Subject: [PATCH 07/28] test: permit explicit bare remotes in fixtures Keep production Git policy unchanged while making local bare-repository integration fixtures independent of the developer's safe.bareRepository setting. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f047b252-1029-4093-9a8e-53456b7dd740 --- apps/server/src/git/GitManager.test.ts | 2 +- apps/server/src/vcs/GitVcsDriverCore.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index e1924c03ade..fc7dd5a39e6 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -221,7 +221,7 @@ function runGit( const result = yield* git.execute({ operation: "GitManager.test.runGit", cwd, - args, + args: ["-c", "safe.bareRepository=all", ...args], allowNonZeroExit, }); return { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 8c1f4260326..39bb4cfc3de 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -83,7 +83,7 @@ const git = ( const result = yield* driver.execute({ operation: "GitVcsDriver.test.git", cwd, - args, + args: ["-c", "safe.bareRepository=all", ...args], ...(env ? { env } : {}), timeoutMs: 10_000, }); From 7cf1131aa05e47fdf530eb18a17d62f6f738dff6 Mon Sep 17 00:00:00 2001 From: Costas Panay Date: Fri, 17 Jul 2026 23:56:48 -0700 Subject: [PATCH 08/28] feat: wire native Codex review modes Add typed uncommitted, base-branch, commit, and custom review targets with inline or detached delivery, and expose inline/detached uncommitted review actions in the command palette. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f047b252-1029-4093-9a8e-53456b7dd740 --- .../Layers/ProviderCommandReactor.ts | 23 +++++++++- apps/server/src/orchestration/decider.ts | 23 ++++++++++ .../provider/Layers/CodexSessionRuntime.ts | 14 ++++++- .../src/provider/Services/ProviderAdapter.ts | 11 ++++- apps/web/src/components/CommandPalette.tsx | 31 +++++++++++++- apps/web/src/hooks/useThreadActions.ts | 36 +++++++++++++++- .../src/operations/commands.test.ts | 29 ++++++++++++- .../client-runtime/src/operations/commands.ts | 13 ++++++ .../src/state/threadCommands.ts | 9 ++++ packages/contracts/src/orchestration.ts | 42 +++++++++++++++++++ 10 files changed, 225 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 8bd0179ac59..82a2611e2bd 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -55,7 +55,8 @@ type ProviderIntentEvent = Extract< | "thread.approval-response-requested" | "thread.user-input-response-requested" | "thread.session-stop-requested" - | "thread.compact-requested"; + | "thread.compact-requested" + | "thread.review-start-requested"; } >; @@ -1050,6 +1051,25 @@ const make = Effect.gen(function* () { action: { type: "compact" }, }); return; + case "thread.review-start-requested": + yield* providerService.syncThreadLifecycle({ + threadId: event.payload.threadId, + action: { + type: "review", + delivery: event.payload.delivery, + target: + event.payload.target.type === "commit" + ? { + type: "commit", + sha: event.payload.target.sha, + ...(event.payload.target.title !== undefined + ? { title: event.payload.target.title } + : {}), + } + : event.payload.target, + }, + }); + return; case "thread.session-stop-requested": yield* processSessionStopRequested(event); return; @@ -1080,6 +1100,7 @@ const make = Effect.gen(function* () { event.type === "thread.approval-response-requested" || event.type === "thread.user-input-response-requested" || event.type === "thread.compact-requested" || + event.type === "thread.review-start-requested" || event.type === "thread.session-stop-requested" ) { return yield* worker.enqueue(event); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 16610928e2a..0a51b9aeef6 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -620,6 +620,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.review.start": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.review-start-requested", + payload: { + threadId: command.threadId, + target: command.target, + delivery: command.delivery, + createdAt: command.createdAt, + }, + }; + } + case "thread.session.set": { yield* requireThread({ readModel, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index d7436497440..5896a8431fc 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -173,7 +173,12 @@ export interface CodexSessionRuntimeShape { | { readonly type: "unarchive" } | { readonly type: "delete" } | { readonly type: "name"; readonly name: string } - | { readonly type: "compact" }, + | { readonly type: "compact" } + | { + readonly type: "review"; + readonly delivery: "inline" | "detached"; + readonly target: EffectCodexSchema.V2ReviewStartParams__ReviewTarget; + }, ) => Effect.Effect; readonly respondToRequest: ( requestId: ApprovalRequestId, @@ -1880,6 +1885,13 @@ export const makeCodexSessionRuntime = ( case "compact": yield* client.request("thread/compact/start", { threadId: providerThreadId }); return; + case "review": + yield* client.request("review/start", { + threadId: providerThreadId, + delivery: action.delivery, + target: action.target, + }); + return; } }), respondToRequest: (requestId, decision) => diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index d7682cbbe8d..1365a39b0c3 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -47,7 +47,16 @@ export type ProviderThreadLifecycleAction = | { readonly type: "unarchive" } | { readonly type: "delete" } | { readonly type: "name"; readonly name: string } - | { readonly type: "compact" }; + | { readonly type: "compact" } + | { + readonly type: "review"; + readonly delivery: "inline" | "detached"; + readonly target: + | { readonly type: "uncommittedChanges" } + | { readonly type: "baseBranch"; readonly branch: string } + | { readonly type: "commit"; readonly sha: string; readonly title?: string | null } + | { readonly type: "custom"; readonly instructions: string }; + }; export interface ProviderAdapterShape { /** diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 37ea78e736f..6e3e40e9fbd 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -27,6 +27,7 @@ import { CornerLeftUpIcon, FolderIcon, FolderPlusIcon, + FileSearchIcon, LinkIcon, MessageSquareIcon, Minimize2Icon, @@ -475,7 +476,7 @@ function OpenCommandPaletteDialog(props: { const primaryEnvironment = usePrimaryEnvironment(); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); - const { compactThread } = useThreadActions(); + const { compactThread, startReview } = useThreadActions(); const projects = useProjects(); const threads = useThreadShells(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); @@ -1060,6 +1061,34 @@ function OpenCommandPaletteDialog(props: { await compactThread(scopeThreadRef(activeThread.environmentId, activeThread.id)); }, }); + actionItems.push({ + kind: "action", + value: "action:review-uncommitted", + searchTerms: ["review", "uncommitted", "changes", "codex", "inline"], + title: "Review uncommitted changes", + description: "Start a native Codex review in the current thread.", + icon: , + run: async () => { + await startReview(scopeThreadRef(activeThread.environmentId, activeThread.id), { + target: { type: "uncommittedChanges" }, + delivery: "inline", + }); + }, + }); + actionItems.push({ + kind: "action", + value: "action:review-uncommitted-detached", + searchTerms: ["review", "uncommitted", "changes", "codex", "detached"], + title: "Review uncommitted changes in a new thread", + description: "Start a detached native Codex review.", + icon: , + run: async () => { + await startReview(scopeThreadRef(activeThread.environmentId, activeThread.id), { + target: { type: "uncommittedChanges" }, + delivery: "detached", + }); + }, + }); } actionItems.push({ diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 0c2880a026a..2f3273551cf 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -54,6 +54,9 @@ export function useThreadActions() { const compactThreadMutation = useAtomCommand(threadEnvironment.compact, { reportFailure: false, }); + const startReviewMutation = useAtomCommand(threadEnvironment.startReview, { + reportFailure: false, + }); const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { reportFailure: false, }); @@ -366,6 +369,29 @@ export function useThreadActions() { [compactThreadMutation], ); + const startReview = useCallback( + ( + target: ScopedThreadRef, + review: { + readonly target: + | { readonly type: "uncommittedChanges" } + | { readonly type: "baseBranch"; readonly branch: string } + | { readonly type: "commit"; readonly sha: string; readonly title?: string | null } + | { readonly type: "custom"; readonly instructions: string }; + readonly delivery: "inline" | "detached"; + }, + ) => + startReviewMutation({ + environmentId: target.environmentId, + input: { + threadId: target.threadId, + target: review.target, + delivery: review.delivery, + }, + }), + [startReviewMutation], + ); + const confirmAndDeleteThread = useCallback( async (target: ScopedThreadRef) => { const localApi = readLocalApi(); @@ -402,7 +428,15 @@ export function useThreadActions() { deleteThread, confirmAndDeleteThread, compactThread, + startReview, }), - [archiveThread, compactThread, confirmAndDeleteThread, deleteThread, unarchiveThread], + [ + archiveThread, + compactThread, + confirmAndDeleteThread, + deleteThread, + startReview, + unarchiveThread, + ], ); } diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index d2ec499fd00..1eb80e385ad 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -21,7 +21,13 @@ import { import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; -import { archiveThread, compactThread, createProject, stopThreadSession } from "./commands.ts"; +import { + archiveThread, + compactThread, + createProject, + startThreadReview, + stopThreadSession, +} from "./commands.ts"; const TEST_CRYPTO_LAYER = Layer.succeed( Crypto.Crypto, @@ -136,6 +142,27 @@ describe("environment commands", () => { }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), ); + it.effect("dispatches native review targets and delivery mode", () => + Effect.gen(function* () { + const dispatched: ClientOrchestrationCommand[] = []; + const supervisor = yield* makeSupervisor(dispatched); + + yield* startThreadReview({ + threadId: ThreadId.make("thread-review"), + target: { type: "baseBranch", branch: "main" }, + delivery: "detached", + createdAt: "2026-06-06T00:03:00.000Z", + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + + expect(dispatched[0]).toMatchObject({ + type: "thread.review.start", + threadId: "thread-review", + target: { type: "baseBranch", branch: "main" }, + delivery: "detached", + }); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); + it.effect("does not add timestamps to commands without createdAt", () => Effect.gen(function* () { const dispatched: ClientOrchestrationCommand[] = []; diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index fa63deb3279..76f7c731fb6 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -45,6 +45,7 @@ export type RespondToThreadUserInputInput = CommandInput<"thread.user-input.resp export type RevertThreadCheckpointInput = CommandInput<"thread.checkpoint.revert">; export type StopThreadSessionInput = CommandInput<"thread.session.stop">; export type CompactThreadInput = CommandInput<"thread.compact">; +export type StartThreadReviewInput = CommandInput<"thread.review.start">; type DispatchTag = typeof ORCHESTRATION_WS_METHODS.dispatchCommand; type CommandEffect = Effect.Effect< @@ -268,3 +269,15 @@ export const compactThread: (input: CompactThreadInput) => CommandEffect = Effec createdAt: metadata.createdAt, }); }); + +export const startThreadReview: (input: StartThreadReviewInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.startThreadReview", +)(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.review.start", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); +}); diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 635b818e303..e59db74eae4 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -15,6 +15,7 @@ import { type SetThreadRuntimeModeInput, type StartThreadTurnInput, type StopThreadSessionInput, + type StartThreadReviewInput, type UnarchiveThreadInput, type UpdateThreadMetadataInput, archiveThread, @@ -29,6 +30,7 @@ import { setThreadRuntimeMode, startThreadTurn, stopThreadSession, + startThreadReview, unarchiveThread, updateThreadMetadata, } from "../operations/commands.ts"; @@ -47,6 +49,7 @@ export type { SetThreadRuntimeModeInput, StartThreadTurnInput, StopThreadSessionInput, + StartThreadReviewInput, UnarchiveThreadInput, UpdateThreadMetadataInput, } from "../operations/commands.ts"; @@ -145,5 +148,11 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + startReview: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:start-review", + execute: (input: StartThreadReviewInput) => startThreadReview(input), + scheduler, + concurrency, + }), }; } diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 17c637e5283..4b938db9ca3 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -691,6 +691,33 @@ const ThreadCompactCommand = Schema.Struct({ createdAt: IsoDateTime, }); +export const ThreadReviewTarget = Schema.Union([ + Schema.Struct({ type: Schema.Literal("uncommittedChanges") }), + Schema.Struct({ + type: Schema.Literal("baseBranch"), + branch: TrimmedNonEmptyString, + }), + Schema.Struct({ + type: Schema.Literal("commit"), + sha: TrimmedNonEmptyString, + title: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + }), + Schema.Struct({ + type: Schema.Literal("custom"), + instructions: TrimmedNonEmptyString, + }), +]); +export type ThreadReviewTarget = typeof ThreadReviewTarget.Type; + +const ThreadReviewStartCommand = Schema.Struct({ + type: Schema.Literal("thread.review.start"), + commandId: CommandId, + threadId: ThreadId, + target: ThreadReviewTarget, + delivery: Schema.Literals(["inline", "detached"]), + createdAt: IsoDateTime, +}); + const DispatchableClientOrchestrationCommand = Schema.Union([ ProjectCreateCommand, ProjectMetaUpdateCommand, @@ -709,6 +736,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadCheckpointRevertCommand, ThreadSessionStopCommand, ThreadCompactCommand, + ThreadReviewStartCommand, ]); export type DispatchableClientOrchestrationCommand = typeof DispatchableClientOrchestrationCommand.Type; @@ -731,6 +759,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadCheckpointRevertCommand, ThreadSessionStopCommand, ThreadCompactCommand, + ThreadReviewStartCommand, ]); export type ClientOrchestrationCommand = typeof ClientOrchestrationCommand.Type; @@ -836,6 +865,7 @@ export const OrchestrationEventType = Schema.Literals([ "thread.reverted", "thread.session-stop-requested", "thread.compact-requested", + "thread.review-start-requested", "thread.session-set", "thread.proposed-plan-upserted", "thread.turn-diff-completed", @@ -993,6 +1023,13 @@ export const ThreadCompactRequestedPayload = Schema.Struct({ createdAt: IsoDateTime, }); +export const ThreadReviewStartRequestedPayload = Schema.Struct({ + threadId: ThreadId, + target: ThreadReviewTarget, + delivery: Schema.Literals(["inline", "detached"]), + createdAt: IsoDateTime, +}); + export const ThreadSessionSetPayload = Schema.Struct({ threadId: ThreadId, session: OrchestrationSession, @@ -1136,6 +1173,11 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.compact-requested"), payload: ThreadCompactRequestedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.review-start-requested"), + payload: ThreadReviewStartRequestedPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.session-set"), From 8b4e000cba589e2f4db710fcdc007cbfaf49d6f5 Mon Sep 17 00:00:00 2001 From: Costas Panay Date: Sat, 18 Jul 2026 00:00:42 -0700 Subject: [PATCH 09/28] feat: discover Codex collaboration modes Probe collaborationMode/list on the shared app-server connection, reject unavailable requested modes, and use the documented default/plan fallback only for method-not-found legacy servers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f047b252-1029-4093-9a8e-53456b7dd740 --- .../src/provider/Layers/CodexAdapter.test.ts | 1 + .../Layers/CodexSessionRuntime.test.ts | 10 ++++ .../provider/Layers/CodexSessionRuntime.ts | 52 +++++++++++++++++++ docs/operations/workplace-fork.md | 2 + 4 files changed, 65 insertions(+) diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 3f2f0d44c51..19570d4efdd 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -304,6 +304,7 @@ it.effect("shares one Codex app-server connection across adapter thread sessions const sharedConnection = { client: {}, exitCode: Effect.never, + collaborationModes: new Set(["default", "plan"]), } as unknown as CodexAppServerConnection; const makeConnection = vi.fn(() => Effect.succeed(sharedConnection)); const layer = Layer.effect( diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index c85bf51b90e..9d645788d37 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -22,6 +22,7 @@ import { isRecoverableThreadResumeError, mcpElicitationQuestions, openCodexThread, + parseCollaborationModeNames, } from "./CodexSessionRuntime.ts"; const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); const encodeUnknownJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); @@ -368,6 +369,15 @@ describe("MCP elicitation", () => { }); }); +describe("Codex collaboration modes", () => { + it("discovers mode identifiers from app-server list responses", () => { + NodeAssert.deepStrictEqual( + [...parseCollaborationModeNames({ data: [{ mode: "default" }, { id: "plan" }] })], + ["default", "plan"], + ); + }); +}); + describe("buildCodexDeveloperInstructions", () => { it("appends runtime info after the mode instructions", () => { const instructions = buildCodexDeveloperInstructions("default", { diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 5896a8431fc..2b12659718c 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -131,6 +131,7 @@ export interface CodexAppServerConnectionOptions { export interface CodexAppServerConnection { readonly client: CodexClient.CodexAppServerClient["Service"]; readonly exitCode: ChildProcessSpawner.ChildProcessHandle["exitCode"]; + readonly collaborationModes: ReadonlySet; } export interface CodexSessionRuntimeSendTurnInput { @@ -198,6 +199,7 @@ export type CodexSessionRuntimeError = | CodexSessionRuntimePendingUserInputNotFoundError | CodexSessionRuntimeInvalidUserInputAnswersError | CodexSessionRuntimeActiveTurnNotSteerableError + | CodexSessionRuntimeCollaborationModeUnavailableError | CodexSessionRuntimeThreadIdMissingError; export class CodexSessionRuntimePendingApprovalNotFoundError extends Schema.TaggedErrorClass()( @@ -259,6 +261,18 @@ export class CodexSessionRuntimeActiveTurnNotSteerableError extends Schema.Tagge } } +export class CodexSessionRuntimeCollaborationModeUnavailableError extends Schema.TaggedErrorClass()( + "CodexSessionRuntimeCollaborationModeUnavailableError", + { + mode: Schema.String, + availableModes: Schema.Array(Schema.String), + }, +) { + override get message(): string { + return `Codex collaboration mode '${this.mode}' is unavailable.`; + } +} + interface PendingApproval { readonly requestId: ApprovalRequestId; readonly jsonRpcId: string; @@ -522,6 +536,7 @@ export function activeTurnNotSteerableKind(error: unknown): string | undefined { if (!isCodexAppServerRequestError(error)) { return undefined; } + return ( findActiveTurnNotSteerable(error.data) ?? (error.errorMessage.toLowerCase().includes("active turn") && @@ -531,6 +546,30 @@ export function activeTurnNotSteerableKind(error: unknown): string | undefined { ); } +export function parseCollaborationModeNames(value: unknown): ReadonlySet { + const modes = new Set(); + const visit = (entry: unknown): void => { + if (Array.isArray(entry)) { + for (const child of entry) visit(child); + return; + } + if (typeof entry !== "object" || entry === null) return; + for (const [key, child] of Object.entries(entry)) { + if ( + (key === "mode" || key === "id" || key === "name") && + typeof child === "string" && + child.trim().length > 0 + ) { + modes.add(child.trim()); + } else { + visit(child); + } + } + }; + visit(value); + return modes; +} + function classifyCodexStderrLine(rawLine: string): { readonly message: string } | null { const line = rawLine.replaceAll(ANSI_ESCAPE_REGEX, "").trim(); if (!line) { @@ -998,9 +1037,16 @@ export const makeCodexAppServerConnection = ( yield* client.request("initialize", buildCodexInitializeParams()); yield* client.notify("initialized", undefined); + const collaborationModes = yield* client.raw.request("collaborationMode/list", {}).pipe( + Effect.map(parseCollaborationModeNames), + Effect.catchIf(isTurnSteerUnavailableError, () => + Effect.succeed(new Set(["default", "plan"])), + ), + ); return { client, exitCode: child.exitCode, + collaborationModes, }; }); @@ -1718,6 +1764,12 @@ export const makeCodexSessionRuntime = ( sendTurn: (input) => Effect.gen(function* () { const providerThreadId = yield* readProviderThreadId; + if (input.interactionMode && !connection.collaborationModes.has(input.interactionMode)) { + return yield* new CodexSessionRuntimeCollaborationModeUnavailableError({ + mode: input.interactionMode, + availableModes: [...connection.collaborationModes], + }); + } if (options.mcpServer || hasConfiguredMcpServer(options.appServerArgs)) { yield* client.request("config/mcpServer/reload", undefined).pipe( Effect.catch((cause) => diff --git a/docs/operations/workplace-fork.md b/docs/operations/workplace-fork.md index a9e7253bf72..b380ab99a3d 100644 --- a/docs/operations/workplace-fork.md +++ b/docs/operations/workplace-fork.md @@ -25,6 +25,8 @@ Do not force-push either protected branch. Resolve workplace integration conflic Each configured local Codex environment owns one long-lived `codex app-server` process. T3 opens, resumes, and multiplexes native Codex threads over that connection. Native notifications and callbacks are routed by Codex `threadId`; closing a T3 thread removes its routed handlers but does not terminate the environment connection. The app-server remains authoritative for thread state and history. +At initialization T3 probes `collaborationMode/list` over the raw app-server channel and rejects unavailable requested modes. App-server versions that explicitly report method-not-found use the legacy `default`/`plan` set. T3 supplies mode-specific developer instructions only for those selected Codex modes to preserve the existing browser-preview integration; it does not replace base Codex instructions. + T3-only metadata (workspace presentation, local checkpoints, and GUI state) remains in T3 persistence. It must not replace or synthesize Codex lifecycle state when the app-server provides an authoritative operation. When T3 creates a Git worktree it can apply the source checkout's current tracked diff with `applyCurrentChanges`. It also copies ignored `AGENTS.override.md` automatically. A repository may list additional untracked local files or directories in `.worktreeinclude`, one repository-relative path per line. Blank lines and `#` comments are ignored; absolute and parent-traversal paths are rejected. From d796739ad3f716642c6d5281882d8158a67ba4dd Mon Sep 17 00:00:00 2001 From: Costas Panay Date: Sat, 18 Jul 2026 00:06:30 -0700 Subject: [PATCH 10/28] feat: preserve unknown Codex protocol events Add routed unknown request and notification subscriptions, visible method-not-found request fallback, a typed runtime.raw extension event, orchestration activity projection, and UI work-log coverage so future app-server methods are never silently dropped. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f047b252-1029-4093-9a8e-53456b7dd740 --- .../Layers/ProviderRuntimeIngestion.ts | 14 ++++ .../src/provider/Layers/CodexAdapter.test.ts | 31 ++++++++ .../src/provider/Layers/CodexAdapter.ts | 24 +++++- .../provider/Layers/CodexSessionRuntime.ts | 50 ++++++++++++ apps/web/src/session-logic.test.ts | 17 ++++ .../contracts/src/providerRuntime.test.ts | 23 ++++++ packages/contracts/src/providerRuntime.ts | 17 ++++ .../effect-codex-app-server/src/client.ts | 77 +++++++++++++++++-- 8 files changed, 243 insertions(+), 10 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 3e5978f4846..a83f775ad3c 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -384,6 +384,20 @@ function runtimeEventToActivities( ]; } + case "runtime.raw": + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: "info", + kind: "runtime.raw", + summary: `Codex event: ${event.payload.method}`, + payload: event.payload, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + case "turn.plan.updated": { return [ { diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 19570d4efdd..9931e16b458 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -502,6 +502,37 @@ function startLifecycleRuntime() { } lifecycleLayer("CodexAdapterLive lifecycle", (it) => { + it.effect("passes unknown Codex notifications through the raw extension channel", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + yield* runtime.emit({ + id: asEventId("evt-future"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "future/codex/event", + payload: { feature: "new" }, + }); + + const firstEvent = yield* Fiber.join(firstEventFiber); + NodeAssert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") return; + NodeAssert.equal(firstEvent.value.type, "runtime.raw"); + NodeAssert.deepStrictEqual(firstEvent.value.payload, { + method: "future/codex/event", + kind: "notification", + payload: { feature: "new" }, + }); + NodeAssert.deepStrictEqual(firstEvent.value.raw, { + source: "codex.app-server.notification", + method: "future/codex/event", + payload: { feature: "new" }, + }); + }), + ); + it.effect("routes native thread lifecycle actions to the active Codex runtime", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index d917d65e13f..3268438fdf0 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -507,7 +507,17 @@ function mapToRuntimeEvents( ): ReadonlyArray { if (event.kind === "error") { if (!event.message) { - return []; + return [ + { + ...runtimeEventBase(event, canonicalThreadId), + type: "runtime.raw", + payload: { + method: event.method, + kind: event.kind, + payload: event.payload ?? {}, + }, + }, + ]; } return [ { @@ -1348,7 +1358,17 @@ function mapToRuntimeEvents( ]; } - return []; + return [ + { + ...runtimeEventBase(event, canonicalThreadId), + type: "runtime.raw", + payload: { + method: event.method, + kind: event.kind, + payload: event.payload ?? {}, + }, + }, + ]; } /** diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 2b12659718c..2af02436aa9 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -700,6 +700,15 @@ function readNotificationThreadId(notification: CodexServerNotification): string } } +function readUnknownThreadId(params: unknown): string | undefined { + if (typeof params !== "object" || params === null) return undefined; + if ("threadId" in params && typeof params.threadId === "string") return params.threadId; + if ("conversationId" in params && typeof params.conversationId === "string") { + return params.conversationId; + } + return undefined; +} + function readRouteFields(notification: CodexServerNotification): { readonly turnId: TurnId | undefined; readonly itemId: ProviderItemId | undefined; @@ -1644,6 +1653,47 @@ export const makeCodexSessionRuntime = ( ) .pipe(Effect.flatMap((unregister) => Effect.addFinalizer(() => unregister))); + yield* client + .registerUnknownServerRequest( + (_method, params) => { + const requestThreadId = readUnknownThreadId(params); + return requestThreadId === undefined || requestThreadId === routedProviderThreadId; + }, + (method, params) => + Effect.gen(function* () { + const requestId = ApprovalRequestId.make(yield* randomUUIDv4("user-input-request")); + yield* emitEvent({ + kind: "request", + threadId: options.threadId, + method, + requestId, + message: `Unsupported Codex callback: ${method}`, + ...(params !== undefined ? { payload: params } : {}), + }); + return yield* CodexErrors.CodexAppServerRequestError.methodNotFound(method); + }), + ) + .pipe(Effect.flatMap((unregister) => Effect.addFinalizer(() => unregister))); + + yield* client + .registerUnknownServerNotification( + (_method, params) => { + const notificationThreadId = readUnknownThreadId(params); + return ( + notificationThreadId === undefined || notificationThreadId === routedProviderThreadId + ); + }, + (method, params) => + emitEvent({ + kind: "notification", + threadId: options.threadId, + method, + message: `Unrecognized Codex event: ${method}`, + ...(params !== undefined ? { payload: params } : {}), + }), + ) + .pipe(Effect.flatMap((unregister) => Effect.addFinalizer(() => unregister))); + const registerServerNotification = (method: M) => client .registerServerNotification( diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 0f12e672f66..d369ff9f070 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -691,6 +691,23 @@ describe("workEntryIndicatesToolFailure", () => { }); describe("deriveWorkLogEntries", () => { + it("keeps unknown Codex events visible in the work log", () => { + const entries = deriveWorkLogEntries([ + makeActivity({ + id: "raw-codex-event", + kind: "runtime.raw", + summary: "Codex event: future/codex/event", + payload: { + method: "future/codex/event", + kind: "notification", + payload: { feature: "new" }, + }, + }), + ]); + + expect(entries[0]?.label).toBe("Codex event: future/codex/event"); + }); + it("omits tool started entries and keeps completed entries", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/packages/contracts/src/providerRuntime.test.ts b/packages/contracts/src/providerRuntime.test.ts index 0e6ced58fd5..146c025ac4d 100644 --- a/packages/contracts/src/providerRuntime.test.ts +++ b/packages/contracts/src/providerRuntime.test.ts @@ -6,6 +6,29 @@ import { ProviderRuntimeEvent } from "./providerRuntime.ts"; const decodeRuntimeEvent = Schema.decodeUnknownSync(ProviderRuntimeEvent); describe("ProviderRuntimeEvent", () => { + it("decodes future Codex events through the raw extension channel", () => { + const parsed = decodeRuntimeEvent({ + type: "runtime.raw", + eventId: "event-future", + provider: "codex", + createdAt: "2026-02-28T00:00:00.000Z", + threadId: "thread-1", + raw: { + source: "codex.app-server.notification", + method: "future/codex/event", + payload: { feature: "new" }, + }, + payload: { + method: "future/codex/event", + kind: "notification", + payload: { feature: "new" }, + }, + }); + + expect(parsed.type).toBe("runtime.raw"); + expect(parsed.raw?.method).toBe("future/codex/event"); + }); + it("accepts fork-provided driver kinds as branded slugs", () => { const parsed = decodeRuntimeEvent({ type: "session.started", diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 2f6522b2742..47256604636 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -194,6 +194,7 @@ const ProviderRuntimeEventType = Schema.Literals([ "files.persisted", "runtime.warning", "runtime.error", + "runtime.raw", ]); export type ProviderRuntimeEventType = typeof ProviderRuntimeEventType.Type; @@ -245,6 +246,7 @@ const FilesPersistedType = Schema.Literal("files.persisted"); const ToolDeniedType = Schema.Literal("tool.denied"); const RuntimeWarningType = Schema.Literal("runtime.warning"); const RuntimeErrorType = Schema.Literal("runtime.error"); +const RuntimeRawType = Schema.Literal("runtime.raw"); const ProviderRuntimeEventBase = Schema.Struct({ eventId: EventId, @@ -612,6 +614,13 @@ const RuntimeErrorPayload = Schema.Struct({ }); export type RuntimeErrorPayload = typeof RuntimeErrorPayload.Type; +const RuntimeRawPayload = Schema.Struct({ + method: TrimmedNonEmptyStringSchema, + kind: Schema.Literals(["notification", "request", "session", "error"]), + payload: Schema.Unknown, +}); +export type RuntimeRawPayload = typeof RuntimeRawPayload.Type; + const ProviderRuntimeSessionStartedEvent = Schema.Struct({ ...ProviderRuntimeEventBase.fields, type: SessionStartedType, @@ -965,6 +974,13 @@ const ProviderRuntimeErrorEvent = Schema.Struct({ }); export type ProviderRuntimeErrorEvent = typeof ProviderRuntimeErrorEvent.Type; +const ProviderRuntimeRawEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: RuntimeRawType, + payload: RuntimeRawPayload, +}); +export type ProviderRuntimeRawEvent = typeof ProviderRuntimeRawEvent.Type; + export const ProviderRuntimeEventV2 = Schema.Union([ ProviderRuntimeSessionStartedEvent, ProviderRuntimeSessionConfiguredEvent, @@ -1014,6 +1030,7 @@ export const ProviderRuntimeEventV2 = Schema.Union([ ProviderRuntimeToolDeniedEvent, ProviderRuntimeWarningEvent, ProviderRuntimeErrorEvent, + ProviderRuntimeRawEvent, ]); export type ProviderRuntimeEventV2 = typeof ProviderRuntimeEventV2.Type; diff --git a/packages/effect-codex-app-server/src/client.ts b/packages/effect-codex-app-server/src/client.ts index 522e26a1abe..87ee3316c3e 100644 --- a/packages/effect-codex-app-server/src/client.ts +++ b/packages/effect-codex-app-server/src/client.ts @@ -85,12 +85,26 @@ export class CodexAppServerClient extends Context.Service< params: unknown, ) => Effect.Effect, ) => Effect.Effect; + readonly registerUnknownServerRequest: ( + accepts: (method: string, params: unknown) => boolean, + handler: ( + method: string, + params: unknown, + ) => Effect.Effect, + ) => Effect.Effect>; readonly handleUnknownServerNotification: ( handler: ( method: string, params: unknown, ) => Effect.Effect, ) => Effect.Effect; + readonly registerUnknownServerNotification: ( + accepts: (method: string, params: unknown) => boolean, + handler: ( + method: string, + params: unknown, + ) => Effect.Effect, + ) => Effect.Effect>; } >()("effect-codex-app-server/client/CodexAppServerClient") {} @@ -128,6 +142,26 @@ export const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make let unknownNotificationHandler: | ((method: string, params: unknown) => Effect.Effect) | undefined; + const routedUnknownRequestHandlers = new Map< + number, + { + readonly accepts: (method: string, params: unknown) => boolean; + readonly handler: ( + method: string, + params: unknown, + ) => Effect.Effect; + } + >(); + const routedUnknownNotificationHandlers = new Map< + number, + { + readonly accepts: (method: string, params: unknown) => boolean; + readonly handler: ( + method: string, + params: unknown, + ) => Effect.Effect; + } + >(); const getServerRequestParamSchema = ( method: M, @@ -195,11 +229,17 @@ export const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make ); } - return unknownNotificationHandler - ? unknownNotificationHandler(notification.method, notification.params).pipe( - Effect.catch(() => Effect.void), - ) - : Effect.void; + const routedHandlers = [...routedUnknownNotificationHandlers.values()].filter(({ accepts }) => + accepts(notification.method, notification.params), + ); + return Effect.forEach( + [ + ...routedHandlers.map(({ handler }) => handler), + ...(unknownNotificationHandler ? [unknownNotificationHandler] : []), + ], + (handler) => handler(notification.method, notification.params), + { discard: true }, + ).pipe(Effect.catch(() => Effect.void)); }; const dispatchRequest = ( @@ -221,9 +261,14 @@ export const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make ); } - return unknownRequestHandler - ? unknownRequestHandler(request.method, request.params) - : Effect.fail(CodexError.CodexAppServerRequestError.methodNotFound(request.method)); + const routedHandler = [...routedUnknownRequestHandlers.values()].find(({ accepts }) => + accepts(request.method, request.params), + )?.handler; + return routedHandler + ? routedHandler(request.method, request.params) + : unknownRequestHandler + ? unknownRequestHandler(request.method, request.params) + : Effect.fail(CodexError.CodexAppServerRequestError.methodNotFound(request.method)); }; const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ @@ -319,10 +364,26 @@ export const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make Effect.sync(() => { unknownRequestHandler = handler; }), + registerUnknownServerRequest: (accepts, handler) => + Effect.sync(() => { + const registrationId = nextRegistrationId++; + routedUnknownRequestHandlers.set(registrationId, { accepts, handler }); + return Effect.sync(() => { + routedUnknownRequestHandlers.delete(registrationId); + }); + }), handleUnknownServerNotification: (handler) => Effect.sync(() => { unknownNotificationHandler = handler; }), + registerUnknownServerNotification: (accepts, handler) => + Effect.sync(() => { + const registrationId = nextRegistrationId++; + routedUnknownNotificationHandlers.set(registrationId, { accepts, handler }); + return Effect.sync(() => { + routedUnknownNotificationHandlers.delete(registrationId); + }); + }), }); }); From 8412dff12d02769e0501bd897fec065e11388e33 Mon Sep 17 00:00:00 2001 From: Costas Panay Date: Sat, 18 Jul 2026 00:10:51 -0700 Subject: [PATCH 11/28] feat: support complete Codex input prompts Preserve optionless, Other, and secret question semantics, retain mixed and multi-select forms, and render secret answers through an explicitly masked composer control. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f047b252-1029-4093-9a8e-53456b7dd740 --- .../src/provider/Layers/CodexAdapter.test.ts | 11 ++ .../src/provider/Layers/CodexAdapter.ts | 4 +- .../provider/Layers/CodexSessionRuntime.ts | 6 + apps/web/src/components/chat/ChatComposer.tsx | 103 ++++++++++-------- .../chat/ComposerSecretUserInput.test.tsx | 23 ++++ .../chat/ComposerSecretUserInput.tsx | 33 ++++++ packages/contracts/src/providerRuntime.ts | 6 + 7 files changed, 142 insertions(+), 44 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerSecretUserInput.test.tsx create mode 100644 apps/web/src/components/chat/ComposerSecretUserInput.tsx diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 9931e16b458..40d45f0e73b 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -1086,6 +1086,13 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { description: "Allow workspace writes only", }, ], + isOther: true, + }, + { + id: "api_key", + header: "API key", + question: "Enter the secret API key", + isSecret: true, }, ], }, @@ -1113,6 +1120,10 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { NodeAssert.equal(events[0].requestId, "req-user-input-1"); NodeAssert.equal(events[0].payload.questions[0]?.id, "sandbox_mode"); NodeAssert.equal(events[0].payload.questions[0]?.multiSelect, false); + NodeAssert.equal(events[0].payload.questions[0]?.allowOther, true); + NodeAssert.equal(events[0].payload.questions[1]?.id, "api_key"); + NodeAssert.equal(events[0].payload.questions[1]?.isSecret, true); + NodeAssert.deepEqual(events[0].payload.questions[1]?.options, []); } NodeAssert.equal(events[1]?.type, "user-input.resolved"); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 3268438fdf0..81bb1481d9e 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -364,7 +364,7 @@ function toUserInputQuestions(questions: ReadonlyArray; + readonly allowOther: boolean; + readonly isSecret: boolean; readonly multiSelect: boolean; }> { if (payload.mode === "url") { @@ -901,6 +903,8 @@ export function mcpElicitationQuestions( { label: "Open", description: payload.url }, { label: "Decline", description: "Do not continue." }, ], + allowOther: false, + isSecret: false, multiSelect: false, }, ]; @@ -913,6 +917,8 @@ export function mcpElicitationQuestions( ? field.description : payload.message, options: mcpElicitationOptions(field), + allowOther: mcpElicitationOptions(field).length === 0, + isSecret: false, multiSelect: "type" in field && field.type === "array", })); } diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c9d5d49575a..28d706ab084 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -11,6 +11,7 @@ import type { ServerProvider, ThreadId, TurnId, + UserInputQuestion, } from "@t3tools/contracts"; import { ProviderDriverKind, @@ -75,6 +76,7 @@ import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; +import { ComposerSecretUserInput } from "./ComposerSecretUserInput"; import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; import { searchSlashCommandItems } from "./composerSlashCommandSearch"; @@ -462,7 +464,7 @@ export interface ChatComposerProps { isLastQuestion: boolean; canAdvance: boolean; customAnswer: string; - activeQuestion: { id: string; multiSelect?: boolean | undefined } | null; + activeQuestion: UserInputQuestion | null; } | null; activePendingResolvedAnswers: Record | null; activePendingIsResponding: boolean; @@ -1038,6 +1040,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const isComposerApprovalState = activePendingApproval !== null; const activePendingUserInput = pendingUserInputs[0] ?? null; + const activePendingQuestion = activePendingProgress?.activeQuestion ?? null; + const isSecretPendingUserInput = activePendingQuestion?.isSecret === true; const hasComposerHeader = isComposerApprovalState || pendingUserInputs.length > 0 || @@ -2385,48 +2389,61 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) )}
- + {isSecretPendingUserInput ? ( + { + onPromptChange(nextPrompt, nextPrompt.length, nextPrompt.length, false, []); + }} + /> + ) : ( + + )} {showMobilePendingAnswerActions ? (
{ + it("masks Codex secret answers and disables browser credential completion", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('type="password"'); + expect(html).toContain('autoComplete="off"'); + expect(html).toContain('name="codex-secret-response"'); + expect(html).toContain('aria-label="API key"'); + expect(html).not.toContain('type="text"'); + }); +}); diff --git a/apps/web/src/components/chat/ComposerSecretUserInput.tsx b/apps/web/src/components/chat/ComposerSecretUserInput.tsx new file mode 100644 index 00000000000..74db609e64d --- /dev/null +++ b/apps/web/src/components/chat/ComposerSecretUserInput.tsx @@ -0,0 +1,33 @@ +import { memo } from "react"; +import { cn } from "~/lib/utils"; + +export const ComposerSecretUserInput = memo(function ComposerSecretUserInput({ + label, + value, + disabled, + mobileActions, + onChange, +}: { + readonly label: string; + readonly value: string; + readonly disabled: boolean; + readonly mobileActions: boolean; + readonly onChange: (value: string) => void; +}) { + return ( + onChange(event.currentTarget.value)} + placeholder="Enter a secret response" + type="password" + value={value} + /> + ); +}); diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 47256604636..274656aa57a 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -446,6 +446,12 @@ export const UserInputQuestion = Schema.Struct({ header: TrimmedNonEmptyStringSchema, question: TrimmedNonEmptyStringSchema, options: Schema.Array(UserInputQuestionOption), + allowOther: Schema.optional(Schema.Boolean).pipe( + Schema.withConstructorDefault(Effect.succeed(false)), + ), + isSecret: Schema.optional(Schema.Boolean).pipe( + Schema.withConstructorDefault(Effect.succeed(false)), + ), multiSelect: Schema.optional(Schema.Boolean).pipe( Schema.withConstructorDefault(Effect.succeed(false)), ), From 1dcda40865a113588a4788a5803a63c6045b133c Mon Sep 17 00:00:00 2001 From: Costas Panay Date: Sat, 18 Jul 2026 00:12:58 -0700 Subject: [PATCH 12/28] feat: expose native Codex review targets Add a command-palette review workflow for uncommitted changes, base branches, commits, and custom instructions with inline or detached delivery. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f047b252-1029-4093-9a8e-53456b7dd740 --- apps/web/src/components/CommandPalette.tsx | 475 +++++++++--------- .../src/components/ReviewStartDialog.test.tsx | 29 ++ apps/web/src/components/ReviewStartDialog.tsx | 217 ++++++++ 3 files changed, 485 insertions(+), 236 deletions(-) create mode 100644 apps/web/src/components/ReviewStartDialog.test.tsx create mode 100644 apps/web/src/components/ReviewStartDialog.tsx diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 6e3e40e9fbd..213e1d9c3a4 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -113,6 +113,7 @@ import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteResults } from "./CommandPaletteResults"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; import { ProjectFavicon } from "./ProjectFavicon"; +import { ReviewStartDialog } from "./ReviewStartDialog"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; import { primaryServerKeybindingsAtom } from "../state/server"; import { resolveShortcutCommand } from "../keybindings"; @@ -490,6 +491,7 @@ function OpenCommandPaletteDialog(props: { const [addProjectCloneFlow, setAddProjectCloneFlow] = useState(null); const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); + const [reviewDialogOpen, setReviewDialogOpen] = useState(false); const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; const addProjectEnvironmentOptions = useMemo(() => { @@ -1063,30 +1065,15 @@ function OpenCommandPaletteDialog(props: { }); actionItems.push({ kind: "action", - value: "action:review-uncommitted", - searchTerms: ["review", "uncommitted", "changes", "codex", "inline"], - title: "Review uncommitted changes", - description: "Start a native Codex review in the current thread.", + value: "action:start-review", + searchTerms: ["review", "uncommitted", "base", "branch", "commit", "custom", "codex"], + title: "Start Codex review...", + description: "Choose the target, instructions, and review delivery.", icon: , - run: async () => { - await startReview(scopeThreadRef(activeThread.environmentId, activeThread.id), { - target: { type: "uncommittedChanges" }, - delivery: "inline", - }); - }, - }); - actionItems.push({ - kind: "action", - value: "action:review-uncommitted-detached", - searchTerms: ["review", "uncommitted", "changes", "codex", "detached"], - title: "Review uncommitted changes in a new thread", - description: "Start a detached native Codex review.", - icon: , - run: async () => { - await startReview(scopeThreadRef(activeThread.environmentId, activeThread.id), { - target: { type: "uncommittedChanges" }, - delivery: "detached", - }); + keepOpen: true, + run: () => { + setReviewDialogOpen(true); + return Promise.resolve(); }, }); } @@ -1701,234 +1688,250 @@ function OpenCommandPaletteDialog(props: { ]); return ( - { - composerHandleRef?.current?.focusAtEnd(); - return false; - }} - onBackdropPointerDown={() => { - setOpen(false); - }} - > - + { - setHighlightedItemValue(typeof value === "string" ? value : null); + className="overflow-hidden p-0" + data-command-palette="true" + data-testid="command-palette" + finalFocus={() => { + composerHandleRef?.current?.focusAtEnd(); + return false; + }} + onBackdropPointerDown={() => { + setOpen(false); }} - onValueChange={handleQueryChange} - value={query} > -
- { + setHighlightedItemValue(typeof value === "string" ? value : null); + }} + onValueChange={handleQueryChange} + value={query} + > +
+ - - - ), - } - : isBrowsing && !isSubmenu + } + {...(isSubmenu ? { - startAddon: , + startAddon: ( + + ), } - : {})} - onKeyDown={handleKeyDown} - /> - {addProjectCloneFlow?.step === "repository" ? ( - - { - event.preventDefault(); - }} - onClick={() => { - void submitAddProjectCloneFlow(); - }} - /> - } - > - {isRemoteProjectPending ? "Working" : remoteProjectButtonLabel} - - Enter - - - - {remoteProjectButtonLabel ?? "Continue"} (Enter) - - - ) : isBrowsing ? ( - - , } - onMouseDown={(event) => { - event.preventDefault(); - }} - onClick={() => { - if (relativePathNeedsActiveProject) { - return; - } - if (isCloneDestinationStep) { - void submitAddProjectCloneFlow(resolvedAddProjectPath); - } else { - void handleAddProject(resolvedAddProjectPath); + : {})} + onKeyDown={handleKeyDown} + /> + {addProjectCloneFlow?.step === "repository" ? ( + + { + event.preventDefault(); + }} + onClick={() => { + void submitAddProjectCloneFlow(); + }} + /> + } + > + {isRemoteProjectPending ? "Working" : remoteProjectButtonLabel} + + Enter + + + + {remoteProjectButtonLabel ?? "Continue"} (Enter) + + + ) : isBrowsing ? ( + + - } - > - - {isCloneDestinationStep && isRemoteProjectPending ? "Cloning" : submitActionLabel} - - - {hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"} - - - - {submitActionLabel} ({addShortcutLabel}) - - - ) : null} -
- - {remoteProjectContext ? ( -
-
- Repository -
-
- {remoteProjectContext.icon} - - - {remoteProjectContext.title} + onMouseDown={(event) => { + event.preventDefault(); + }} + onClick={() => { + if (relativePathNeedsActiveProject) { + return; + } + if (isCloneDestinationStep) { + void submitAddProjectCloneFlow(resolvedAddProjectPath); + } else { + void handleAddProject(resolvedAddProjectPath); + } + }} + /> + } + > + + {isCloneDestinationStep && isRemoteProjectPending + ? "Cloning" + : submitActionLabel} - - {remoteProjectContext.description} + + {hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"} + + + + {submitActionLabel} ({addShortcutLabel}) + + + ) : null} +
+ + {remoteProjectContext ? ( +
+
+ Repository +
+
+ {remoteProjectContext.icon} + + + {remoteProjectContext.title} + + + {remoteProjectContext.description} + - +
-
- ) : null} - -
- -
- - - - - - - - Navigate - - {addProjectCloneFlow?.step === "repository" ? ( - - Enter - - {remoteProjectButtonLabel ?? "Continue"} - - - ) : !canSubmitBrowsePath || hasHighlightedBrowseItem ? ( + ) : null} + + + +
- Enter - Select + + + + + + + Navigate - ) : null} - {isSubmenu ? ( + {addProjectCloneFlow?.step === "repository" ? ( + + Enter + + {remoteProjectButtonLabel ?? "Continue"} + + + ) : !canSubmitBrowsePath || hasHighlightedBrowseItem ? ( + + Enter + Select + + ) : null} + {isSubmenu ? ( + + Backspace + Back + + ) : null} - Backspace - Back + Esc + Close +
+ {canOpenProjectFromFileManager ? ( + ) : null} - - Esc - Close - -
- {canOpenProjectFromFileManager ? ( - - ) : null} -
- - + + + + {activeThread ? ( + { + await startReview(scopeThreadRef(activeThread.environmentId, activeThread.id), input); + setOpen(false); + }} + /> + ) : null} + ); } diff --git a/apps/web/src/components/ReviewStartDialog.test.tsx b/apps/web/src/components/ReviewStartDialog.test.tsx new file mode 100644 index 00000000000..587ce696e62 --- /dev/null +++ b/apps/web/src/components/ReviewStartDialog.test.tsx @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vite-plus/test"; +import { buildReviewTarget } from "./ReviewStartDialog"; + +describe("buildReviewTarget", () => { + it("builds every native Codex review target", () => { + expect(buildReviewTarget({ type: "uncommittedChanges", value: "", commitTitle: "" })).toEqual({ + type: "uncommittedChanges", + }); + expect(buildReviewTarget({ type: "baseBranch", value: " main ", commitTitle: "" })).toEqual({ + type: "baseBranch", + branch: "main", + }); + expect(buildReviewTarget({ type: "commit", value: " abc123 ", commitTitle: " Fix " })).toEqual({ + type: "commit", + sha: "abc123", + title: "Fix", + }); + expect(buildReviewTarget({ type: "custom", value: " Check auth ", commitTitle: "" })).toEqual({ + type: "custom", + instructions: "Check auth", + }); + }); + + it("rejects targets that require missing input", () => { + expect(buildReviewTarget({ type: "baseBranch", value: " ", commitTitle: "" })).toBeNull(); + expect(buildReviewTarget({ type: "commit", value: "", commitTitle: "" })).toBeNull(); + expect(buildReviewTarget({ type: "custom", value: "", commitTitle: "" })).toBeNull(); + }); +}); diff --git a/apps/web/src/components/ReviewStartDialog.tsx b/apps/web/src/components/ReviewStartDialog.tsx new file mode 100644 index 00000000000..2878cc98308 --- /dev/null +++ b/apps/web/src/components/ReviewStartDialog.tsx @@ -0,0 +1,217 @@ +import type { ThreadReviewTarget } from "@t3tools/contracts"; +import { useEffect, useMemo, useState } from "react"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { Input } from "./ui/input"; +import { Textarea } from "./ui/textarea"; +import { cn } from "~/lib/utils"; + +type ReviewTargetKind = ThreadReviewTarget["type"]; +type ReviewDelivery = "inline" | "detached"; +type ReviewTargetInput = + | { readonly type: "uncommittedChanges" } + | { readonly type: "baseBranch"; readonly branch: string } + | { readonly type: "commit"; readonly sha: string; readonly title?: string | null } + | { readonly type: "custom"; readonly instructions: string }; + +const TARGETS: ReadonlyArray<{ + readonly type: ReviewTargetKind; + readonly label: string; + readonly description: string; +}> = [ + { + type: "uncommittedChanges", + label: "Uncommitted", + description: "Review current working-tree changes.", + }, + { + type: "baseBranch", + label: "Base branch", + description: "Review changes relative to a branch.", + }, + { + type: "commit", + label: "Commit", + description: "Review one commit by SHA.", + }, + { + type: "custom", + label: "Custom", + description: "Give Codex custom review instructions.", + }, +]; + +export function buildReviewTarget(input: { + readonly type: ReviewTargetKind; + readonly value: string; + readonly commitTitle: string; +}): ReviewTargetInput | null { + const value = input.value.trim(); + switch (input.type) { + case "uncommittedChanges": + return { type: "uncommittedChanges" }; + case "baseBranch": + return value ? { type: "baseBranch", branch: value } : null; + case "commit": { + if (!value) return null; + const title = input.commitTitle.trim(); + return { type: "commit", sha: value, ...(title ? { title } : {}) }; + } + case "custom": + return value ? { type: "custom", instructions: value } : null; + } +} + +export function ReviewStartDialog({ + open, + onOpenChange, + onStart, +}: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly onStart: (input: { + readonly target: ReviewTargetInput; + readonly delivery: ReviewDelivery; + }) => Promise; +}) { + const [targetType, setTargetType] = useState("uncommittedChanges"); + const [value, setValue] = useState(""); + const [commitTitle, setCommitTitle] = useState(""); + const [delivery, setDelivery] = useState("inline"); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (!open) return; + setTargetType("uncommittedChanges"); + setValue(""); + setCommitTitle(""); + setDelivery("inline"); + setSubmitting(false); + }, [open]); + + const target = useMemo( + () => buildReviewTarget({ type: targetType, value, commitTitle }), + [commitTitle, targetType, value], + ); + + const submit = async () => { + if (!target || submitting) return; + setSubmitting(true); + try { + await onStart({ target, delivery }); + onOpenChange(false); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + Start Codex review + + Run the app-server's native review flow against the exact scope you choose. + + + +
+ {TARGETS.map((option) => ( + + ))} +
+ + {targetType === "baseBranch" ? ( + setValue(event.currentTarget.value)} + /> + ) : targetType === "commit" ? ( +
+ setValue(event.currentTarget.value)} + /> + setCommitTitle(event.currentTarget.value)} + /> +
+ ) : targetType === "custom" ? ( +