diff --git a/apps/desktop/src/app/DesktopAppActivation.test.ts b/apps/desktop/src/app/DesktopAppActivation.test.ts index d6ce80322798..adea66be780f 100644 --- a/apps/desktop/src/app/DesktopAppActivation.test.ts +++ b/apps/desktop/src/app/DesktopAppActivation.test.ts @@ -5,10 +5,11 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import { + EnvironmentId, ProjectId, ThreadId, type DesktopAppActivationRequest, - type DesktopAppActivationResponse, + type DesktopAppControlResponse, } from "@t3tools/contracts"; import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl"; import { HostProcessPlatform, HostProcessUserId } from "@t3tools/shared/hostProcess"; @@ -44,8 +45,22 @@ function request(requestId: string, platform: NodeJS.Platform): DesktopAppActiva }; } -function exchange(address: string, payload: DesktopAppActivationRequest) { - return new Promise((resolve, reject) => { +function openThreadRequest( + requestId: string, + platform: NodeJS.Platform, +): DesktopAppActivationRequest { + return { + version: 1, + requestId, + type: "open-thread", + platform: platform === "win32" ? "win32" : platform === "darwin" ? "darwin" : "linux", + environmentId: EnvironmentId.make("primary"), + threadId: ThreadId.make("thread-1"), + }; +} + +function exchange(address: string, payload: unknown) { + return new Promise((resolve, reject) => { const socket = NodeNet.createConnection(address); socket.setEncoding("utf8"); let buffer = ""; @@ -56,7 +71,7 @@ function exchange(address: string, payload: DesktopAppActivationRequest) { const newline = buffer.indexOf("\n"); if (newline === -1) return; socket.destroy(); - resolve(JSON.parse(buffer.slice(0, newline)) as DesktopAppActivationResponse); + resolve(JSON.parse(buffer.slice(0, newline)) as DesktopAppControlResponse); }); }); } @@ -137,4 +152,137 @@ describe("desktop app control server", () => { }); }), ); + + it.effect("answers a capabilities probe without invoking activation", () => + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const userId = yield* HostProcessUserId; + yield* Effect.promise(async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-probe-test-")); + const target = makeTarget(NodePath.join(root, "userdata"), platform, userId); + const received: DesktopAppActivationRequest[] = []; + const server = await startDesktopAppControlServer({ + ...target, + userId, + handle: async (input) => { + received.push(input); + return { + version: 1, + requestId: input.requestId, + ok: true, + projectId: ProjectId.make("project-1"), + threadId: ThreadId.make("thread-1"), + }; + }, + cancel: () => undefined, + }); + openServers.push(server); + + const response = await exchange(target.address, { + version: 1, + requestId: "probe-1", + type: "get-capabilities", + }); + + expect(received).toHaveLength(0); + expect(response).toEqual({ + version: 1, + requestId: "probe-1", + ok: true, + type: "capabilities", + operations: ["open-workspace", "open-thread"], + environmentScope: "primary", + }); + + await server.close(); + openServers.splice(openServers.indexOf(server), 1); + await NodeFSP.rm(root, { recursive: true, force: true }); + }); + }), + ); + + it.effect("roundtrips an existing-thread request", () => + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const userId = yield* HostProcessUserId; + yield* Effect.promise(async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-thread-test-")); + const target = makeTarget(NodePath.join(root, "userdata"), platform, userId); + const received: DesktopAppActivationRequest[] = []; + const server = await startDesktopAppControlServer({ + ...target, + userId, + handle: async (input) => { + received.push(input); + return { + version: 1, + requestId: input.requestId, + ok: true, + environmentId: EnvironmentId.make("primary"), + projectId: ProjectId.make("project-1"), + threadId: ThreadId.make("thread-1"), + }; + }, + cancel: () => undefined, + }); + openServers.push(server); + + const response = await exchange(target.address, openThreadRequest("thread-1", platform)); + + expect(received).toEqual([openThreadRequest("thread-1", platform)]); + expect(response).toMatchObject({ + ok: true, + requestId: "thread-1", + environmentId: "primary", + projectId: "project-1", + threadId: "thread-1", + }); + + await server.close(); + openServers.splice(openServers.indexOf(server), 1); + await NodeFSP.rm(root, { recursive: true, force: true }); + }); + }), + ); + + it.effect("rejects a malformed control request", () => + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const userId = yield* HostProcessUserId; + yield* Effect.promise(async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-invalid-test-")); + const target = makeTarget(NodePath.join(root, "userdata"), platform, userId); + const received: DesktopAppActivationRequest[] = []; + const server = await startDesktopAppControlServer({ + ...target, + userId, + handle: async (input) => { + received.push(input); + return { + version: 1, + requestId: input.requestId, + ok: true, + projectId: ProjectId.make("project-1"), + threadId: ThreadId.make("thread-1"), + }; + }, + cancel: () => undefined, + }); + openServers.push(server); + + const response = await exchange(target.address, { + version: 1, + requestId: "bad-1", + type: "open-thread", + }); + + expect(received).toHaveLength(0); + expect(response).toMatchObject({ ok: false, code: "invalid-request" }); + + await server.close(); + openServers.splice(openServers.indexOf(server), 1); + await NodeFSP.rm(root, { recursive: true, force: true }); + }); + }), + ); }); diff --git a/apps/desktop/src/app/DesktopAppActivation.ts b/apps/desktop/src/app/DesktopAppActivation.ts index 50fc70d783e4..742da1a470b9 100644 --- a/apps/desktop/src/app/DesktopAppActivation.ts +++ b/apps/desktop/src/app/DesktopAppActivation.ts @@ -6,7 +6,10 @@ import * as NodeOS from "node:os"; import { DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, DesktopAppActivationRequest, + DesktopAppControlRequest, type DesktopAppActivationResponse, + type DesktopAppCapabilitiesSuccess, + type DesktopAppControlResponse, } from "@t3tools/contracts"; import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl"; import { HostProcessUserId } from "@t3tools/shared/hostProcess"; @@ -29,6 +32,7 @@ import { makeComponentLogger } from "./DesktopObservability.ts"; const MAX_REQUEST_BYTES = 64 * 1024; const REQUEST_TIMEOUT_MS = 15_000; +const isDesktopAppControlRequest = Schema.is(DesktopAppControlRequest); const isDesktopAppActivationRequest = Schema.is(DesktopAppActivationRequest); export class DesktopAppActivationStartError extends Schema.TaggedError()( @@ -57,6 +61,17 @@ function invalidResponse(requestId: string, message: string): DesktopAppActivati }; } +function capabilitiesResponse(requestId: string): DesktopAppCapabilitiesSuccess { + return { + version: DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, + requestId, + ok: true, + type: "capabilities", + operations: ["open-workspace", "open-thread"], + environmentScope: "primary", + }; +} + function requestIdFromUnknown(value: unknown): string { if ( typeof value === "object" && @@ -115,7 +130,7 @@ export async function startDesktopAppControlServer(input: { socket.setTimeout(5_000, () => socket.destroy()); - const finish = (response: DesktopAppActivationResponse) => { + const finish = (response: DesktopAppControlResponse) => { responseSent = true; if (!socket.destroyed) socket.end(`${JSON.stringify(response)}\n`); }; @@ -142,6 +157,18 @@ export async function startDesktopAppControlServer(input: { return; } + if (!isDesktopAppControlRequest(parsed)) { + finish( + invalidResponse(requestIdFromUnknown(parsed), "The desktop app request is invalid."), + ); + return; + } + if (parsed.type === "get-capabilities") { + // Answer probes immediately: no window focus, no renderer wait, and no + // request bookkeeping, so a capability check never activates anything. + finish(capabilitiesResponse(parsed.requestId)); + return; + } if (!isDesktopAppActivationRequest(parsed)) { finish( invalidResponse(requestIdFromUnknown(parsed), "The desktop app request is invalid."), @@ -208,6 +235,7 @@ export class DesktopAppActivation extends Context.Service< readonly start: Effect.Effect; readonly setRendererReady: (ready: boolean) => Effect.Effect; readonly complete: (response: DesktopAppActivationResponse) => Effect.Effect; + readonly isRequestActive: (requestId: string) => boolean; } >()("@t3tools/desktop/app/DesktopAppActivation") {} @@ -301,6 +329,7 @@ export const make = Effect.gen(function* () { }); }), complete: (response) => Effect.sync(() => broker.complete(response)), + isRequestActive: (requestId) => broker.isRequestActive(requestId), }); }); diff --git a/apps/desktop/src/app/DesktopAppActivationBroker.test.ts b/apps/desktop/src/app/DesktopAppActivationBroker.test.ts index 7a889c2e91d3..0d7956a04400 100644 --- a/apps/desktop/src/app/DesktopAppActivationBroker.test.ts +++ b/apps/desktop/src/app/DesktopAppActivationBroker.test.ts @@ -1,4 +1,9 @@ -import { ProjectId, ThreadId, type DesktopAppActivationRequest } from "@t3tools/contracts"; +import { + EnvironmentId, + ProjectId, + ThreadId, + type DesktopAppActivationRequest, +} from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { DesktopAppActivationBroker } from "./DesktopAppActivationBroker.ts"; @@ -11,10 +16,228 @@ const request: DesktopAppActivationRequest = { platform: "linux", }; +function threadRequest(requestId: string, threadId = "thread-1"): DesktopAppActivationRequest { + return { + version: 1, + requestId, + type: "open-thread", + platform: "linux", + environmentId: EnvironmentId.make("primary"), + threadId: ThreadId.make(threadId), + }; +} + +function success(requestId: string) { + return { + version: 1 as const, + requestId, + ok: true as const, + projectId: ProjectId.make("project-1"), + threadId: ThreadId.make("thread-1"), + }; +} + +function threadSuccess(requestId: string, threadId: string) { + return { + version: 1 as const, + requestId, + ok: true as const, + environmentId: EnvironmentId.make("primary"), + projectId: ProjectId.make("project-1"), + threadId: ThreadId.make(threadId), + }; +} + +function threadFailure(requestId: string) { + return { + version: 1 as const, + requestId, + ok: false as const, + code: "thread-not-found" as const, + message: "The stale renderer could not find the thread.", + }; +} + +function threadSuperseded(requestId: string) { + return { + version: 1 as const, + requestId, + ok: false as const, + code: "request-superseded" as const, + message: "The renderer request is no longer active.", + }; +} + +function deferred() { + let resolve!: () => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function captureRenderer() { + const requests: DesktopAppActivationRequest[] = []; + const send = vi.fn((rendererRequest: DesktopAppActivationRequest) => { + requests.push(rendererRequest); + }); + return { requests, send }; +} + +async function expectStaleCompletionIgnored( + invalidation: "cancel" | "supersede", + staleCompletion: "failure" | "different-target-success" | "same-target-success", +): Promise { + const activate = vi.fn(); + const dispatched: DesktopAppActivationRequest[] = []; + const send = vi.fn((rendererRequest: DesktopAppActivationRequest) => { + dispatched.push(rendererRequest); + }); + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate }); + broker.registerRenderer(send); + + const originalRequest = threadRequest("reused-client-id", "old-thread"); + const originalResponse = broker.request(originalRequest); + const originalDispatch = dispatched[0]!; + + let supersedingResponse: Promise> | undefined; + if (invalidation === "cancel") { + broker.cancel(originalRequest.requestId); + await expect(originalResponse).resolves.toMatchObject({ + ok: false, + code: "renderer-unavailable", + }); + } else { + supersedingResponse = broker.request(threadRequest("superseding-client-id", "middle-thread")); + await expect(originalResponse).resolves.toMatchObject({ + ok: false, + code: "request-superseded", + }); + } + + const replacementRequest = threadRequest("reused-client-id", "replacement-thread"); + const replacementResponse = broker.request(replacementRequest); + if (supersedingResponse !== undefined) { + await expect(supersedingResponse).resolves.toMatchObject({ + ok: false, + code: "request-superseded", + }); + } + const replacementDispatch = dispatched.at(-1)!; + + expect(originalDispatch.requestId).not.toBe(originalRequest.requestId); + expect(replacementDispatch.requestId).not.toBe(originalDispatch.requestId); + expect(broker.isRequestActive(originalDispatch.requestId)).toBe(false); + expect(broker.isRequestActive(replacementDispatch.requestId)).toBe(true); + expect(broker.isRequestActive(originalRequest.requestId)).toBe(false); + expect(broker.isRequestActive(replacementRequest.requestId)).toBe(false); + expect(originalRequest).toEqual(threadRequest("reused-client-id", "old-thread")); + expect(replacementRequest).toEqual(threadRequest("reused-client-id", "replacement-thread")); + + const received: Awaited[] = []; + void replacementResponse.then((response) => received.push(response)); + broker.complete(threadFailure(originalRequest.requestId)); + await Promise.resolve(); + expect(received).toEqual([]); + + const staleResponse = + staleCompletion === "failure" + ? threadFailure(originalDispatch.requestId) + : threadSuccess( + originalDispatch.requestId, + staleCompletion === "same-target-success" ? "replacement-thread" : "other-thread", + ); + broker.complete(staleResponse); + await Promise.resolve(); + + expect(received).toEqual([]); + expect(activate).not.toHaveBeenCalled(); + + broker.complete(threadSuccess(replacementDispatch.requestId, "replacement-thread")); + const response = await replacementResponse; + expect(response).toMatchObject({ + ok: true, + requestId: "reused-client-id", + threadId: "replacement-thread", + }); + expect(activate).toHaveBeenCalledOnce(); + broker.close(); +} + describe("DesktopAppActivationBroker", () => { + it("ignores stale failures and successes when a canceled or superseded client id is reused", async () => { + for (const invalidation of ["cancel", "supersede"] as const) { + for (const staleCompletion of [ + "failure", + "different-target-success", + "same-target-success", + ] as const) { + await expectStaleCompletionIgnored(invalidation, staleCompletion); + } + } + }); + + it("stops a delayed renderer task from navigating after a canceled client id is reused", async () => { + const activate = vi.fn(); + const dispatched: DesktopAppActivationRequest[] = []; + const send = vi.fn((rendererRequest: DesktopAppActivationRequest) => { + dispatched.push(rendererRequest); + }); + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate }); + broker.registerRenderer(send); + + const oldThreadId = ThreadId.make("old-thread"); + const oldRequest = threadRequest("reused-client-id", oldThreadId); + const oldResponse = broker.request(oldRequest); + const oldDispatch = dispatched[0]!; + const probeStarted = deferred(); + const releaseProbe = deferred(); + const navigated: string[] = []; + const staleTask = (async () => { + probeStarted.resolve(); + await releaseProbe.promise; + if (!(await Promise.resolve(broker.isRequestActive(oldDispatch.requestId)))) { + return threadSuperseded(oldDispatch.requestId); + } + navigated.push(oldThreadId); + return threadSuccess(oldDispatch.requestId, oldThreadId); + })(); + await probeStarted.promise; + + broker.cancel(oldRequest.requestId); + await expect(oldResponse).resolves.toMatchObject({ ok: false, code: "renderer-unavailable" }); + const replacementResponse = broker.request( + threadRequest("reused-client-id", "replacement-thread"), + ); + const replacementDispatch = dispatched[1]!; + + expect(broker.isRequestActive(oldDispatch.requestId)).toBe(false); + expect(broker.isRequestActive(replacementDispatch.requestId)).toBe(true); + releaseProbe.resolve(); + + const staleResponse = await staleTask; + expect(staleResponse).toMatchObject({ + ok: false, + code: "request-superseded", + requestId: oldDispatch.requestId, + }); + expect(navigated).toEqual([]); + broker.complete(staleResponse); + broker.complete(threadSuccess(replacementDispatch.requestId, "replacement-thread")); + + await expect(replacementResponse).resolves.toMatchObject({ + ok: true, + requestId: "reused-client-id", + threadId: "replacement-thread", + }); + expect(navigated).toEqual([]); + expect(activate).toHaveBeenCalledOnce(); + broker.close(); + }); + it("focuses immediately and waits for renderer readiness", async () => { const activate = vi.fn(); - const send = vi.fn(); + const { requests: dispatched, send } = captureRenderer(); const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate }); const response = broker.request(request); @@ -22,10 +245,11 @@ describe("DesktopAppActivationBroker", () => { expect(send).not.toHaveBeenCalled(); broker.registerRenderer(send); - expect(send).toHaveBeenCalledWith(request); + expect(dispatched[0]).toMatchObject({ ...request, requestId: expect.any(String) }); + expect(dispatched[0]?.requestId).not.toBe(request.requestId); broker.complete({ version: 1, - requestId: request.requestId, + requestId: dispatched[0]!.requestId, ok: true, projectId: ProjectId.make("project-1"), threadId: ThreadId.make("thread-1"), @@ -51,7 +275,7 @@ describe("DesktopAppActivationBroker", () => { it("queues requests after unsubscribe until a new renderer registers", async () => { const previousSend = vi.fn(); - const nextSend = vi.fn(); + const { requests: dispatched, send: nextSend } = captureRenderer(); const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); broker.registerRenderer(previousSend); broker.clearRenderer(); @@ -61,10 +285,10 @@ describe("DesktopAppActivationBroker", () => { expect(nextSend).not.toHaveBeenCalled(); broker.registerRenderer(nextSend); - expect(nextSend).toHaveBeenCalledWith(request); + expect(dispatched[0]).toMatchObject({ ...request, requestId: expect.any(String) }); broker.complete({ version: 1, - requestId: request.requestId, + requestId: dispatched[0]!.requestId, ok: true, projectId: ProjectId.make("project-1"), threadId: ThreadId.make("thread-1"), @@ -88,7 +312,7 @@ describe("DesktopAppActivationBroker", () => { }); it("never sends a canceled request that was queued behind another request", async () => { - const send = vi.fn(); + const { requests: dispatched, send } = captureRenderer(); const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); broker.registerRenderer(send); const secondRequest = { ...request, requestId: "request-2" }; @@ -96,12 +320,12 @@ describe("DesktopAppActivationBroker", () => { const firstResponse = broker.request(request); const secondResponse = broker.request(secondRequest); expect(send).toHaveBeenCalledTimes(1); - expect(send).toHaveBeenLastCalledWith(request); + expect(dispatched[0]).toMatchObject({ ...request, requestId: expect.any(String) }); broker.cancel(secondRequest.requestId); broker.complete({ version: 1, - requestId: request.requestId, + requestId: dispatched[0]!.requestId, ok: true, projectId: ProjectId.make("project-1"), threadId: ThreadId.make("thread-1"), @@ -127,4 +351,304 @@ describe("DesktopAppActivationBroker", () => { vi.useRealTimers(); } }); + + it("supersedes a queued open-thread request without dispatching it", async () => { + const { requests: dispatched, send } = captureRenderer(); + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); + broker.registerRenderer(send); + + const workspace = broker.request(request); + expect(send).toHaveBeenCalledTimes(1); + const workspaceDispatch = dispatched[0]!; + + const stale = broker.request(threadRequest("thread-a")); + expect(broker.isRequestActive("thread-a")).toBe(false); + const fresh = broker.request(threadRequest("thread-b")); + + await expect(stale).resolves.toMatchObject({ ok: false, code: "request-superseded" }); + expect(send).toHaveBeenCalledTimes(1); + + broker.complete(success(workspaceDispatch.requestId)); + expect(send).toHaveBeenCalledTimes(2); + expect(dispatched[1]).toMatchObject({ + ...threadRequest("thread-b"), + requestId: expect.any(String), + }); + + broker.complete({ + version: 1, + requestId: dispatched[1]!.requestId, + ok: false, + code: "thread-open-failed", + message: "The test stopped the navigation.", + }); + await expect(fresh).resolves.toMatchObject({ ok: false }); + await expect(workspace).resolves.toMatchObject({ ok: true }); + broker.close(); + }); + + it("reports a queued request inactive until the renderer receives it", async () => { + const { requests: dispatched, send } = captureRenderer(); + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); + + const queued = broker.request(request); + expect(broker.isRequestActive(request.requestId)).toBe(false); + + broker.registerRenderer(send); + const dispatchRequestId = dispatched[0]!.requestId; + expect(broker.isRequestActive(dispatchRequestId)).toBe(true); + expect(broker.isRequestActive(request.requestId)).toBe(false); + + broker.complete(success(dispatchRequestId)); + expect(broker.isRequestActive(dispatchRequestId)).toBe(false); + await queued; + broker.close(); + }); + + it("activates an open-thread once only after the renderer confirms the target", async () => { + const activate = vi.fn(); + const { requests: dispatched, send } = captureRenderer(); + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate }); + broker.registerRenderer(send); + + const response = broker.request(threadRequest("thread-1")); + expect(activate).not.toHaveBeenCalled(); + const dispatchRequestId = dispatched[0]!.requestId; + expect(dispatched[0]).toMatchObject({ + ...threadRequest("thread-1"), + requestId: expect.any(String), + }); + + broker.complete({ + version: 1, + requestId: dispatchRequestId, + ok: true, + environmentId: EnvironmentId.make("primary"), + projectId: ProjectId.make("project-1"), + threadId: ThreadId.make("thread-1"), + }); + + await expect(response).resolves.toMatchObject({ ok: true, threadId: "thread-1" }); + expect(activate).toHaveBeenCalledOnce(); + broker.close(); + }); + + it("does not activate when the renderer reports the thread missing", async () => { + const activate = vi.fn(); + const { requests: dispatched, send } = captureRenderer(); + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate }); + broker.registerRenderer(send); + + const response = broker.request(threadRequest("thread-missing")); + broker.complete({ + version: 1, + requestId: dispatched[0]!.requestId, + ok: false, + code: "thread-not-found", + message: "No such thread.", + }); + + await expect(response).resolves.toMatchObject({ + ok: false, + code: "thread-not-found", + requestId: "thread-missing", + }); + expect(activate).not.toHaveBeenCalled(); + broker.close(); + }); + + it("fails an open-thread immediately and never queues it without a renderer", async () => { + const activate = vi.fn(); + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate }); + + const response = broker.request(threadRequest("no-window")); + await expect(response).resolves.toMatchObject({ ok: false, code: "renderer-unavailable" }); + expect(activate).not.toHaveBeenCalled(); + + const send = vi.fn(); + broker.registerRenderer(send); + expect(send).not.toHaveBeenCalled(); + broker.close(); + }); + + it("rejects an open-thread with no renderer without superseding pending work", async () => { + const activate = vi.fn(); + const send = vi.fn(); + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate }); + broker.registerRenderer(send); + + const workspace = broker.request(request); + const queued = broker.request(threadRequest("queued")); + expect(activate).toHaveBeenCalledOnce(); + broker.clearRenderer(); + await expect(workspace).resolves.toMatchObject({ ok: false, code: "renderer-unavailable" }); + await expect(queued).resolves.toMatchObject({ ok: false, code: "renderer-unavailable" }); + + const rejected = broker.request(threadRequest("late-with-no-window")); + await expect(rejected).resolves.toMatchObject({ ok: false, code: "renderer-unavailable" }); + + const nextSend = vi.fn(); + broker.registerRenderer(nextSend); + expect(nextSend).not.toHaveBeenCalled(); + + broker.complete({ + version: 1, + requestId: "queued", + ok: true, + environmentId: EnvironmentId.make("primary"), + projectId: ProjectId.make("project-1"), + threadId: ThreadId.make("thread-1"), + }); + expect(activate).toHaveBeenCalledOnce(); + broker.close(); + }); + + it("fails an open-thread when the renderer throws instead of requeueing it", async () => { + const activate = vi.fn(); + const throwingSend = vi.fn(() => { + throw new Error("renderer send failed"); + }); + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate }); + broker.registerRenderer(throwingSend); + + const response = broker.request(threadRequest("thread-throws")); + await expect(response).resolves.toMatchObject({ ok: false, code: "renderer-unavailable" }); + expect(activate).not.toHaveBeenCalled(); + + const nextSend = vi.fn(); + broker.registerRenderer(nextSend); + expect(nextSend).not.toHaveBeenCalled(); + broker.close(); + }); + + it("uses a new dispatch id when an open-workspace request is retried after renderer send fails", async () => { + const failedDispatches: DesktopAppActivationRequest[] = []; + const failingRenderer = vi.fn((rendererRequest: DesktopAppActivationRequest) => { + failedDispatches.push(rendererRequest); + throw new Error("renderer send failed"); + }); + const { requests: successfulDispatches, send: workingRenderer } = captureRenderer(); + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); + broker.registerRenderer(failingRenderer); + + const response = broker.request(request); + expect(failingRenderer).toHaveBeenCalledOnce(); + + broker.registerRenderer(workingRenderer); + expect(successfulDispatches).toHaveLength(1); + expect(successfulDispatches[0]?.requestId).not.toBe(failedDispatches[0]?.requestId); + broker.complete(success(successfulDispatches[0]!.requestId)); + + await expect(response).resolves.toMatchObject({ + ok: true, + requestId: request.requestId, + }); + broker.close(); + }); + + it("does not activate when a cancelled open-thread later completes", async () => { + const activate = vi.fn(); + const { requests: dispatched, send } = captureRenderer(); + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate }); + broker.registerRenderer(send); + + const response = broker.request(threadRequest("thread-1")); + const dispatchRequestId = dispatched[0]!.requestId; + broker.cancel("thread-1"); + await expect(response).resolves.toMatchObject({ ok: false, code: "renderer-unavailable" }); + + broker.complete({ + version: 1, + requestId: dispatchRequestId, + ok: true, + environmentId: EnvironmentId.make("primary"), + projectId: ProjectId.make("project-1"), + threadId: ThreadId.make("thread-1"), + }); + expect(activate).not.toHaveBeenCalled(); + broker.close(); + }); + + it("does not activate a superseded open-thread when a late success arrives", async () => { + const activate = vi.fn(); + const { requests: dispatched, send } = captureRenderer(); + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate }); + broker.registerRenderer(send); + + const stale = broker.request(threadRequest("thread-a")); + const staleDispatch = dispatched[0]!; + const fresh = broker.request(threadRequest("thread-b")); + const freshDispatch = dispatched[1]!; + await expect(stale).resolves.toMatchObject({ ok: false, code: "request-superseded" }); + expect(activate).not.toHaveBeenCalled(); + expect(broker.isRequestActive(staleDispatch.requestId)).toBe(false); + expect(broker.isRequestActive(freshDispatch.requestId)).toBe(true); + + broker.complete({ + version: 1, + requestId: staleDispatch.requestId, + ok: true, + environmentId: EnvironmentId.make("primary"), + projectId: ProjectId.make("project-1"), + threadId: ThreadId.make("thread-1"), + }); + expect(activate).not.toHaveBeenCalled(); + + broker.cancel("thread-b"); + await fresh; + broker.close(); + }); + + it("does not activate or report success when the renderer acks a different target", async () => { + const activate = vi.fn(); + const { requests: dispatched, send } = captureRenderer(); + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate }); + broker.registerRenderer(send); + + const response = broker.request(threadRequest("thread-1")); + broker.complete({ + version: 1, + requestId: dispatched[0]!.requestId, + ok: true, + environmentId: EnvironmentId.make("other-environment"), + projectId: ProjectId.make("project-1"), + threadId: ThreadId.make("thread-1"), + }); + + await expect(response).resolves.toMatchObject({ ok: false, code: "thread-open-failed" }); + expect(activate).not.toHaveBeenCalled(); + broker.close(); + }); + + it("drops activity on cancel, timeout and close", async () => { + vi.useFakeTimers(); + try { + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); + const { requests: dispatched, send } = captureRenderer(); + broker.registerRenderer(send); + + const timedOut = broker.request({ ...request, requestId: "timeout" }); + const timeoutDispatchId = dispatched[0]!.requestId; + expect(broker.isRequestActive(timeoutDispatchId)).toBe(true); + await vi.advanceTimersByTimeAsync(1_000); + expect(broker.isRequestActive(timeoutDispatchId)).toBe(false); + await timedOut; + + const canceled = broker.request({ ...request, requestId: "cancel" }); + const canceledDispatchId = dispatched[1]!.requestId; + expect(broker.isRequestActive(canceledDispatchId)).toBe(true); + broker.cancel("cancel"); + expect(broker.isRequestActive(canceledDispatchId)).toBe(false); + await canceled; + + const closed = broker.request({ ...request, requestId: "close" }); + const closedDispatchId = dispatched[2]!.requestId; + expect(broker.isRequestActive(closedDispatchId)).toBe(true); + broker.close(); + expect(broker.isRequestActive(closedDispatchId)).toBe(false); + await closed; + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/apps/desktop/src/app/DesktopAppActivationBroker.ts b/apps/desktop/src/app/DesktopAppActivationBroker.ts index 221df9ca86dd..a24ac81b17e8 100644 --- a/apps/desktop/src/app/DesktopAppActivationBroker.ts +++ b/apps/desktop/src/app/DesktopAppActivationBroker.ts @@ -1,4 +1,6 @@ // @effect-diagnostics globalTimers:off -- This protocol broker owns cancellable request deadlines outside the Effect runtime. +import * as NodeCrypto from "node:crypto"; + import { DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, type DesktopAppActivationFailure, @@ -10,7 +12,7 @@ interface PendingActivation { readonly request: DesktopAppActivationRequest; readonly resolve: (response: DesktopAppActivationResponse) => void; readonly timeout: ReturnType; - dispatched: boolean; + dispatchRequestId: string | null; } type RendererSender = (request: DesktopAppActivationRequest) => void; @@ -54,6 +56,40 @@ export class DesktopAppActivationBroker { ); } + // Bound: this private first version only targets an already running, + // renderer-ready T3 Code window. It never creates or reopens a closed + // window, so an open-thread request with no renderer fails immediately + // instead of queueing for a window that may never appear. Check this + // before superseding so a rejected request cannot disturb pending work. + if (request.type === "open-thread" && this.#renderer === null) { + return Promise.resolve( + failure( + request.requestId, + "renderer-unavailable", + "No running T3 Code window can open that conversation.", + ), + ); + } + + // A newer open-thread request replaces older pending open-thread requests: + // only the latest target matters, and an already-stale navigation must not + // run. Open-workspace requests keep their original ordering. Settle the + // whole batch without flushing so a superseded request is never dispatched + // from inside this loop; the single flush below dispatches the new head. + if (request.type === "open-thread") { + for (const pending of this.#pending.values()) { + if (pending.request.type === "open-thread") { + this.#settleWithoutFlush( + failure( + pending.request.requestId, + "request-superseded", + "A newer request replaced this one before it was handled.", + ), + ); + } + } + } + const response = new Promise((resolve) => { const timeout = setTimeout(() => { this.#settle( @@ -68,15 +104,27 @@ export class DesktopAppActivationBroker { request, resolve, timeout, - dispatched: false, + dispatchRequestId: null, }); }); - this.#activate(); + // Open-thread requests must not raise the previous conversation before the + // renderer confirms the new target, so they skip activation here and let + // complete() decide once the target is validated. Workspace behavior stays + // exactly as before: focus immediately, then queue until the renderer is + // ready. + if (request.type !== "open-thread") { + this.#activate(); + } this.#flush(); return response; } + /** True only while the request is pending and has already reached the renderer. */ + isRequestActive(dispatchRequestId: string): boolean { + return this.#findPendingDispatch(dispatchRequestId) !== undefined; + } + registerRenderer(send: RendererSender): void { this.#renderer = send; this.#flush(); @@ -85,7 +133,12 @@ export class DesktopAppActivationBroker { clearRenderer(): void { this.#renderer = null; for (const pending of this.#pending.values()) { - if (pending.dispatched) { + // A lost renderer invalidates every open-thread target, whether it was + // already dispatched or still queued, because a later reopened window + // must not navigate to a conversation the user asked for before it went + // away. Dispatched workspace requests fail for the same reason, while + // undispatched workspace requests keep queueing until a renderer returns. + if (pending.request.type === "open-thread" || pending.dispatchRequestId !== null) { this.#settle( failure( pending.request.requestId, @@ -98,7 +151,32 @@ export class DesktopAppActivationBroker { } complete(response: DesktopAppActivationResponse): void { - this.#settle(response); + const pending = this.#findPendingDispatch(response.requestId); + if (pending === undefined) return; + + if (response.ok && pending.request.type === "open-thread") { + // Raise the window only after the renderer confirms the exact target this + // broker dispatched. A success for another thread or environment must not + // focus the window, and late, cancelled, superseded or timed-out responses + // no longer have a pending request and never activate. + if ( + response.environmentId === pending.request.environmentId && + response.threadId === pending.request.threadId + ) { + this.#activate(); + this.#settle({ ...response, requestId: pending.request.requestId }); + return; + } + this.#settle( + failure( + pending.request.requestId, + "thread-open-failed", + "The desktop app did not open the requested conversation.", + ), + ); + return; + } + this.#settle({ ...response, requestId: pending.request.requestId }); } cancel(requestId: string): void { @@ -120,27 +198,45 @@ export class DesktopAppActivationBroker { #flush(): void { const renderer = this.#renderer; if (renderer === null) return; - if ([...this.#pending.values()].some((pending) => pending.dispatched)) return; + if ([...this.#pending.values()].some((pending) => pending.dispatchRequestId !== null)) return; for (const pending of this.#pending.values()) { - if (pending.dispatched) continue; + if (pending.dispatchRequestId !== null) continue; try { - pending.dispatched = true; - renderer(pending.request); + // This opaque per-dispatch value travels in renderer requestId; + // complete() restores the stored client requestId before resolving. + const dispatchRequestId = NodeCrypto.randomUUID(); + pending.dispatchRequestId = dispatchRequestId; + renderer({ ...pending.request, requestId: dispatchRequestId }); } catch { - pending.dispatched = false; - this.#renderer = null; + pending.dispatchRequestId = null; + // A renderer that throws on send is gone for this request: cancel + // open-thread work instead of requeueing it. clearRenderer drops the + // failed sender before it settles and flushes, so this cannot recurse. + // Undispatched workspace requests still requeue as before. + this.clearRenderer(); } return; } } + #findPendingDispatch(dispatchRequestId: string): PendingActivation | undefined { + for (const pending of this.#pending.values()) { + if (pending.dispatchRequestId === dispatchRequestId) return pending; + } + return undefined; + } + #settle(response: DesktopAppActivationResponse): void { + this.#settleWithoutFlush(response); + this.#flush(); + } + + #settleWithoutFlush(response: DesktopAppActivationResponse): void { const pending = this.#pending.get(response.requestId); if (!pending) return; clearTimeout(pending.timeout); this.#pending.delete(response.requestId); pending.resolve(response); - this.#flush(); } } diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index c97c602552f4..a6649ab98201 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -78,6 +78,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(AppActivationIpc.setReady); yield* ipc.handle(AppActivationIpc.complete); + yield* ipc.handle(AppActivationIpc.isRequestActive); yield* ipc.handleSync(getAppBranding); yield* ipc.handleSync(getSystemLocale); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 226793657848..6568a221230a 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -16,6 +16,8 @@ export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state" export const DESKTOP_APP_ACTIVATION_READY_CHANNEL = "desktop:app-activation-ready"; export const DESKTOP_APP_ACTIVATION_COMPLETE_CHANNEL = "desktop:app-activation-complete"; export const DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL = "desktop:app-activation-request"; +export const DESKTOP_APP_ACTIVATION_IS_REQUEST_ACTIVE_CHANNEL = + "desktop:app-activation-is-request-active"; export const UPDATE_STATE_CHANNEL = "desktop:update-state"; export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; diff --git a/apps/desktop/src/ipc/methods/appActivation.ts b/apps/desktop/src/ipc/methods/appActivation.ts index b5e659b235dc..3790d2a61965 100644 --- a/apps/desktop/src/ipc/methods/appActivation.ts +++ b/apps/desktop/src/ipc/methods/appActivation.ts @@ -25,3 +25,18 @@ export const complete = DesktopIpc.makeIpcMethod({ yield* activation.complete(response); }), }); + +/** + * Read-only liveness probe used by the renderer immediately before and after + * navigating to an existing thread. It answers from the broker's pending set + * and never itself activates a window. + */ +export const isRequestActive = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.DESKTOP_APP_ACTIVATION_IS_REQUEST_ACTIVE_CHANNEL, + payload: Schema.String, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.appActivation.isRequestActive")(function* (requestId) { + const activation = yield* DesktopAppActivation.DesktopAppActivation; + return activation.isRequestActive(requestId); + }), +}); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 453879d37afe..ff3925c1ffa0 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -247,6 +247,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.DESKTOP_APP_ACTIVATION_READY_CHANNEL, ready), complete: (response) => ipcRenderer.invoke(IpcChannels.DESKTOP_APP_ACTIVATION_COMPLETE_CHANNEL, response), + isRequestActive: (requestId) => + ipcRenderer.invoke(IpcChannels.DESKTOP_APP_ACTIVATION_IS_REQUEST_ACTIVE_CHANNEL, requestId), onRequest: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => { if (typeof request !== "object" || request === null) return; diff --git a/apps/server/src/cli/app.test.ts b/apps/server/src/cli/app.test.ts index 0dddca4b1bf0..d5a5a6823a89 100644 --- a/apps/server/src/cli/app.test.ts +++ b/apps/server/src/cli/app.test.ts @@ -48,6 +48,11 @@ const pathExists = (path: string) => ), ); +const isOpenWorkspaceRequest = ( + request: DesktopAppActivationRequest, +): request is Extract => + request.type === "open-workspace"; + async function startFakeDesktop(input: { readonly baseDir: string; readonly stateSubdirectory?: "userdata" | "dev"; @@ -198,7 +203,11 @@ describe("t3 app", () => { yield* runCli(["app"], { T3CODE_HOME: baseDir }); yield* runCli(["app", explicitPath, "--base-dir", baseDir]); - expect(desktop.received.map((request) => request.workspaceRoot)).toEqual([ + // open-thread shares the union, so assert the discriminator and narrow + // before reading workspace-only fields rather than casting the union. + expect(desktop.received.every((request) => request.type === "open-workspace")).toBe(true); + const workspaceRequests = desktop.received.filter(isOpenWorkspaceRequest); + expect(workspaceRequests.map((request) => request.workspaceRoot)).toEqual([ workingDirectory, explicitPath, ]); diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 2577866838b1..0f8aeec34217 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -1,4 +1,10 @@ +// @effect-diagnostics nodeBuiltinImport:off - computes the expected desktop control address from the same Node temp directory and path primitives the server uses. +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + import * as NodeServices from "@effect/platform-node/NodeServices"; +import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl"; +import { HostProcessPlatform, HostProcessUserId } from "@t3tools/shared/hostProcess"; import { expect, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; @@ -74,6 +80,29 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { } satisfies ServerConfig.ServerConfig["Service"]; }); +const describeWithDesktopHost = ( + serverConfig: ServerConfig.ServerConfig["Service"], + overrides: Partial, + host: { readonly platform: NodeJS.Platform; readonly userId: number | undefined }, +) => + Effect.gen(function* () { + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + return yield* serverEnvironment.getDescriptor; + }).pipe( + Effect.provide( + ServerEnvironment.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provide(ServerConfig.layer({ ...serverConfig, ...overrides })), + Layer.provide( + Layer.merge( + Layer.succeed(HostProcessPlatform, host.platform), + Layer.succeed(HostProcessUserId, host.userId), + ), + ), + ), + ), + ); + it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { it.effect.each([ { name: "missing", content: undefined }, @@ -262,6 +291,138 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { }), ); + it.effect("advertises the native desktop control address for a custom data directory", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-desktop-control-test-", + }); + // A trailing separator must resolve to the same address the desktop + // shell computes from its own state directory. + const serverConfig = yield* makeServerConfig(`${baseDir}${NodePath.sep}`); + yield* fileSystem.makeDirectory(serverConfig.stateDir, { recursive: true }); + + const platform = yield* HostProcessPlatform; + const userId = yield* HostProcessUserId; + const descriptor = yield* describeWithDesktopHost( + serverConfig, + { mode: "desktop", desktopTelemetryControlFd: 5 }, + { platform, userId }, + ); + const expected = resolveDesktopAppControlAddress({ + stateDir: NodePath.resolve(serverConfig.stateDir), + platform, + tempDir: NodeOS.tmpdir(), + userId, + joinPath: NodePath.join, + }); + + expect(descriptor.capabilities.desktopAppUpdate).toBe(true); + expect(descriptor.capabilities.desktopAppControl).toEqual({ + version: 1, + address: expected.address, + }); + }), + ); + + it.effect("advertises a Windows named-pipe desktop control address on a Windows host", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-desktop-control-win-test-", + }); + const serverConfig = yield* makeServerConfig(baseDir); + yield* fileSystem.makeDirectory(serverConfig.stateDir, { recursive: true }); + + const descriptor = yield* describeWithDesktopHost( + serverConfig, + { mode: "desktop", desktopTelemetryControlFd: 5 }, + { platform: "win32", userId: undefined }, + ); + const expected = resolveDesktopAppControlAddress({ + stateDir: NodePath.resolve(serverConfig.stateDir), + platform: "win32", + tempDir: NodeOS.tmpdir(), + userId: undefined, + joinPath: NodePath.join, + }); + + expect(expected.directory).toBeNull(); + expect(descriptor.capabilities.desktopAppControl?.address).toMatch( + /^\\\\\.\\pipe\\t3code-app-[0-9a-f]{24}$/, + ); + expect(descriptor.capabilities.desktopAppControl).toEqual({ + version: 1, + address: expected.address, + }); + }), + ); + + it.effect( + "omits desktopAppControl without the control fd, outside desktop mode, and on unsupported platforms", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-desktop-control-omit-test-", + }); + const serverConfig = yield* makeServerConfig(baseDir); + yield* fileSystem.makeDirectory(serverConfig.stateDir, { recursive: true }); + const host = { platform: "linux" as NodeJS.Platform, userId: 1000 }; + + const withoutFd = yield* describeWithDesktopHost(serverConfig, { mode: "desktop" }, host); + expect(withoutFd.capabilities.desktopAppControl).toBeUndefined(); + + const web = yield* describeWithDesktopHost( + serverConfig, + { mode: "web", desktopTelemetryControlFd: 5 }, + host, + ); + expect(web.capabilities.desktopAppControl).toBeUndefined(); + + const unsupported = yield* describeWithDesktopHost( + serverConfig, + { mode: "desktop", desktopTelemetryControlFd: 5 }, + { platform: "aix", userId: 1000 }, + ); + expect(unsupported.capabilities.desktopAppControl).toBeUndefined(); + }), + ); + + it.effect("gives different desktop control addresses for different data directories", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const firstBaseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-desktop-control-first-", + }); + const secondBaseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-desktop-control-second-", + }); + const firstConfig = yield* makeServerConfig(firstBaseDir); + const secondConfig = yield* makeServerConfig(secondBaseDir); + yield* fileSystem.makeDirectory(firstConfig.stateDir, { recursive: true }); + yield* fileSystem.makeDirectory(secondConfig.stateDir, { recursive: true }); + const host = { platform: "linux" as NodeJS.Platform, userId: 1000 }; + + const first = yield* describeWithDesktopHost( + firstConfig, + { mode: "desktop", desktopTelemetryControlFd: 5 }, + host, + ); + const second = yield* describeWithDesktopHost( + secondConfig, + { mode: "desktop", desktopTelemetryControlFd: 5 }, + host, + ); + + expect(first.capabilities.desktopAppControl?.address).toBeDefined(); + expect(second.capabilities.desktopAppControl?.address).toBeDefined(); + expect(first.capabilities.desktopAppControl?.address).not.toBe( + second.capabilities.desktopAppControl?.address, + ); + }), + ); + it.effect("structures persisted environment id filesystem failures", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 9c25767245df..3675f6744576 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -1,9 +1,17 @@ +// @effect-diagnostics nodeBuiltinImport:off - the desktop control address is defined in terms of Node's temp directory and platform. +import * as NodeOS from "node:os"; + import { EnvironmentId, PROVIDER_SEND_TURN_MAX_FILE_BYTES, type ExecutionEnvironmentDescriptor, } from "@t3tools/contracts"; -import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl"; +import { + HostProcessArchitecture, + HostProcessPlatform, + HostProcessUserId, +} from "@t3tools/shared/hostProcess"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -187,6 +195,7 @@ export const make = Effect.gen(function* () { const identity = yield* ServerEnvironmentIdentity; const hostPlatform = yield* HostProcessPlatform; const hostArchitecture = yield* HostProcessArchitecture; + const userId = yield* HostProcessUserId; const environmentId = yield* identity.getEnvironmentId; const cwdBaseName = path.basename(serverConfig.cwd).trim(); const label = yield* resolveServerEnvironmentLabel({ cwdBaseName }); @@ -202,12 +211,30 @@ export const make = Effect.gen(function* () { // the fd and correctly do not advertise. const desktopAppUpdate = serverSelfUpdate === "desktop-managed" && serverConfig.desktopTelemetryControlFd !== undefined; + // The address is a hint the client still has to probe and validate, and it + // is only meaningful where a native desktop shell exists. Reusing + // desktopAppUpdate's gate keeps WSL and headless servers -- which never + // receive the control fd -- from advertising it. + const platformOsValue = platformOs(hostPlatform); + const desktopAppControl = + desktopAppUpdate && platformOsValue !== "unknown" + ? { + version: 1 as const, + address: resolveDesktopAppControlAddress({ + stateDir: path.resolve(serverConfig.stateDir), + platform: hostPlatform, + tempDir: NodeOS.tmpdir(), + userId, + joinPath: path.join, + }).address, + } + : undefined; const descriptor: ExecutionEnvironmentDescriptor = { environmentId, label, platform: { - os: platformOs(hostPlatform), + os: platformOsValue, arch: platformArch(hostArchitecture), ...(machine === null ? {} : { machine }), }, @@ -237,6 +264,7 @@ export const make = Effect.gen(function* () { threadPullRequestLinking: true, environmentIcon: true, projectCloneTracking: true, + providerIntegrationContext: 1, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" || desktopAppUpdate ? { @@ -245,6 +273,7 @@ export const make = Effect.gen(function* () { } : {}), ...(desktopAppUpdate ? { desktopAppUpdate: true } : {}), + ...(desktopAppControl === undefined ? {} : { desktopAppControl }), }, }; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 38bdb7f1f2e7..ec41b677e1ee 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -50,6 +50,7 @@ import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../ import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; import type { ClaudeScopedLimitNames } from "./claudeUsageLimits.ts"; import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; +import { T3CODE_INTEGRATION_CONTEXT } from "../providerIntegrationContext.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -2808,6 +2809,41 @@ describe("ClaudeAdapterLive", () => { }, ); + it.effect( + "stamps the conversation integration context into the spawned query environment", + () => { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-claude-ctx-")); + const environmentIdPath = NodePath.join(baseDir, "userdata", "environment-id"); + NodeFS.mkdirSync(NodePath.dirname(environmentIdPath), { recursive: true }); + NodeFS.writeFileSync(environmentIdPath, "environment-claude\n"); + const harness = makeHarness({ cwd: "/tmp/synthetic-claude-ctx", baseDir }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + cwd: "/tmp/synthetic-claude-ctx", + }); + const actualQuery = harness.getLastCreateQueryInput(); + assert(actualQuery !== undefined); + const raw = actualQuery.options.env?.[T3CODE_INTEGRATION_CONTEXT]; + assert.equal(typeof raw, "string"); + // @effect-diagnostics-next-line preferSchemaOverJson:off - inspect the subprocess wire value independently of its producer. + assert.deepEqual(JSON.parse(raw as string), { + version: 1, + kind: "conversation", + environmentId: "environment-claude", + threadId: "thread-claude-1", + providerInstanceId: "claudeAgent", + }); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }, + ); + it.effect("fails a turn for every dead-turn terminal_reason", () => { const reasons = [ "blocking_limit", diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 3afde2b39a7a..d005ff757c23 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -88,6 +88,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { withProviderIntegrationContext } from "../providerIntegrationContext.ts"; import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { claudeSignedOutMessage, makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { planClaudeSkillDispatch } from "../Drivers/ClaudeSkillDispatch.ts"; @@ -4717,6 +4718,19 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(input.cwd ? [input.cwd] : []), serverConfig.attachmentsDir, ]; + const deviceEnvironment = McpProviderSession.withAgentDeviceEnvironment( + claudeEnvironment, + mcpSession, + ); + const queryEnvironment = + (yield* withProviderIntegrationContext(deviceEnvironment, { + kind: "conversation", + threadId, + providerInstanceId: boundInstanceId, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(ServerConfig, serverConfig), + )) ?? deviceEnvironment; const queryOptions: ClaudeQueryOptions = { ...(input.cwd ? { cwd: input.cwd } : {}), ...(apiModelId ? { model: apiModelId } : {}), @@ -4746,7 +4760,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( canUseTool, onUserDialog, supportedDialogKinds: ["resume_return"], - env: McpProviderSession.withAgentDeviceEnvironment(claudeEnvironment, mcpSession), + env: queryEnvironment, additionalDirectories, ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), ...(mcpSession diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index f7c6036885d9..80ffa25f349c 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -47,6 +47,7 @@ import { type CodexThreadSnapshot, } from "./CodexSessionRuntime.ts"; import { makeCodexAdapter } from "./CodexAdapter.ts"; +import { T3CODE_INTEGRATION_CONTEXT } from "../providerIntegrationContext.ts"; const decodeCodexSettings = Schema.decodeSync(CodexSettings); // Test-local service tag so the rest of the file can keep using `yield* CodexAdapter`. @@ -300,6 +301,66 @@ validationLayer("CodexAdapterLive validation", (it) => { ); }); +const integrationContextRuntimeFactory = makeRuntimeFactory(); +const integrationDriverEnvironment: NodeJS.ProcessEnv = { + PATH: process.env.PATH, + T3CODE_INTEGRATION_CONTEXT: JSON.stringify({ stale: true }), +}; +const integrationDriverEnvironmentSnapshot = { ...integrationDriverEnvironment }; +const integrationContextLayer = it.layer( + Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = decodeCodexSettings({}); + return yield* makeCodexAdapter(codexConfig, { + instanceId: ProviderInstanceId.make("codex_personal"), + environment: integrationDriverEnvironment, + makeRuntime: integrationContextRuntimeFactory.factory, + }); + }), + ).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "t3code-codex-integration-context-" }), + ), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ), +); + +integrationContextLayer("CodexAdapterLive integration context", (it) => { + it.effect("stamps the conversation context for the bound instance", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + NodeFS.mkdirSync(NodePath.dirname(serverConfig.environmentIdPath), { recursive: true }); + NodeFS.writeFileSync(serverConfig.environmentIdPath, "environment-codex\n"); + const adapter = yield* CodexAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("sess-context"), + runtimeMode: "full-access", + }); + const runtime = integrationContextRuntimeFactory.lastRuntime; + NodeAssert.ok(runtime); + const raw = runtime.options.environment?.[T3CODE_INTEGRATION_CONTEXT]; + NodeAssert.equal(typeof raw, "string"); + // @effect-diagnostics-next-line preferSchemaOverJson:off - inspect the subprocess wire value independently of its producer. + NodeAssert.deepStrictEqual(JSON.parse(raw as string), { + version: 1, + kind: "conversation", + environmentId: "environment-codex", + threadId: "sess-context", + providerInstanceId: "codex_personal", + }); + // The provider driver environment passed to the adapter is never mutated. + NodeAssert.deepStrictEqual( + integrationDriverEnvironment, + integrationDriverEnvironmentSnapshot, + ); + }), + ); +}); + 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 b43755736ca3..f6f8754a621d 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -48,6 +48,7 @@ import * as EffectCodexSchema from "effect-codex-app-server/schema"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { withProviderIntegrationContext } from "../providerIntegrationContext.ts"; import { ProviderAdapterRequestError, @@ -2303,7 +2304,22 @@ 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 conversationEnvironment = yield* withProviderIntegrationContext( + runtimeInput.environment, + { + kind: "conversation", + threadId: input.threadId, + providerInstanceId: boundInstanceId, + }, + ).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(ServerConfig, serverConfig), + ); + const runtime = yield* createRuntime( + conversationEnvironment === undefined + ? runtimeInput + : { ...runtimeInput, environment: conversationEnvironment }, + ).pipe( Effect.provideService(Scope.Scope, sessionScope), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), Effect.provideService(Crypto.Crypto, crypto), diff --git a/apps/server/src/provider/providerIntegrationContext.test.ts b/apps/server/src/provider/providerIntegrationContext.test.ts new file mode 100644 index 000000000000..c2596d899689 --- /dev/null +++ b/apps/server/src/provider/providerIntegrationContext.test.ts @@ -0,0 +1,194 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import { expect } from "vite-plus/test"; + +import * as ServerConfig from "../config.ts"; +import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; +import { + T3CODE_INTEGRATION_CONTEXT, + withProviderIntegrationContext, +} from "./providerIntegrationContext.ts"; + +const TEST_THREAD_ID = ThreadId.make("thread-context-1"); +const TEST_INSTANCE_ID = ProviderInstanceId.make("codex_personal"); + +const testLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-provider-integration-context-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +const readContext = (environment: NodeJS.ProcessEnv | undefined): unknown => { + const raw = environment?.[T3CODE_INTEGRATION_CONTEXT]; + expect(raw).toBeTypeOf("string"); + return JSON.parse(raw as string); +}; + +it("leaves the provider driver environment free of the integration marker", () => { + const base = { PATH: "/bin", CODEX_HOME: "/home/.codex" }; + const merged = mergeProviderInstanceEnvironment(undefined, base); + expect(T3CODE_INTEGRATION_CONTEXT in merged).toBe(false); + expect( + Object.keys( + mergeProviderInstanceEnvironment( + [{ name: "CUSTOM_VALUE", value: "1", sensitive: false }], + base, + ), + ).sort(), + ).toEqual(["CODEX_HOME", "CUSTOM_VALUE", "PATH"]); +}); + +it.layer(testLayer)("providerIntegrationContext", (it) => { + it.effect("emits only the whitelisted conversation context", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString(serverConfig.environmentIdPath, "environment-abc\n"); + + const base = { + PATH: "/usr/bin", + SECRET_TOKEN: "super-secret-value", + CWD_HINT: "/tmp/must-not-leak", + T3CODE_INTEGRATION_CONTEXT: '{"inherited":true}', + }; + const baseSnapshot = { ...base }; + + const environment = yield* withProviderIntegrationContext(base, { + kind: "conversation", + threadId: TEST_THREAD_ID, + providerInstanceId: TEST_INSTANCE_ID, + }); + + expect(environment).not.toBe(base); + const context = readContext(environment); + expect(context).toEqual({ + version: 1, + kind: "conversation", + environmentId: "environment-abc", + threadId: "thread-context-1", + providerInstanceId: "codex_personal", + }); + expect(Object.keys(context as Record).sort()).toEqual([ + "environmentId", + "kind", + "providerInstanceId", + "threadId", + "version", + ]); + const serialized = environment?.[T3CODE_INTEGRATION_CONTEXT] ?? ""; + expect(serialized).not.toContain("super-secret-value"); + expect(serialized).not.toContain("/tmp/must-not-leak"); + + // The caller's environment is never mutated; only the copy gains the marker. + expect(base).toEqual(baseSnapshot); + }), + ); + + it.effect("fails open without the reserved key when the identity is missing or empty", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.remove(serverConfig.environmentIdPath).pipe(Effect.ignore); + + const withInherited = { PATH: "/bin", T3CODE_INTEGRATION_CONTEXT: '{"inherited":true}' }; + const missing = yield* withProviderIntegrationContext(withInherited, { + kind: "auxiliary", + }); + expect(missing?.[T3CODE_INTEGRATION_CONTEXT]).toBeUndefined(); + expect(missing?.PATH).toBe("/bin"); + expect(withInherited[T3CODE_INTEGRATION_CONTEXT]).toBe('{"inherited":true}'); + + expect( + yield* withProviderIntegrationContext(undefined, { + kind: "conversation", + threadId: TEST_THREAD_ID, + providerInstanceId: TEST_INSTANCE_ID, + }), + ).toBeUndefined(); + + yield* fileSystem.writeFileString(serverConfig.environmentIdPath, " \n"); + const empty = yield* withProviderIntegrationContext(withInherited, { kind: "auxiliary" }); + expect(empty?.[T3CODE_INTEGRATION_CONTEXT]).toBeUndefined(); + }), + ); + + it.effect("emits an auxiliary context without conversation ids", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString(serverConfig.environmentIdPath, "environment-xyz\n"); + + const environment = yield* withProviderIntegrationContext( + { PATH: "/bin", SECRET_TOKEN: "another-secret" }, + { kind: "auxiliary" }, + ); + + expect(readContext(environment)).toEqual({ + version: 1, + kind: "auxiliary", + environmentId: "environment-xyz", + }); + }), + ); + + it.effect( + "strips a parent-process marker when identity is unavailable and no base env is supplied", + () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.remove(serverConfig.environmentIdPath).pipe(Effect.ignore); + const previous = process.env[T3CODE_INTEGRATION_CONTEXT]; + process.env[T3CODE_INTEGRATION_CONTEXT] = '{"kind":"conversation","threadId":"parent"}'; + try { + const environment = yield* withProviderIntegrationContext(undefined, { + kind: "auxiliary", + }); + expect(environment).toBeDefined(); + expect(environment?.[T3CODE_INTEGRATION_CONTEXT]).toBeUndefined(); + expect(process.env[T3CODE_INTEGRATION_CONTEXT]).toContain("parent"); + } finally { + if (previous === undefined) delete process.env[T3CODE_INTEGRATION_CONTEXT]; + else process.env[T3CODE_INTEGRATION_CONTEXT] = previous; + } + }), + ); + + it.effect("keeps two conversations in the same cwd distinct by thread id", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString(serverConfig.environmentIdPath, "environment-shared\n"); + + const first = yield* withProviderIntegrationContext( + { PATH: "/bin" }, + { + kind: "conversation", + threadId: ThreadId.make("thread-a"), + providerInstanceId: TEST_INSTANCE_ID, + }, + ); + const second = yield* withProviderIntegrationContext( + { PATH: "/bin" }, + { + kind: "conversation", + threadId: ThreadId.make("thread-b"), + providerInstanceId: TEST_INSTANCE_ID, + }, + ); + + expect(readContext(first)).toMatchObject({ + environmentId: "environment-shared", + threadId: "thread-a", + providerInstanceId: "codex_personal", + }); + expect(readContext(second)).toMatchObject({ + environmentId: "environment-shared", + threadId: "thread-b", + providerInstanceId: "codex_personal", + }); + }), + ); +}); diff --git a/apps/server/src/provider/providerIntegrationContext.ts b/apps/server/src/provider/providerIntegrationContext.ts new file mode 100644 index 000000000000..900e563b2429 --- /dev/null +++ b/apps/server/src/provider/providerIntegrationContext.ts @@ -0,0 +1,95 @@ +/** + * Provider integration context — the ownership marker T3 Code stamps into the + * environment of the subprocesses it starts for a conversation. + * + * Provider hooks (for example Codex `PostToolUse`) need to tell whether an + * event belongs to a conversation T3 already manages, so they can hand the + * event back to T3 instead of letting it materialize as a second, ghost + * conversation card. The marker is descriptive only: it carries no cwd, + * tokens, endpoints, native provider IDs, or prompts. Conversation card + * identity is the environment ID plus the T3 thread ID; the configured + * provider instance slug is included for routing diagnostics, not identity. + * + * The value is derived at the invocation boundary from the server's persisted + * `environment-id` file. When that identity cannot be read, the reserved key + * is omitted entirely, so a subprocess never claims guessed ownership. + * + * @module providerIntegrationContext + */ +import { EnvironmentId, type ProviderInstanceId, type ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; + +import * as ServerConfig from "../config.ts"; + +export const T3CODE_INTEGRATION_CONTEXT = "T3CODE_INTEGRATION_CONTEXT"; + +export type ProviderIntegrationContextRequest = + | { + readonly kind: "conversation"; + readonly threadId: ThreadId; + readonly providerInstanceId: ProviderInstanceId; + } + | { readonly kind: "auxiliary" }; + +const readEnvironmentId = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const serverConfig = yield* ServerConfig.ServerConfig; + return yield* fileSystem + .readFileString(serverConfig.environmentIdPath) + .pipe(Effect.orElseSucceed(() => "")) + .pipe(Effect.map((value) => value.trim())); +}); + +/** + * Return a copy of `baseEnvironment` with the derived T3 integration context. + * + * `undefined` is returned only when the caller passed no base environment, + * no reserved marker is inherited, and the server identity is unavailable, + * which preserves the original "inherit + * the parent environment" behavior for the Codex app-server. Otherwise a copy + * is always returned: the caller's object and `process.env` are never mutated. + * Any inherited `T3CODE_INTEGRATION_CONTEXT` is stripped before the freshly + * derived marker is added, and a missing/empty/unreadable identity file simply + * yields the stripped environment without the reserved key. + */ +export const withProviderIntegrationContext = Effect.fn("withProviderIntegrationContext")( + function* ( + baseEnvironment: NodeJS.ProcessEnv | undefined, + request: ProviderIntegrationContextRequest, + ): Effect.fn.Return< + NodeJS.ProcessEnv | undefined, + never, + FileSystem.FileSystem | ServerConfig.ServerConfig + > { + const environmentIdRaw = yield* readEnvironmentId; + if ( + baseEnvironment === undefined && + environmentIdRaw.length === 0 && + process.env[T3CODE_INTEGRATION_CONTEXT] === undefined + ) { + return undefined; + } + + const environment: NodeJS.ProcessEnv = { ...(baseEnvironment ?? process.env) }; + delete environment[T3CODE_INTEGRATION_CONTEXT]; + if (environmentIdRaw.length === 0) { + return environment; + } + + const environmentId = EnvironmentId.make(environmentIdRaw); + const context = + request.kind === "conversation" + ? { + version: 1, + kind: "conversation", + environmentId, + threadId: request.threadId, + providerInstanceId: request.providerInstanceId, + } + : { version: 1, kind: "auxiliary", environmentId }; + // @effect-diagnostics-next-line preferSchemaOverJson:off - serialize the locally constructed, primitive-only environment marker. + environment[T3CODE_INTEGRATION_CONTEXT] = JSON.stringify(context); + return environment; + }, +); diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index 8fe5152d3450..fc4c6f34aa4c 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -105,6 +105,11 @@ function makeFakeClaudeBinary(dir: string) { ' fail("CLAUDE_CONFIG_DIR was " + (process.env.CLAUDE_CONFIG_DIR ?? ""), 5);', "}", "", + "const contextMustBe = process.env.T3_FAKE_CLAUDE_CONTEXT_MUST_BE;", + "if (contextMustBe && process.env.T3CODE_INTEGRATION_CONTEXT !== contextMustBe) {", + ' fail("integration context mismatch: " + (process.env.T3CODE_INTEGRATION_CONTEXT ?? "unset"), 13);', + "}", + "", "const stderrText = process.env.T3_FAKE_CLAUDE_STDERR;", "if (stderrText) {", ' process.stderr.write(stderrText + "\\n");', @@ -128,6 +133,7 @@ function withFakeClaudeEnv( argsMustNotContain?: string; stdinMustContain?: string; configDirMustBe?: string; + contextMustBe?: string; cwdMustNotBe?: string; claudeConfig?: Partial; }, @@ -146,6 +152,7 @@ function withFakeClaudeEnv( const previousArgsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; const previousStdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; const previousConfigDirMustBe = process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE; + const previousContextMustBe = process.env.T3_FAKE_CLAUDE_CONTEXT_MUST_BE; const previousCwdMustNotBe = process.env.T3_FAKE_CLAUDE_CWD_MUST_NOT_BE; yield* Effect.acquireRelease( @@ -194,6 +201,12 @@ function withFakeClaudeEnv( } else { delete process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE; } + + if (input.contextMustBe !== undefined) { + process.env.T3_FAKE_CLAUDE_CONTEXT_MUST_BE = input.contextMustBe; + } else { + delete process.env.T3_FAKE_CLAUDE_CONTEXT_MUST_BE; + } }), () => Effect.sync(() => { @@ -246,6 +259,12 @@ function withFakeClaudeEnv( } else { process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE = previousConfigDirMustBe; } + + if (previousContextMustBe === undefined) { + delete process.env.T3_FAKE_CLAUDE_CONTEXT_MUST_BE; + } else { + process.env.T3_FAKE_CLAUDE_CONTEXT_MUST_BE = previousContextMustBe; + } }), ); @@ -296,6 +315,37 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { ), ); + it.effect("marks auxiliary claude subprocesses with the auxiliary integration context", () => + withFakeClaudeEnv( + { + output: JSON.stringify({ structured_output: { title: "Auxiliary title" } }), + contextMustBe: JSON.stringify({ + version: 1, + kind: "auxiliary", + environmentId: "environment-claude-aux", + }), + }, + (textGeneration) => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString( + serverConfig.environmentIdPath, + "environment-claude-aux\n", + ); + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Describe this change", + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + SYNTHETIC_CLAUDE_STANDARD_MODEL, + ), + }); + expect(generated.title).toBe("Auxiliary title"); + }), + ), + ); + it.effect("keeps a configured custom alias opaque to the Claude CLI", () => withFakeClaudeEnv( { diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 357ecd686e46..04e7ee12613f 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -19,6 +19,8 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { TextGenerationError } from "@t3tools/contracts"; +import * as ServerConfig from "../config.ts"; +import { withProviderIntegrationContext } from "../provider/providerIntegrationContext.ts"; import * as TextGeneration from "./TextGeneration.ts"; import { buildBranchNamePrompt, @@ -77,6 +79,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ) { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; + const serverConfig = yield* Effect.service(ServerConfig.ServerConfig); const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); const scopedModelCatalog = modelCatalog.pipe( Effect.map((catalog) => scopeClaudeModelCatalog(catalog, claudeSettings.customModels)), @@ -196,6 +199,13 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ), ) : cwd; + const spawnEnvironment = + (yield* withProviderIntegrationContext(claudeEnvironment, { + kind: "auxiliary", + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(ServerConfig.ServerConfig, serverConfig), + )) ?? claudeEnvironment; const spawnCommand = yield* resolveSpawnCommand( claudeSettings.binaryPath || "claude", [ @@ -217,10 +227,10 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu "--permission-mode", "dontAsk", ], - { env: claudeEnvironment }, + { env: spawnEnvironment }, ); const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { - env: claudeEnvironment, + env: spawnEnvironment, cwd: workingDirectory, shell: spawnCommand.shell, stdin: { diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 91c9cb94b5d5..493b2157d84c 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -36,6 +36,7 @@ interface FakeCodexInput { forbidReasoningEffort?: boolean; requireArg?: string; forbidArg?: string; + requireIntegrationContext?: string; stdinMustContain?: string; stdinMustNotContain?: string; } @@ -52,6 +53,7 @@ function makeFakeCodexBinary(dir: string, input: FakeCodexInput) { forbidReasoningEffort: input.forbidReasoningEffort ?? false, requireArg: input.requireArg ?? null, forbidArg: input.forbidArg ?? null, + requireIntegrationContext: input.requireIntegrationContext ?? null, stdinMustContain: input.stdinMustContain ?? null, stdinMustNotContain: input.stdinMustNotContain ?? null, stderr: input.stderr ?? null, @@ -99,6 +101,12 @@ function makeFakeCodexBinary(dir: string, input: FakeCodexInput) { "if (check.forbidArg !== null && originalArgs.includes(` ${check.forbidArg} `)) {", ' fail("forbidden arg: " + check.forbidArg, 9);', "}", + "if (", + " check.requireIntegrationContext !== null &&", + " process.env.T3CODE_INTEGRATION_CONTEXT !== check.requireIntegrationContext", + ") {", + ' fail("integration context mismatch", 10);', + "}", 'if (check.requireImage && !seenImage) fail("missing --image input", 2);', "if (", " check.requireServiceTier !== null &&", @@ -181,6 +189,33 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { ), ); } + it.effect("marks auxiliary codex exec subprocesses with the auxiliary integration context", () => + withFakeCodexEnv( + { + output: JSON.stringify({ title: "Auxiliary title" }), + requireIntegrationContext: JSON.stringify({ + version: 1, + kind: "auxiliary", + environmentId: "environment-codex-aux", + }), + }, + (textGeneration) => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString( + serverConfig.environmentIdPath, + "environment-codex-aux\n", + ); + const result = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Describe this change", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + expect(result.title).toBe("Auxiliary title"); + }), + ), + ); it.effect("generates and sanitizes commit messages without branch by default", () => withFakeCodexEnv( { diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 4c9ac59d8422..3e0272c963c3 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -21,6 +21,7 @@ import { resolveAttachmentPath } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; import { expandHomePath } from "../pathExpansion.ts"; import { codexExecLaunchArgs, resolveCodexLaunchArgs } from "../provider/Layers/codexLaunchArgs.ts"; +import { withProviderIntegrationContext } from "../provider/providerIntegrationContext.ts"; import * as TextGeneration from "./TextGeneration.ts"; import { buildBranchNamePrompt, @@ -193,6 +194,13 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? DEFAULT_TEXT_GENERATION_REASONING_EFFORT; const serviceTier = getCodexServiceTierOptionValue(modelSelection); + const spawnEnvironment = + (yield* withProviderIntegrationContext(resolvedEnvironment, { + kind: "auxiliary", + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(ServerConfig.ServerConfig, serverConfig), + )) ?? resolvedEnvironment; const spawnCommand = yield* resolveSpawnCommand( codexConfig.binaryPath || "codex", [ @@ -214,11 +222,11 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func ...imagePaths.flatMap((imagePath) => ["--image", imagePath]), "-", ], - { env: resolvedEnvironment }, + { env: spawnEnvironment }, ); const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { env: { - ...resolvedEnvironment, + ...spawnEnvironment, ...(codexConfig.homePath ? { CODEX_HOME: expandHomePath(codexConfig.homePath) } : {}), }, cwd, diff --git a/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx b/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx index 3e941e69dd53..3fd0c71f9f63 100644 --- a/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx +++ b/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx @@ -1,22 +1,25 @@ import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { DesktopAppActivationRequest } from "@t3tools/contracts"; +import { useRouter } from "@tanstack/react-router"; import { useEffect, useEffectEvent, useRef } from "react"; import { handleDesktopAppActivationRequest } from "../../desktopAppActivation"; import { useNewThreadHandler } from "../../hooks/useHandleNewThread"; import { findProjectByPath, inferProjectTitleFromPath } from "../../lib/projectPaths"; import { newProjectId } from "../../lib/utils"; -import { readProjects, waitForProject } from "../../state/entities"; +import { readProjects, readThreadShell, waitForProject } from "../../state/entities"; import { usePrimaryEnvironment } from "../../state/environments"; import { projectEnvironment } from "../../state/projects"; import { useEnvironmentQuery } from "../../state/query"; import { environmentShell } from "../../state/shell"; import { useAtomCommand } from "../../state/use-atom-command"; +import { buildThreadRouteParams } from "../../threadRoutes"; export function DesktopAppActivationCoordinator() { const primaryEnvironment = usePrimaryEnvironment(); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); const openThread = useNewThreadHandler(); + const router = useRouter(); const queueRef = useRef(Promise.resolve()); const activation = window.desktopBridge?.appActivation; const shell = useEnvironmentQuery( @@ -71,6 +74,33 @@ export function DesktopAppActivationCoordinator() { await waitForProject(projectRef); }, openThread: (projectRef) => openThread(projectRef), + readThreadShell: (ref) => { + const shell = readThreadShell(ref); + if (shell === null) return null; + return { + environmentId: shell.environmentId, + threadId: shell.id, + projectId: shell.projectId, + archivedAt: shell.archivedAt, + }; + }, + navigateToThread: async (ref) => { + await router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(ref), + }); + }, + isThreadRouteActive: (ref) => + router.buildLocation({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(ref), + }).pathname === router.state.location.pathname, + // Optional on purpose: an older desktop shell lacks the probe, so + // open-thread fails safely instead of assuming the request is live. + isRequestActive: + activation?.isRequestActive === undefined + ? undefined + : (requestId: string) => activation.isRequestActive!(requestId), }), ); diff --git a/apps/web/src/desktopAppActivation.test.ts b/apps/web/src/desktopAppActivation.test.ts index e362391ed682..bf7ac1cc2281 100644 --- a/apps/web/src/desktopAppActivation.test.ts +++ b/apps/web/src/desktopAppActivation.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { handleDesktopAppActivationRequest, type DesktopAppActivationDependencies, + type DesktopAppActivationThread, } from "./desktopAppActivation"; const environmentId = EnvironmentId.make("primary"); @@ -17,6 +18,26 @@ const request = { workspaceRoot: "/workspace/project", platform: "linux", } as const; +const threadRequest = { + version: 1, + requestId: "request-thread", + type: "open-thread", + platform: "linux", + environmentId, + threadId, +} as const; + +function openableThread( + overrides: Partial = {}, +): DesktopAppActivationThread { + return { + environmentId, + threadId, + projectId: existingProjectId, + archivedAt: null, + ...overrides, + }; +} function dependencies( overrides: Partial = {}, @@ -31,6 +52,10 @@ function dependencies( createProject: vi.fn(async () => createdProjectId), waitForProject: vi.fn(async () => undefined), openThread: vi.fn(async () => ({ threadId })), + readThreadShell: vi.fn(() => openableThread()), + navigateToThread: vi.fn(async () => undefined), + isThreadRouteActive: vi.fn(() => true), + isRequestActive: vi.fn(async () => true), ...overrides, }; } @@ -104,4 +129,219 @@ describe("desktop app activation", () => { }); expect(openThread).not.toHaveBeenCalled(); }); + + it("opens an existing thread and echoes the exact environment, thread and project", async () => { + const deps = dependencies(); + + const response = await handleDesktopAppActivationRequest(threadRequest, deps); + + expect(deps.createProject).not.toHaveBeenCalled(); + expect(deps.openThread).not.toHaveBeenCalled(); + expect(deps.navigateToThread).toHaveBeenCalledWith({ environmentId, threadId }); + expect(response).toEqual({ + version: 1, + requestId: threadRequest.requestId, + ok: true, + environmentId, + projectId: existingProjectId, + threadId, + }); + }); + + it("rejects a request for a different environment without substituting the primary", async () => { + const navigateToThread = vi.fn(async () => undefined); + const response = await handleDesktopAppActivationRequest( + { ...threadRequest, environmentId: EnvironmentId.make("other") }, + dependencies({ navigateToThread }), + ); + + expect(response).toMatchObject({ ok: false, code: "environment-unavailable" }); + expect(navigateToThread).not.toHaveBeenCalled(); + }); + + it("rejects an open-thread request whose platform does not match the primary environment", async () => { + const navigateToThread = vi.fn(async () => undefined); + const response = await handleDesktopAppActivationRequest( + { ...threadRequest, platform: "win32" }, + dependencies({ navigateToThread }), + ); + + expect(response).toMatchObject({ ok: false, code: "platform-mismatch" }); + expect(navigateToThread).not.toHaveBeenCalled(); + }); + + it("returns thread-not-found for a missing thread", async () => { + const navigateToThread = vi.fn(async () => undefined); + const response = await handleDesktopAppActivationRequest( + threadRequest, + dependencies({ readThreadShell: () => null, navigateToThread }), + ); + + expect(response).toMatchObject({ ok: false, code: "thread-not-found" }); + expect(navigateToThread).not.toHaveBeenCalled(); + }); + + it("returns thread-not-found for an archived thread", async () => { + const navigateToThread = vi.fn(async () => undefined); + const response = await handleDesktopAppActivationRequest( + threadRequest, + dependencies({ + readThreadShell: () => openableThread({ archivedAt: "2024-01-01T00:00:00.000Z" }), + navigateToThread, + }), + ); + + expect(response).toMatchObject({ ok: false, code: "thread-not-found" }); + expect(navigateToThread).not.toHaveBeenCalled(); + }); + + it("returns thread-not-found when the resolved shell is not the requested thread", async () => { + const navigateToThread = vi.fn(async () => undefined); + const response = await handleDesktopAppActivationRequest( + threadRequest, + dependencies({ + readThreadShell: () => openableThread({ threadId: ThreadId.make("thread-other") }), + navigateToThread, + }), + ); + + expect(response).toMatchObject({ ok: false, code: "thread-not-found" }); + expect(navigateToThread).not.toHaveBeenCalled(); + }); + + it("fails safely when the desktop shell cannot report request activity", async () => { + const navigateToThread = vi.fn(async () => undefined); + const response = await handleDesktopAppActivationRequest( + threadRequest, + dependencies({ isRequestActive: undefined, navigateToThread }), + ); + + expect(response).toMatchObject({ ok: false, code: "renderer-unavailable" }); + expect(navigateToThread).not.toHaveBeenCalled(); + }); + + it("fails safely when the desktop shell cannot read or verify threads", async () => { + const navigateToThread = vi.fn(async () => undefined); + const response = await handleDesktopAppActivationRequest( + threadRequest, + dependencies({ readThreadShell: undefined, navigateToThread }), + ); + + expect(response).toMatchObject({ ok: false, code: "renderer-unavailable" }); + expect(navigateToThread).not.toHaveBeenCalled(); + }); + + it("does not navigate when the request is no longer active", async () => { + const navigateToThread = vi.fn(async () => undefined); + const response = await handleDesktopAppActivationRequest( + threadRequest, + dependencies({ isRequestActive: vi.fn(async () => false), navigateToThread }), + ); + + expect(response).toMatchObject({ ok: false, code: "request-superseded" }); + expect(navigateToThread).not.toHaveBeenCalled(); + }); + + it("does not acknowledge a request that stops being active during navigation", async () => { + const navigateToThread = vi.fn(async () => undefined); + let checks = 0; + const isRequestActive = vi.fn(async () => { + checks += 1; + return checks === 1; + }); + const response = await handleDesktopAppActivationRequest( + threadRequest, + dependencies({ isRequestActive, navigateToThread }), + ); + + expect(navigateToThread).toHaveBeenCalledOnce(); + expect(response).toMatchObject({ ok: false, code: "request-superseded" }); + }); + + it("returns thread-open-failed when navigation rejects", async () => { + const response = await handleDesktopAppActivationRequest( + threadRequest, + dependencies({ + navigateToThread: vi.fn(async () => { + throw new Error("Navigation failed."); + }), + }), + ); + + expect(response).toMatchObject({ ok: false, code: "thread-open-failed" }); + }); + + it("does not treat a redirected route as success", async () => { + const response = await handleDesktopAppActivationRequest( + threadRequest, + dependencies({ isThreadRouteActive: vi.fn(() => false) }), + ); + + expect(response).toMatchObject({ ok: false, code: "thread-open-failed" }); + }); + + it("does not navigate when the activity probe rejects before navigation", async () => { + const navigateToThread = vi.fn(async () => undefined); + const response = await handleDesktopAppActivationRequest( + threadRequest, + dependencies({ + isRequestActive: vi.fn(async () => { + throw new Error("The desktop bridge was torn down."); + }), + navigateToThread, + }), + ); + + expect(navigateToThread).not.toHaveBeenCalled(); + expect(response).toMatchObject({ + version: 1, + requestId: threadRequest.requestId, + ok: false, + code: "thread-open-failed", + }); + // The generic failure must never echo the raw dependency error. + expect(JSON.stringify(response)).not.toContain("torn down"); + }); + + it("does not report success when the activity probe rejects after navigation", async () => { + const navigateToThread = vi.fn(async () => undefined); + const isRequestActive = vi + .fn(async () => true) + .mockResolvedValueOnce(true) + .mockRejectedValueOnce(new Error("The desktop bridge was torn down.")); + const response = await handleDesktopAppActivationRequest( + threadRequest, + dependencies({ isRequestActive, navigateToThread }), + ); + + expect(navigateToThread).toHaveBeenCalledOnce(); + expect(isRequestActive).toHaveBeenCalledTimes(2); + expect(response).toMatchObject({ + version: 1, + requestId: threadRequest.requestId, + ok: false, + code: "thread-open-failed", + }); + expect(JSON.stringify(response)).not.toContain("torn down"); + }); + + it("does not navigate when the thread is archived while awaiting the activity probe", async () => { + const navigateToThread = vi.fn(async () => undefined); + const readThreadShell = vi.fn(() => openableThread({ archivedAt: "2024-01-01T00:00:00.000Z" })); + readThreadShell.mockReturnValueOnce(openableThread()); + + const response = await handleDesktopAppActivationRequest( + threadRequest, + dependencies({ readThreadShell, navigateToThread }), + ); + + expect(readThreadShell).toHaveBeenCalledTimes(2); + expect(navigateToThread).not.toHaveBeenCalled(); + expect(response).toMatchObject({ + version: 1, + requestId: threadRequest.requestId, + ok: false, + code: "thread-not-found", + }); + }); }); diff --git a/apps/web/src/desktopAppActivation.ts b/apps/web/src/desktopAppActivation.ts index d291e7ed54e2..73bfbdd22ffc 100644 --- a/apps/web/src/desktopAppActivation.ts +++ b/apps/web/src/desktopAppActivation.ts @@ -2,10 +2,12 @@ import type { DesktopAppActivationFailure, DesktopAppActivationRequest, DesktopAppActivationResponse, + DesktopAppOpenThreadRequest, EnvironmentId, ExecutionEnvironmentPlatformOs, ProjectId, ScopedProjectRef, + ScopedThreadRef, ThreadId, } from "@t3tools/contracts"; @@ -20,6 +22,18 @@ export interface DesktopAppActivationTarget { readonly platform: ExecutionEnvironmentPlatformOs; } +/** + * Minimal view of an existing thread the activation handler needs. The real + * coordinator projects `readThreadShell` into this shape so the handler stays + * testable and provider-agnostic. + */ +export interface DesktopAppActivationThread { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly projectId: ProjectId; + readonly archivedAt: string | null; +} + export interface DesktopAppActivationDependencies { readonly getTarget: () => DesktopAppActivationTarget | null; readonly findProject: ( @@ -34,6 +48,17 @@ export interface DesktopAppActivationDependencies { readonly openThread: ( projectRef: ScopedProjectRef, ) => Promise<{ readonly threadId: ThreadId } | null>; + /** + * The following are only needed for open-thread. They are optional so older + * callers and tests keep compiling, but open-thread fails safely when any is + * absent rather than guessing. + */ + readonly readThreadShell?: + | ((ref: ScopedThreadRef) => DesktopAppActivationThread | null) + | undefined; + readonly navigateToThread?: ((ref: ScopedThreadRef) => Promise) | undefined; + readonly isThreadRouteActive?: ((ref: ScopedThreadRef) => boolean) | undefined; + readonly isRequestActive?: ((requestId: string) => Promise) | undefined; } function failure( @@ -54,8 +79,26 @@ function errorMessage(error: unknown, fallback: string): string { return error instanceof Error && error.message.trim().length > 0 ? error.message : fallback; } -export async function handleDesktopAppActivationRequest( - request: DesktopAppActivationRequest, +function resolveOpenableThread( + request: DesktopAppOpenThreadRequest, + ref: ScopedThreadRef, + readThreadShell: (ref: ScopedThreadRef) => DesktopAppActivationThread | null, +): DesktopAppActivationThread | null { + const thread = readThreadShell(ref); + if (thread === null) return null; + // The shell lookup is already keyed by environment, but validate the + // reference explicitly so a mismatched or stale cache entry can never be + // reported as the requested thread. + if (thread.environmentId !== request.environmentId || thread.threadId !== request.threadId) { + return null; + } + // Archived threads are not openable and are never unarchived by activation. + if (thread.archivedAt !== null) return null; + return thread; +} + +async function handleOpenWorkspaceRequest( + request: Extract, dependencies: DesktopAppActivationDependencies, ): Promise { const target = dependencies.getTarget(); @@ -117,3 +160,127 @@ export async function handleDesktopAppActivationRequest( ); } } + +async function handleOpenThreadRequest( + request: DesktopAppOpenThreadRequest, + dependencies: DesktopAppActivationDependencies, +): Promise { + const target = dependencies.getTarget(); + if (target === null) { + return failure( + request.requestId, + "environment-unavailable", + "The desktop app's primary local environment is not connected.", + ); + } + + const requestPlatform = desktopPlatformToEnvironmentOs(request.platform); + if (requestPlatform !== target.platform) { + return failure( + request.requestId, + "platform-mismatch", + `The command path is for ${requestPlatform}, but the desktop app's primary environment uses ${target.platform}. Cross-platform path mapping is not supported.`, + ); + } + + // No silent substitution: a request for a non-primary environment is + // unavailable, not redirected to the primary one. + if (request.environmentId !== target.environmentId) { + return failure( + request.requestId, + "environment-unavailable", + "The requested environment is not the desktop app's primary environment.", + ); + } + + const { isRequestActive, readThreadShell, navigateToThread, isThreadRouteActive } = dependencies; + if ( + isRequestActive === undefined || + readThreadShell === undefined || + navigateToThread === undefined || + isThreadRouteActive === undefined + ) { + return failure( + request.requestId, + "renderer-unavailable", + "The desktop app cannot open an existing thread.", + ); + } + + const ref: ScopedThreadRef = { + environmentId: request.environmentId, + threadId: request.threadId, + }; + + // The activity probes, the thread read and the navigation all cross the IPC + // boundary, so any of them can reject when the desktop bridge is torn down. + // Catch once and answer with a normal failure: a rejected promise makes the + // coordinator drop the response and leaves the broker waiting for timeout. + try { + const thread = resolveOpenableThread(request, ref, readThreadShell); + if (thread === null) { + return failure( + request.requestId, + "thread-not-found", + "The requested thread does not exist in the desktop app.", + ); + } + + if (!(await isRequestActive(request.requestId))) { + return failure( + request.requestId, + "request-superseded", + "The desktop app request is no longer active.", + ); + } + + // Re-read immediately before navigating: the first read may have raced a + // delete or archive, and opening a gone thread would be a false success. + if (resolveOpenableThread(request, ref, readThreadShell) === null) { + return failure( + request.requestId, + "thread-not-found", + "The requested thread does not exist in the desktop app.", + ); + } + + await navigateToThread(ref); + + // A resolved navigate() promise is not proof of success by itself; also do + // not acknowledge a request that stopped being active mid-navigation. + if (!(await isRequestActive(request.requestId))) { + return failure( + request.requestId, + "request-superseded", + "The desktop app request is no longer active.", + ); + } + + if (!isThreadRouteActive(ref)) { + return failure(request.requestId, "thread-open-failed", "T3 Code could not open the thread."); + } + + return { + version: 1, + requestId: request.requestId, + ok: true, + environmentId: request.environmentId, + projectId: thread.projectId, + threadId: request.threadId, + }; + } catch { + // Never leak the raw dependency error: the CLI needs a stable code, and a + // torn-down bridge message is not actionable. + return failure(request.requestId, "thread-open-failed", "T3 Code could not open the thread."); + } +} + +export async function handleDesktopAppActivationRequest( + request: DesktopAppActivationRequest, + dependencies: DesktopAppActivationDependencies, +): Promise { + if (request.type === "open-thread") { + return handleOpenThreadRequest(request, dependencies); + } + return handleOpenWorkspaceRequest(request, dependencies); +} diff --git a/packages/contracts/src/desktopAppActivation.ts b/packages/contracts/src/desktopAppActivation.ts index 020188cf43eb..5a136d30d5a4 100644 --- a/packages/contracts/src/desktopAppActivation.ts +++ b/packages/contracts/src/desktopAppActivation.ts @@ -1,28 +1,74 @@ import * as Schema from "effect/Schema"; -import { ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { EnvironmentId, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; export const DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION = 1 as const; export const DesktopAppActivationPlatform = Schema.Literals(["darwin", "linux", "win32"]); export type DesktopAppActivationPlatform = typeof DesktopAppActivationPlatform.Type; -export const DesktopAppActivationRequest = Schema.Struct({ +/** The activation operations a desktop shell can advertise. */ +export const DesktopAppActivationOperation = Schema.Literals(["open-workspace", "open-thread"]); +export type DesktopAppActivationOperation = typeof DesktopAppActivationOperation.Type; + +/** Existing open-a-workspace request. Kept byte-for-byte compatible with protocol v1. */ +export const DesktopAppOpenWorkspaceRequest = Schema.Struct({ version: Schema.Literal(DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION), requestId: TrimmedNonEmptyString, type: Schema.Literal("open-workspace"), workspaceRoot: TrimmedNonEmptyString, platform: DesktopAppActivationPlatform, }); +export type DesktopAppOpenWorkspaceRequest = typeof DesktopAppOpenWorkspaceRequest.Type; + +/** + * Opens an existing conversation in the desktop's primary environment. The + * environmentId is the client's chosen target, not a hint: the renderer + * rejects a mismatch instead of substituting its primary. + */ +export const DesktopAppOpenThreadRequest = Schema.Struct({ + version: Schema.Literal(DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION), + requestId: TrimmedNonEmptyString, + type: Schema.Literal("open-thread"), + platform: DesktopAppActivationPlatform, + environmentId: EnvironmentId, + threadId: ThreadId, +}); +export type DesktopAppOpenThreadRequest = typeof DesktopAppOpenThreadRequest.Type; + +export const DesktopAppActivationRequest = Schema.Union([ + DesktopAppOpenWorkspaceRequest, + DesktopAppOpenThreadRequest, +]); export type DesktopAppActivationRequest = typeof DesktopAppActivationRequest.Type; +/** + * Read-only capability probe. Separate from activation so a shell can answer + * it without focusing a window, touching the renderer, or creating anything. + */ +export const DesktopAppGetCapabilitiesRequest = Schema.Struct({ + version: Schema.Literal(DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION), + requestId: TrimmedNonEmptyString, + type: Schema.Literal("get-capabilities"), +}); +export type DesktopAppGetCapabilitiesRequest = typeof DesktopAppGetCapabilitiesRequest.Type; + +/** Everything a connected control client may send over the local socket. */ +export const DesktopAppControlRequest = Schema.Union([ + DesktopAppActivationRequest, + DesktopAppGetCapabilitiesRequest, +]); +export type DesktopAppControlRequest = typeof DesktopAppControlRequest.Type; + export const DesktopAppActivationErrorCode = Schema.Literals([ "invalid-request", "renderer-unavailable", "environment-unavailable", "platform-mismatch", "project-create-failed", + "thread-not-found", "thread-open-failed", + "request-superseded", "request-timeout", "internal-error", ]); @@ -32,6 +78,9 @@ export const DesktopAppActivationSuccess = Schema.Struct({ version: Schema.Literal(DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION), requestId: TrimmedNonEmptyString, ok: Schema.Literal(true), + // Absent for open-workspace responses so existing v1 workspace clients + // decode them unchanged; always present for open-thread. + environmentId: Schema.optional(EnvironmentId), projectId: ProjectId, threadId: ThreadId, }); @@ -46,8 +95,25 @@ export const DesktopAppActivationFailure = Schema.Struct({ }); export type DesktopAppActivationFailure = typeof DesktopAppActivationFailure.Type; +/** Activation-only response union. Capability responses intentionally sit outside it. */ export const DesktopAppActivationResponse = Schema.Union([ DesktopAppActivationSuccess, DesktopAppActivationFailure, ]); export type DesktopAppActivationResponse = typeof DesktopAppActivationResponse.Type; + +export const DesktopAppCapabilitiesSuccess = Schema.Struct({ + version: Schema.Literal(DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION), + requestId: TrimmedNonEmptyString, + ok: Schema.Literal(true), + type: Schema.Literal("capabilities"), + operations: Schema.Array(DesktopAppActivationOperation), + environmentScope: Schema.Literal("primary"), +}); +export type DesktopAppCapabilitiesSuccess = typeof DesktopAppCapabilitiesSuccess.Type; + +export const DesktopAppControlResponse = Schema.Union([ + DesktopAppActivationResponse, + DesktopAppCapabilitiesSuccess, +]); +export type DesktopAppControlResponse = typeof DesktopAppControlResponse.Type; diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index c8b8833ead86..e0d19b10a148 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -167,6 +167,23 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ desktop servers whose app predates the remote trigger, where clients must keep telling the user to update the app on that machine. */ desktopAppUpdate: Schema.optionalKey(Schema.Boolean), + /** Local-only control socket the desktop app supervising this server + listens on. Clients treat this as an untrusted hint, not authorization + or proof the listener is ready: they must probe the socket and validate + the target environment/thread before activating. Absent on headless + servers, WSL-hosted backends without a native control fd, and platforms + with no desktop shell. */ + desktopAppControl: Schema.optionalKey( + Schema.Struct({ + version: Schema.Literal(1), + address: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), + }), + ), + /** Server stamps `T3CODE_INTEGRATION_CONTEXT` into the subprocess + environments of T3-managed provider conversations, so provider hooks can + recognize that an event is owned by T3. The literal is the context + schema version; absent on servers that predate the marker. */ + providerIntegrationContext: Schema.optionalKey(Schema.Literal(1)), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index dd972b7aa816..ea1b2d17654c 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1334,6 +1334,12 @@ export interface DesktopBridge { setReady: (ready: boolean) => Promise; complete: (response: DesktopAppActivationResponse) => Promise; onRequest: (listener: (request: DesktopAppActivationRequest) => void) => () => void; + /** + * Whether the shell still considers this request active. Optional: older + * shells lack it, and open-thread callers must fail safely rather than + * assume a request is still live. + */ + isRequestActive?: (requestId: string) => Promise; }; /** * Desktop-only preview surface. Present iff the renderer is hosted by the