authorgravatar for ethorelli@gmail.comErik Thorelli <ethorelli@gmail.com> 2026-10-06 10:52:08-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 10:52:08-07:00
log78552a8c6162c9b5248231bc70be8bffdb50cbd3
treeee48d1dc45597e71f184354e297bb29f872bb833
parentb38aa18365fe314e079d99670932bedfc7407e5a
signature Signed by PGP key B5690EEEBB952194

chore(deps): upgrade @effect/tsgo to 0.46.1 (#16360)

Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

72 files changed, 965 insertions(+), 651 deletions(-)

apps/desktop/src/electron/ElectronProtocol.test.ts+51-1
...@@ -1,9 +1,11 @@...@@ -1,9 +1,11 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
2import * as Cause from "effect/Cause";2import * as Cause from "effect/Cause";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
4import * as NodeServices from "@effect/platform-node/NodeServices";5import * as NodeServices from "@effect/platform-node/NodeServices";
5import * as FileSystem from "effect/FileSystem";6import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
8import * as TestClock from "effect/testing/TestClock";
7import { beforeEach, vi } from "vite-plus/test";9import { beforeEach, vi } from "vite-plus/test";
810
9const { handleMock, netFetchMock, unhandleMock } = vi.hoisted(() => ({11const { handleMock, netFetchMock, unhandleMock } = vi.hoisted(() => ({
...@@ -174,7 +176,11 @@ describe("ElectronProtocol", () => {...@@ -174,7 +176,11 @@ describe("ElectronProtocol", () => {
174 targetOrigin: new URL("http://127.0.0.1:5733/"),176 targetOrigin: new URL("http://127.0.0.1:5733/"),
175 clerkFrontendApiHostname: undefined,177 clerkFrontendApiHostname: undefined,
176 });178 });
177 return yield* Effect.promise(() => handler!(new Request("t3code-dev://app/")));179 const fiber = yield* Effect.forkChild(
180 Effect.promise(() => handler!(new Request("t3code-dev://app/"))),
181 );
182 yield* TestClock.adjust("50 millis");
183 return yield* Fiber.join(fiber);
178 }),184 }),
179 );185 );
180186
...@@ -183,6 +189,50 @@ describe("ElectronProtocol", () => {...@@ -183,6 +189,50 @@ describe("ElectronProtocol", () => {
183 }).pipe(Effect.provide(layerProtocol)),189 }).pipe(Effect.provide(layerProtocol)),
184 );190 );
185191
192 it.effect("rejects with the last renderer target failure after 50ms and 150ms retries", () =>
193 Effect.gen(function* () {
194 let handler: ((request: Request) => Promise<Response>) | undefined;
195 handleMock.mockImplementation((_scheme, nextHandler) => {
196 handler = nextHandler;
197 });
198 const lastFailure = new Error("connect ECONNREFUSED 127.0.0.1:5733 (3)");
199 netFetchMock
200 .mockRejectedValueOnce(new Error("connect ECONNREFUSED 127.0.0.1:5733 (1)"))
201 .mockRejectedValueOnce(new Error("connect ECONNREFUSED 127.0.0.1:5733 (2)"))
202 .mockRejectedValueOnce(lastFailure);
203
204 const rejection = yield* Effect.scoped(
205 Effect.gen(function* () {
206 const protocol = yield* ElectronProtocol.ElectronProtocol;
207 yield* protocol.registerDesktopProtocol({
208 scheme: "t3code-dev",
209 targetOrigin: new URL("http://127.0.0.1:5733/"),
210 clerkFrontendApiHostname: undefined,
211 });
212 const fiber = yield* Effect.forkChild(
213 Effect.promise(() =>
214 handler!(new Request("t3code-dev://app/")).then(
215 () => null,
216 (error: unknown) => error,
217 ),
218 ),
219 );
220 yield* TestClock.adjust("49 millis");
221 assert.equal(netFetchMock.mock.calls.length, 1);
222 yield* TestClock.adjust("1 millis");
223 assert.equal(netFetchMock.mock.calls.length, 2);
224 yield* TestClock.adjust("149 millis");
225 assert.equal(netFetchMock.mock.calls.length, 2);
226 yield* TestClock.adjust("1 millis");
227 return yield* Fiber.join(fiber);
228 }),
229 );
230
231 assert.strictEqual(rejection, lastFailure);
232 assert.equal(netFetchMock.mock.calls.length, 3);
233 }).pipe(Effect.provide(layerProtocol)),
234 );
235
186 it.effect("preserves protocol registration failures", () =>236 it.effect("preserves protocol registration failures", () =>
187 Effect.gen(function* () {237 Effect.gen(function* () {
188 const cause = new Error("protocol registration failed");238 const cause = new Error("protocol registration failed");
apps/desktop/src/electron/ElectronProtocol.ts+32-27
...@@ -3,10 +3,10 @@ import * as Effect from "effect/Effect";...@@ -3,10 +3,10 @@ import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";5import * as Option from "effect/Option";
6import * as NodeTimersPromises from "node:timers/promises";
7import * as Path from "effect/Path";6import * as Path from "effect/Path";
8import * as Mime from "effect/http/Mime";7import * as Mime from "effect/http/Mime";
9import * as Ref from "effect/Ref";8import * as Ref from "effect/Ref";
9import * as Schedule from "effect/Schedule";
10import * as Schema from "effect/Schema";10import * as Schema from "effect/Schema";
11import * as Scope from "effect/Scope";11import * as Scope from "effect/Scope";
1212
...@@ -150,11 +150,29 @@ const registerDesktopSchemePrivileges = Effect.sync(registerDesktopSchemePrivile...@@ -150,11 +150,29 @@ const registerDesktopSchemePrivileges = Effect.sync(registerDesktopSchemePrivile
150150
151export const layerSchemePrivileges = Layer.effectDiscard(registerDesktopSchemePrivileges);151export const layerSchemePrivileges = Layer.effectDiscard(registerDesktopSchemePrivileges);
152152
153async function proxyRequest(153class ElectronProtocolFetchError extends Schema.TaggedError<ElectronProtocolFetchError>()(
154 "ElectronProtocolFetchError",
155 { cause: Schema.Defect() },
156) {}
157
158const netFetch = (url: string, init: RequestInit) =>
159 Effect.tryPromise({
160 try: () => Electron.net.fetch(url, init),
161 catch: (cause) => new ElectronProtocolFetchError({ cause }),
162 });
163
164// The dev renderer target can briefly refuse connections while Vite restarts:
165// retry idempotent requests after 50ms, then 150ms, and keep the last failure.
166const fetchWithTransientRetry = (url: string, init: RequestInit) =>
167 netFetch(url, init).pipe(
168 Effect.retry({ schedule: Schedule.exponential("50 millis", 3), times: 2 }),
169 );
170
171const proxyRequest = Effect.fn("desktop.protocol.proxyRequest")(function* (
154 request: Request,172 request: Request,
155 targetOrigin: URL,173 targetOrigin: URL,
156 contentSecurityPolicy: string,174 contentSecurityPolicy: string,
157): Promise<Response> {175) {
158 const requestUrl = new URL(request.url);176 const requestUrl = new URL(request.url);
159 if (requestUrl.host !== DESKTOP_HOST) {177 if (requestUrl.host !== DESKTOP_HOST) {
160 return new Response(null, { status: 404 });178 return new Response(null, { status: 404 });
...@@ -190,12 +208,10 @@ async function proxyRequest(...@@ -190,12 +208,10 @@ async function proxyRequest(
190 }208 }
191 const response =209 const response =
192 request.method === "GET" || request.method === "HEAD"210 request.method === "GET" || request.method === "HEAD"
193 ? await fetchWithTransientRetry(targetUrl.toString(), init)211 ? yield* fetchWithTransientRetry(targetUrl.toString(), init)
194 : await Electron.net.fetch(targetUrl.toString(), init);212 : yield* netFetch(targetUrl.toString(), init);
195 return withContentSecurityPolicy(response, contentSecurityPolicy);213 return withContentSecurityPolicy(response, contentSecurityPolicy);
196}214});
197
198const TRANSIENT_FETCH_RETRY_DELAYS_MS = [0, 50, 150] as const;
199215
200// Serves the packaged web client without a backend: files resolve within the216// Serves the packaged web client without a backend: files resolve within the
201// asset directory, and any other path falls back to index.html so the SPA217// asset directory, and any other path falls back to index.html so the SPA
...@@ -238,24 +254,6 @@ const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* (...@@ -238,24 +254,6 @@ const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* (
238 });254 });
239});255});
240256
241async function fetchWithTransientRetry(url: string, init: RequestInit): Promise<Response> {
242 let lastError: unknown;
243
244 for (const delayMs of TRANSIENT_FETCH_RETRY_DELAYS_MS) {
245 if (delayMs > 0) {
246 await NodeTimersPromises.setTimeout(delayMs);
247 }
248
249 try {
250 return await Electron.net.fetch(url, init);
251 } catch (error) {
252 lastError = error;
253 }
254 }
255
256 throw lastError;
257}
258
259/** @public Service construction is part of the canonical Effect module API. */257/** @public Service construction is part of the canonical Effect module API. */
260export const make = Effect.gen(function* () {258export const make = Effect.gen(function* () {
261 const registered = yield* Ref.make(false);259 const registered = yield* Ref.make(false);
...@@ -278,7 +276,14 @@ export const make = Effect.gen(function* () {...@@ -278,7 +276,14 @@ export const make = Effect.gen(function* () {
278 contentSecurityPolicy,276 contentSecurityPolicy,
279 );277 );
280 }278 }
281 return proxyRequest(request, input.targetOrigin, contentSecurityPolicy);279 // Reject with net.fetch's own error, as an unproxied fetch would.
280 return runPromise(
281 proxyRequest(request, input.targetOrigin, contentSecurityPolicy).pipe(
282 Effect.catchTags({
283 ElectronProtocolFetchError: (error) => Effect.die(error.cause),
284 }),
285 ),
286 );
282 });287 });
283 },288 },
284 catch: (cause) => new ElectronProtocolRegistrationError({ scheme: input.scheme, cause }),289 catch: (cause) => new ElectronProtocolRegistrationError({ scheme: input.scheme, cause }),
apps/desktop/src/ssh/DesktopSshEnvironment.ts+2
...@@ -18,6 +18,7 @@ import {...@@ -18,6 +18,7 @@ import {
18} from "@t3tools/ssh/errors";18} from "@t3tools/ssh/errors";
19import * as SshTunnel from "@t3tools/ssh/tunnel";19import * as SshTunnel from "@t3tools/ssh/tunnel";
20import * as Context from "effect/Context";20import * as Context from "effect/Context";
21import type * as Crypto from "effect/Crypto";
21import * as Effect from "effect/Effect";22import * as Effect from "effect/Effect";
22import * as FileSystem from "effect/FileSystem";23import * as FileSystem from "effect/FileSystem";
23import * as Layer from "effect/Layer";24import * as Layer from "effect/Layer";
...@@ -29,6 +30,7 @@ import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts";...@@ -29,6 +30,7 @@ import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts";
2930
30export type DesktopSshEnvironmentRuntimeServices =31export type DesktopSshEnvironmentRuntimeServices =
31 | ChildProcessSpawner.ChildProcessSpawner32 | ChildProcessSpawner.ChildProcessSpawner
33 | Crypto.Crypto
32 | FileSystem.FileSystem34 | FileSystem.FileSystem
33 | Path.Path35 | Path.Path
34 | HttpClient.HttpClient36 | HttpClient.HttpClient
apps/server/package.json+1
...@@ -34,6 +34,7 @@...@@ -34,6 +34,7 @@
34 "@effect/platform-node-shared": "catalog:",34 "@effect/platform-node-shared": "catalog:",
35 "@ff-labs/fff-node": "0.9.4",35 "@ff-labs/fff-node": "0.9.4",
36 "@napi-rs/keyring": "^1.3.0",36 "@napi-rs/keyring": "^1.3.0",
37 "@noble/hashes": "catalog:",
37 "@opencode-ai/sdk": "^1.3.15",38 "@opencode-ai/sdk": "^1.3.15",
38 "@opencode/client": "2.0.23",39 "@opencode/client": "2.0.23",
39 "@opencode/protocol": "2.0.23",40 "@opencode/protocol": "2.0.23",
apps/server/src/auth/ReusableDevAuth.ts+1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no timingSafeEqual.
1import * as NodeCrypto from "node:crypto";2import * as NodeCrypto from "node:crypto";
2import { AuthSessionId } from "@t3tools/contracts";3import { AuthSessionId } from "@t3tools/contracts";
3import * as DateTime from "effect/DateTime";4import * as DateTime from "effect/DateTime";
apps/server/src/auth/utils.ts+1
...@@ -4,6 +4,7 @@ import type {...@@ -4,6 +4,7 @@ import type {
4 AuthClientPresentationMetadata,4 AuthClientPresentationMetadata,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import type * as HttpServerRequest from "effect/http/HttpServerRequest";6import type * as HttpServerRequest from "effect/http/HttpServerRequest";
7// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createHmac or timingSafeEqual.
7import * as NodeCrypto from "node:crypto";8import * as NodeCrypto from "node:crypto";
8import * as Base64Url from "effect/encoding/Base64Url";9import * as Base64Url from "effect/encoding/Base64Url";
9import * as Result from "effect/Result";10import * as Result from "effect/Result";
apps/server/src/checkpointing/CheckpointDiffQuery.test.ts+3-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
1import { assert, it, vi } from "@effect/vitest";2import { assert, it, vi } from "@effect/vitest";
2import { CheckpointRef, CheckpointScopeId, RunId, ThreadId } from "@t3tools/contracts";3import { CheckpointRef, CheckpointScopeId, RunId, ThreadId } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
...@@ -59,6 +60,7 @@ function layerFor(input: {...@@ -59,6 +60,7 @@ function layerFor(input: {
59 }),60 }),
60 ),61 ),
61 ),62 ),
63 Layer.provideMerge(NodeCrypto.layer),
62 );64 );
63}65}
6466
...@@ -80,7 +82,7 @@ it.effect("computes V2 run diffs from projected checkpoint scopes", () => {...@@ -80,7 +82,7 @@ it.effect("computes V2 run diffs from projected checkpoint scopes", () => {
80 });82 });
81 assert.deepEqual(diffCheckpoints.mock.calls[0]?.[0], {83 assert.deepEqual(diffCheckpoints.mock.calls[0]?.[0], {
82 cwd: "/repo",84 cwd: "/repo",
83 fromCheckpointRef: checkpointRefForScopeOrdinal({85 fromCheckpointRef: yield* checkpointRefForScopeOrdinal({
84 scopeId: firstScopeId,86 scopeId: firstScopeId,
85 ordinalWithinScope: 0,87 ordinalWithinScope: 0,
86 }),88 }),
apps/server/src/checkpointing/CheckpointDiffQuery.ts+14-13
...@@ -15,6 +15,7 @@ import {...@@ -15,6 +15,7 @@ import {
15 type ThreadId,15 type ThreadId,
16} from "@t3tools/contracts";16} from "@t3tools/contracts";
17import * as Context from "effect/Context";17import * as Context from "effect/Context";
18import * as Crypto from "effect/Crypto";
18import * as Effect from "effect/Effect";19import * as Effect from "effect/Effect";
19import * as Layer from "effect/Layer";20import * as Layer from "effect/Layer";
20import * as Schema from "effect/Schema";21import * as Schema from "effect/Schema";
...@@ -77,6 +78,7 @@ function buildTurnDiffResult(...@@ -77,6 +78,7 @@ function buildTurnDiffResult(
77export const make = Effect.gen(function* () {78export const make = Effect.gen(function* () {
78 const threads = yield* ThreadManagement.ThreadManagementService;79 const threads = yield* ThreadManagement.ThreadManagementService;
79 const checkpointStore = yield* CheckpointStore.CheckpointStore;80 const checkpointStore = yield* CheckpointStore.CheckpointStore;
81 const crypto = yield* Crypto.Crypto;
8082
81 const getTurnDiff: CheckpointDiffQuery["Service"]["getTurnDiff"] = Effect.fn("getTurnDiff")(83 const getTurnDiff: CheckpointDiffQuery["Service"]["getTurnDiff"] = Effect.fn("getTurnDiff")(
82 function* (input) {84 function* (input) {
...@@ -155,21 +157,20 @@ export const make = Effect.gen(function* () {...@@ -155,21 +157,20 @@ export const make = Effect.gen(function* () {
155 });157 });
156 }158 }
157159
160 // The root scope is shared by every run in this thread. Its runId
161 // tracks the latest owner, while ordinal zero stays the baseline.
162 const firstScope =
163 input.fromTurnCount === 0
164 ? projection.checkpointScopes.find((scope) => scope.kind === "root_run")
165 : undefined;
158 const fromCheckpointRef =166 const fromCheckpointRef =
159 input.fromTurnCount === 0167 input.fromTurnCount === 0
160 ? (() => {168 ? firstScope === undefined
161 // The root scope is shared by every run in this thread. Its169 ? undefined
162 // runId tracks the latest owner, while ordinal zero stays the baseline.170 : yield* checkpointRefForScopeOrdinal({
163 const firstScope = projection.checkpointScopes.find(171 scopeId: firstScope.id,
164 (scope) => scope.kind === "root_run",172 ordinalWithinScope: 0,
165 );173 }).pipe(Effect.provideService(Crypto.Crypto, crypto))
166 return firstScope === undefined
167 ? undefined
168 : checkpointRefForScopeOrdinal({
169 scopeId: firstScope.id,
170 ordinalWithinScope: 0,
171 });
172 })()
173 : readyCheckpoints.find((checkpoint) => checkpoint.appRunOrdinal === input.fromTurnCount)174 : readyCheckpoints.find((checkpoint) => checkpoint.appRunOrdinal === input.fromTurnCount)
174 ?.ref;175 ?.ref;
175 if (fromCheckpointRef === undefined) {176 if (fromCheckpointRef === undefined) {
apps/server/src/cloud/CloudLink.ts+1
...@@ -4,6 +4,7 @@...@@ -4,6 +4,7 @@
4 * and mint requests, and keeping the managed tunnel registered, recovered and4 * and mint requests, and keeping the managed tunnel registered, recovered and
5 * released. HTTP handlers, server startup and shutdown all go through it.5 * released. HTTP handlers, server startup and shutdown all go through it.
6 */6 */
7// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createPublicKey.
7import * as NodeCrypto from "node:crypto";8import * as NodeCrypto from "node:crypto";
8import {9import {
9 AuthStandardClientScopes,10 AuthStandardClientScopes,
apps/server/src/cloud/environmentKeys.ts+1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync.
1import * as NodeCrypto from "node:crypto";2import * as NodeCrypto from "node:crypto";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";4import * as Option from "effect/Option";
apps/server/src/device/AgentDeviceTarget.test.ts+5-3
...@@ -41,7 +41,9 @@ console.log(readFileSync(args[args.indexOf('--config') + 1], 'utf8'));...@@ -41,7 +41,9 @@ console.log(readFileSync(args[args.indexOf('--config') + 1], 'utf8'));
41if (process.env.AGENT_DEVICE_DAEMON_BASE_URL) process.exit(2);`,41if (process.env.AGENT_DEVICE_DAEMON_BASE_URL) process.exit(2);`,
42 );42 );
43 const shim = yield* ensureAgentDeviceShim({ entryPath, stateDir: dir });43 const shim = yield* ensureAgentDeviceShim({ entryPath, stateDir: dir });
44 const files = ["mini", "android"].map((host) => agentDeviceConfigPath(dir, host, path));44 const files = yield* Effect.forEach(["mini", "android"], (host) =>
45 agentDeviceConfigPath(dir, host, path),
46 );
45 for (const [index, file] of files.entries())47 for (const [index, file] of files.entries())
46 yield* writeAgentDeviceConfig(file, {48 yield* writeAgentDeviceConfig(file, {
47 baseUrl: `http://127.0.0.1:${1000 + index}`,49 baseUrl: `http://127.0.0.1:${1000 + index}`,
...@@ -73,8 +75,8 @@ if (process.env.AGENT_DEVICE_DAEMON_BASE_URL) process.exit(2);`,...@@ -73,8 +75,8 @@ if (process.env.AGENT_DEVICE_DAEMON_BASE_URL) process.exit(2);`,
73 });75 });
74 expect((yield* Effect.promise(() => invoke(files[0]!))).daemonAuthToken).toBe("new");76 expect((yield* Effect.promise(() => invoke(files[0]!))).daemonAuthToken).toBe("new");
75 expect(yield* fs.readFileString(files[1]!)).toBe(second);77 expect(yield* fs.readFileString(files[1]!)).toBe(second);
76 expect(agentDeviceSession("thread", "mini", "same-id")).not.toBe(78 expect(yield* agentDeviceSession("thread", "mini", "same-id")).not.toBe(
77 agentDeviceSession("thread", "android", "same-id"),79 yield* agentDeviceSession("thread", "android", "same-id"),
78 );80 );
79 for (const args of [81 for (const args of [
80 ["snapshot"],82 ["snapshot"],
apps/server/src/device/AgentDeviceTarget.ts+12-6
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import * as NodeCrypto from "node:crypto";1import * as Crypto from "effect/Crypto";
2import * as Schema from "effect/Schema";
3import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Hex from "effect/encoding/Hex";
4import * as Schema from "effect/Schema";
4import * as FileSystem from "effect/FileSystem";5import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";6import * as Path from "effect/Path";
67
...@@ -12,15 +13,20 @@ const encodeEndpoint = Schema.encodeEffect(...@@ -12,15 +13,20 @@ const encodeEndpoint = Schema.encodeEffect(
12 ),13 ),
13);14);
1415
15const key = (value: string) =>16const key = Effect.fn("AgentDeviceTarget.key")(function* (value: string) {
16 NodeCrypto.createHash("sha256").update(value).digest("hex").slice(0, 24);17 const crypto = yield* Crypto.Crypto;
18 const digest = yield* crypto
19 .digest("SHA-256", new TextEncoder().encode(value))
20 .pipe(Effect.orDie);
21 return Hex.encode(digest).slice(0, 24);
22});
1723
18/** A stable file per host lets forwarded endpoints change without retargeting other commands. */24/** A stable file per host lets forwarded endpoints change without retargeting other commands. */
19export const agentDeviceConfigPath = (stateDir: string, hostId: string, path: Path.Path) =>25export const agentDeviceConfigPath = (stateDir: string, hostId: string, path: Path.Path) =>
20 path.join(stateDir, "device", "hosts", `${key(hostId)}.json`);26 key(hostId).pipe(Effect.map((hash) => path.join(stateDir, "device", "hosts", `${hash}.json`)));
2127
22export const agentDeviceSession = (threadId: string, hostId: string, deviceId: string) =>28export const agentDeviceSession = (threadId: string, hostId: string, deviceId: string) =>
23 `t3-${key(JSON.stringify([threadId, hostId, deviceId]))}`;29 key(JSON.stringify([threadId, hostId, deviceId])).pipe(Effect.map((hash) => `t3-${hash}`));
2430
25export const writeAgentDeviceConfig = Effect.fn("AgentDeviceTarget.writeConfig")(function* (31export const writeAgentDeviceConfig = Effect.fn("AgentDeviceTarget.writeConfig")(function* (
26 file: string,32 file: string,
apps/server/src/device/DeviceMultiHost.test.ts+2-1
...@@ -2,6 +2,7 @@ import { expect, it } from "@effect/vitest";...@@ -2,6 +2,7 @@ import { expect, it } from "@effect/vitest";
2import { ThreadId } from "@t3tools/contracts";2import { ThreadId } from "@t3tools/contracts";
3import * as Deferred from "effect/Deferred";3import * as Deferred from "effect/Deferred";
4import * as Fiber from "effect/Fiber";4import * as Fiber from "effect/Fiber";
5import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
5import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
6import { HttpClient, HttpClientResponse } from "effect/http";7import { HttpClient, HttpClientResponse } from "effect/http";
7import * as ServerSettings from "../serverSettings.ts";8import * as ServerSettings from "../serverSettings.ts";
...@@ -77,7 +78,7 @@ it.effect("keeps hosts independent when serials collide and another host fails",...@@ -77,7 +78,7 @@ it.effect("keeps hosts independent when serials collide and another host fails",
77 order.push("write finished");78 order.push("write finished");
78 return "/host-config.json";79 return "/host-config.json";
79 }),80 }),
80 ).pipe(Effect.provideService(HttpClient.HttpClient, http));81 ).pipe(Effect.provide(NodeCrypto.layer), Effect.provideService(HttpClient.HttpClient, http));
81 expect(yield* service.agentReadinessIfSupported("b")).not.toBeNull();82 expect(yield* service.agentReadinessIfSupported("b")).not.toBeNull();
82 const listed = yield* service.list;83 const listed = yield* service.list;
83 expect(listed.devices.map((device) => device.hostId).sort()).toEqual(["a", "b"]);84 expect(listed.devices.map((device) => device.hostId).sort()).toEqual(["a", "b"]);
apps/server/src/device/DeviceService.test.ts+4
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7 ThreadId,7 ThreadId,
8 type DeviceServiceState,8 type DeviceServiceState,
9} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
10import * as Effect from "effect/Effect";11import * as Effect from "effect/Effect";
11import * as Exit from "effect/Exit";12import * as Exit from "effect/Exit";
12import * as Deferred from "effect/Deferred";13import * as Deferred from "effect/Deferred";
...@@ -135,6 +136,7 @@ const fixture = Effect.fn("fixture")(function* (...@@ -135,6 +136,7 @@ const fixture = Effect.fn("fixture")(function* (
135 undefined,136 undefined,
136 installTool,137 installTool,
137 ).pipe(138 ).pipe(
139 Effect.provide(NodeCrypto.layer),
138 Effect.provideService(DeviceHost.DeviceHost, host),140 Effect.provideService(DeviceHost.DeviceHost, host),
139 Effect.provideService(141 Effect.provideService(
140 ServerSettings.ServerSettingsService,142 ServerSettings.ServerSettingsService,
...@@ -482,6 +484,7 @@ it.effect.each(["shutdown", "close"] as const)(...@@ -482,6 +484,7 @@ it.effect.each(["shutdown", "close"] as const)(
482 }),484 }),
483 );485 );
484 const service = yield* DeviceService.makeWithHosts(new Map([[host.id, host]])).pipe(486 const service = yield* DeviceService.makeWithHosts(new Map([[host.id, host]])).pipe(
487 Effect.provide(NodeCrypto.layer),
485 Effect.provideService(HttpClient.HttpClient, http),488 Effect.provideService(HttpClient.HttpClient, http),
486 );489 );
487 const input = { threadId, deviceId, platform: "ios" as const };490 const input = { threadId, deviceId, platform: "ios" as const };
...@@ -574,6 +577,7 @@ it.effect.each([...@@ -574,6 +577,7 @@ it.effect.each([
574 }),577 }),
575 );578 );
576 const service = yield* DeviceService.makeWithHosts(new Map([[host.id, host]])).pipe(579 const service = yield* DeviceService.makeWithHosts(new Map([[host.id, host]])).pipe(
580 Effect.provide(NodeCrypto.layer),
577 Effect.provideService(HttpClient.HttpClient, http),581 Effect.provideService(HttpClient.HttpClient, http),
578 );582 );
579 yield* service.list;583 yield* service.list;
apps/server/src/device/DeviceService.ts+20-14
...@@ -46,6 +46,7 @@ import {...@@ -46,6 +46,7 @@ import {
46 writeAgentDeviceConfig,46 writeAgentDeviceConfig,
47} from "./AgentDeviceTarget.ts";47} from "./AgentDeviceTarget.ts";
48import * as Context from "effect/Context";48import * as Context from "effect/Context";
49import * as Crypto from "effect/Crypto";
49import * as DateTime from "effect/DateTime";50import * as DateTime from "effect/DateTime";
50import * as Duration from "effect/Duration";51import * as Duration from "effect/Duration";
51import * as Effect from "effect/Effect";52import * as Effect from "effect/Effect";
...@@ -186,6 +187,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*...@@ -186,6 +187,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
186 installTool?: (tool: "hub" | "agent") => Effect.Effect<unknown, DeviceError>,187 installTool?: (tool: "hub" | "agent") => Effect.Effect<unknown, DeviceError>,
187) {188) {
188 const settings = yield* ServerSettings.ServerSettingsService;189 const settings = yield* ServerSettings.ServerSettingsService;
190 const crypto = yield* Crypto.Crypto;
189 const lifecycleLock = yield* Semaphore.make(1);191 const lifecycleLock = yield* Semaphore.make(1);
190 const readDeviceSettings = settings.getSettings.pipe(192 const readDeviceSettings = settings.getSettings.pipe(
191 Effect.map((value) => ({193 Effect.map((value) => ({
...@@ -943,12 +945,12 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*...@@ -943,12 +945,12 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
943 return yield* configureAgent(input.hostId, ready);945 return yield* configureAgent(input.hostId, ready);
944 }),946 }),
945 );947 );
946 return [948 const session = yield* agentDeviceSession(
947 "--config",949 input.threadId,
948 configPath,950 input.hostId,
949 "--session",951 input.deviceId,
950 agentDeviceSession(input.threadId, input.hostId, input.deviceId),952 ).pipe(Effect.provideService(Crypto.Crypto, crypto));
951 ];953 return ["--config", configPath, "--session", session];
952 }),954 }),
953 state: SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state)),955 state: SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state)),
954 subscribe: PubSub.subscribe(statePubSub),956 subscribe: PubSub.subscribe(statePubSub),
...@@ -998,9 +1000,17 @@ export const make = Effect.gen(function* () {...@@ -998,9 +1000,17 @@ export const make = Effect.gen(function* () {
998 const hosts = new Map<DeviceHostId, DeviceHost.DeviceHost["Service"]>([1000 const hosts = new Map<DeviceHostId, DeviceHost.DeviceHost["Service"]>([
999 [localHost.id, localHost],1001 [localHost.id, localHost],
1000 ]);1002 ]);
1001 const configureAgent = (hostId: DeviceHostId, ready: DeviceHost.DeviceHostAgentReady) => {1003 const crypto = yield* Crypto.Crypto;
1002 const file = agentDeviceConfigPath(config.stateDir, hostId, path);1004 const configPath = (hostId: DeviceHostId) =>
1003 return writeAgentDeviceConfig(file, ready.agentDevice).pipe(1005 agentDeviceConfigPath(config.stateDir, hostId, path).pipe(
1006 Effect.provideService(Crypto.Crypto, crypto),
1007 );
1008 const configureAgent = (hostId: DeviceHostId, ready: DeviceHost.DeviceHostAgentReady) =>
1009 Effect.gen(function* () {
1010 const file = yield* configPath(hostId);
1011 yield* writeAgentDeviceConfig(file, ready.agentDevice);
1012 return file;
1013 }).pipe(
1004 Effect.provideService(FileSystem.FileSystem, fs),1014 Effect.provideService(FileSystem.FileSystem, fs),
1005 Effect.provideService(Path.Path, path),1015 Effect.provideService(Path.Path, path),
1006 Effect.mapError(1016 Effect.mapError(
...@@ -1011,9 +1021,7 @@ export const make = Effect.gen(function* () {...@@ -1011,9 +1021,7 @@ export const make = Effect.gen(function* () {
1011 cause,1021 cause,
1012 }),1022 }),
1013 ),1023 ),
1014 Effect.as(file),
1015 );1024 );
1016 };
1017 const probeContext =1025 const probeContext =
1018 yield* Effect.context<Effect.Services<ReturnType<typeof SshDeviceHost.probe>>>();1026 yield* Effect.context<Effect.Services<ReturnType<typeof SshDeviceHost.probe>>>();
1019 const localTargetContext =1027 const localTargetContext =
...@@ -1089,9 +1097,7 @@ export const make = Effect.gen(function* () {...@@ -1089,9 +1097,7 @@ export const make = Effect.gen(function* () {
1089 ({ id, scope }) =>1097 ({ id, scope }) =>
1090 Effect.gen(function* () {1098 Effect.gen(function* () {
1091 yield* Scope.close(scope, Exit.void);1099 yield* Scope.close(scope, Exit.void);
1092 yield* fs1100 yield* fs.remove(yield* configPath(id), { force: true }).pipe(Effect.ignore);
1093 .remove(agentDeviceConfigPath(config.stateDir, id, path), { force: true })
1094 .pipe(Effect.ignore);
1095 }),1101 }),
1096 { concurrency: 4, discard: true },1102 { concurrency: 4, discard: true },
1097 );1103 );
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+54-32
...@@ -32,6 +32,7 @@ import { assert, describe, it } from "@effect/vitest";...@@ -32,6 +32,7 @@ import { assert, describe, it } from "@effect/vitest";
32import * as Context from "effect/Context";32import * as Context from "effect/Context";
33import * as DateTime from "effect/DateTime";33import * as DateTime from "effect/DateTime";
34import * as Deferred from "effect/Deferred";34import * as Deferred from "effect/Deferred";
35import * as Crypto from "effect/Crypto";
35import * as Effect from "effect/Effect";36import * as Effect from "effect/Effect";
36import * as Exit from "effect/Exit";37import * as Exit from "effect/Exit";
37import * as FileSystem from "effect/FileSystem";38import * as FileSystem from "effect/FileSystem";
...@@ -957,6 +958,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => {...@@ -957,6 +958,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => {
957 attachmentsDir,958 attachmentsDir,
958 fileSystem,959 fileSystem,
959 path: yield* Path.Path,960 path: yield* Path.Path,
961 crypto: yield* Crypto.Crypto,
960 idAllocator,962 idAllocator,
961 queryRunner: {963 queryRunner: {
962 allocateSessionId: Effect.succeed("native-thread-claude-accept-edits"),964 allocateSessionId: Effect.succeed("native-thread-claude-accept-edits"),
...@@ -1199,6 +1201,7 @@ describe("ClaudeAdapterV2 resume compaction", () => {...@@ -1199,6 +1201,7 @@ describe("ClaudeAdapterV2 resume compaction", () => {
1199 attachmentsDir,1201 attachmentsDir,
1200 fileSystem,1202 fileSystem,
1201 path: yield* Path.Path,1203 path: yield* Path.Path,
1204 crypto: yield* Crypto.Crypto,
1202 idAllocator,1205 idAllocator,
1203 queryRunner: {1206 queryRunner: {
1204 allocateSessionId: Effect.succeed("native-thread-claude-resume"),1207 allocateSessionId: Effect.succeed("native-thread-claude-resume"),
...@@ -1418,6 +1421,7 @@ describe("ClaudeAdapterV2 attachments", () => {...@@ -1418,6 +1421,7 @@ describe("ClaudeAdapterV2 attachments", () => {
1418 attachmentsDir,1421 attachmentsDir,
1419 fileSystem,1422 fileSystem,
1420 path: yield* Path.Path,1423 path: yield* Path.Path,
1424 crypto: yield* Crypto.Crypto,
1421 idAllocator,1425 idAllocator,
1422 queryRunner: {1426 queryRunner: {
1423 allocateSessionId: Effect.succeed("native-thread-claude-attachments"),1427 allocateSessionId: Effect.succeed("native-thread-claude-attachments"),
...@@ -1558,6 +1562,7 @@ describe("ClaudeAdapterV2 attachments", () => {...@@ -1558,6 +1562,7 @@ describe("ClaudeAdapterV2 attachments", () => {
1558 attachmentsDir,1562 attachmentsDir,
1559 fileSystem,1563 fileSystem,
1560 path: yield* Path.Path,1564 path: yield* Path.Path,
1565 crypto: yield* Crypto.Crypto,
1561 idAllocator,1566 idAllocator,
1562 queryRunner: {1567 queryRunner: {
1563 allocateSessionId: Effect.succeed("native-thread-claude-unsupported-attachment"),1568 allocateSessionId: Effect.succeed("native-thread-claude-unsupported-attachment"),
...@@ -1647,6 +1652,7 @@ describe("ClaudeAdapterV2 native fork", () => {...@@ -1647,6 +1652,7 @@ describe("ClaudeAdapterV2 native fork", () => {
1647 attachmentsDir,1652 attachmentsDir,
1648 fileSystem,1653 fileSystem,
1649 path: yield* Path.Path,1654 path: yield* Path.Path,
1655 crypto: yield* Crypto.Crypto,
1650 idAllocator,1656 idAllocator,
1651 queryRunner: {1657 queryRunner: {
1652 allocateSessionId: Effect.succeed("source-native-session"),1658 allocateSessionId: Effect.succeed("source-native-session"),
...@@ -1819,6 +1825,7 @@ describe("ClaudeAdapterV2 native session identity", () => {...@@ -1819,6 +1825,7 @@ describe("ClaudeAdapterV2 native session identity", () => {
1819 attachmentsDir,1825 attachmentsDir,
1820 fileSystem,1826 fileSystem,
1821 path: yield* Path.Path,1827 path: yield* Path.Path,
1828 crypto: yield* Crypto.Crypto,
1822 idAllocator,1829 idAllocator,
1823 queryRunner: {1830 queryRunner: {
1824 allocateSessionId: Effect.succeed("native-session-identity"),1831 allocateSessionId: Effect.succeed("native-session-identity"),
...@@ -2108,6 +2115,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2108,6 +2115,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2108 attachmentsDir,2115 attachmentsDir,
2109 fileSystem,2116 fileSystem,
2110 path: yield* Path.Path,2117 path: yield* Path.Path,
2118 crypto: yield* Crypto.Crypto,
2111 idAllocator,2119 idAllocator,
2112 continuationRequests: {2120 continuationRequests: {
2113 offer: (request) =>2121 offer: (request) =>
...@@ -3892,6 +3900,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -3892,6 +3900,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
3892 attachmentsDir,3900 attachmentsDir,
3893 fileSystem,3901 fileSystem,
3894 path: yield* Path.Path,3902 path: yield* Path.Path,
3903 crypto: yield* Crypto.Crypto,
3895 idAllocator,3904 idAllocator,
3896 continuationRequests: {3905 continuationRequests: {
3897 offer: (request) =>3906 offer: (request) =>
...@@ -4137,6 +4146,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4137,6 +4146,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4137 attachmentsDir,4146 attachmentsDir,
4138 fileSystem,4147 fileSystem,
4139 path: yield* Path.Path,4148 path: yield* Path.Path,
4149 crypto: yield* Crypto.Crypto,
4140 idAllocator,4150 idAllocator,
4141 continuationRequests: {4151 continuationRequests: {
4142 offer: () => Effect.void,4152 offer: () => Effect.void,
...@@ -4389,11 +4399,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4389,11 +4399,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4389 const continuationAttempt = RunAttemptId.make("attempt-claude-wake-callback-3");4399 const continuationAttempt = RunAttemptId.make("attempt-claude-wake-callback-3");
4390 const planToolUseId = "toolu_01WakePlanExitPlanMode";4400 const planToolUseId = "toolu_01WakePlanExitPlanMode";
4391 const planMarkdown = "# Wake plan\n\n1. Report the background result.";4401 const planMarkdown = "# Wake plan\n\n1. Report the background result.";
4392 const stamp = (frame: SDKMessage, attemptId: RunAttemptId) =>4402 const stamp = Effect.fnUntraced(function* (frame: SDKMessage, attemptId: RunAttemptId) {
4393 claudeSdkFrame({4403 return claudeSdkFrame({
4394 ...frame,4404 ...frame,
4395 user_message_uuid: ClaudeAdapterV2.claudePromptUuid(attemptId),4405 user_message_uuid: yield* ClaudeAdapterV2.claudePromptUuid(attemptId),
4396 });4406 });
4407 });
4397 const runOf = (attemptId: RunAttemptId) => RunId.make(`run-${attemptId}`);4408 const runOf = (attemptId: RunAttemptId) => RunId.make(`run-${attemptId}`);
4398 const planToolUse = claudeSdkFrame({4409 const planToolUse = claudeSdkFrame({
4399 type: "assistant",4410 type: "assistant",
...@@ -4446,10 +4457,10 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4446,10 +4457,10 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4446 runtimePolicy,4457 runtimePolicy,
4447 }),4458 }),
4448 );4459 );
4449 yield* Queue.offer(harness.sdkMessages, stamp(wakeTaskStarted, firstAttempt));4460 yield* Queue.offer(harness.sdkMessages, yield* stamp(wakeTaskStarted, firstAttempt));
4450 yield* Queue.offer(4461 yield* Queue.offer(
4451 harness.sdkMessages,4462 harness.sdkMessages,
4452 stamp(4463 yield* stamp(
4453 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000740", result: "STARTED" }),4464 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000740", result: "STARTED" }),
4454 firstAttempt,4465 firstAttempt,
4455 ),4466 ),
...@@ -4508,7 +4519,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4508,7 +4519,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4508 // The prompt's own turn follows and echoes its uuid.4519 // The prompt's own turn follows and echoes its uuid.
4509 yield* Queue.offer(4520 yield* Queue.offer(
4510 harness.sdkMessages,4521 harness.sdkMessages,
4511 stamp(4522 yield* stamp(
4512 makeAssistantTextFrame({4523 makeAssistantTextFrame({
4513 uuid: "00000000-0000-4000-8000-000000000743",4524 uuid: "00000000-0000-4000-8000-000000000743",
4514 text: "USER_REPLY",4525 text: "USER_REPLY",
...@@ -4518,7 +4529,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4518,7 +4529,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4518 );4529 );
4519 yield* Queue.offer(4530 yield* Queue.offer(
4520 harness.sdkMessages,4531 harness.sdkMessages,
4521 stamp(4532 yield* stamp(
4522 makeResultFrame({4533 makeResultFrame({
4523 uuid: "00000000-0000-4000-8000-000000000744",4534 uuid: "00000000-0000-4000-8000-000000000744",
4524 result: "USER_REPLY",4535 result: "USER_REPLY",
...@@ -4688,11 +4699,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4688,11 +4699,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4688 const firstAttempt = RunAttemptId.make("attempt-claude-wake-approval-1");4699 const firstAttempt = RunAttemptId.make("attempt-claude-wake-approval-1");
4689 const userAttempt = RunAttemptId.make("attempt-claude-wake-approval-2");4700 const userAttempt = RunAttemptId.make("attempt-claude-wake-approval-2");
4690 const bashToolUseId = "toolu_01WakeApprovalBash";4701 const bashToolUseId = "toolu_01WakeApprovalBash";
4691 const stamp = (frame: SDKMessage, attemptId: RunAttemptId) =>4702 const stamp = Effect.fnUntraced(function* (frame: SDKMessage, attemptId: RunAttemptId) {
4692 claudeSdkFrame({4703 return claudeSdkFrame({
4693 ...frame,4704 ...frame,
4694 user_message_uuid: ClaudeAdapterV2.claudePromptUuid(attemptId),4705 user_message_uuid: yield* ClaudeAdapterV2.claudePromptUuid(attemptId),
4695 });4706 });
4707 });
46964708
4697 yield* harness.runtime.startTurn(4709 yield* harness.runtime.startTurn(
4698 makeClaudeTestTurnInput({4710 makeClaudeTestTurnInput({
...@@ -4707,14 +4719,14 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4707,14 +4719,14 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4707 );4719 );
4708 yield* Queue.offer(4720 yield* Queue.offer(
4709 harness.sdkMessages,4721 harness.sdkMessages,
4710 stamp(4722 yield* stamp(
4711 makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000780", text: "One." }),4723 makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000780", text: "One." }),
4712 firstAttempt,4724 firstAttempt,
4713 ),4725 ),
4714 );4726 );
4715 yield* Queue.offer(4727 yield* Queue.offer(
4716 harness.sdkMessages,4728 harness.sdkMessages,
4717 stamp(4729 yield* stamp(
4718 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000781", result: "One." }),4730 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000781", result: "One." }),
4719 firstAttempt,4731 firstAttempt,
4720 ),4732 ),
...@@ -4811,11 +4823,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4811,11 +4823,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4811 const now = yield* DateTime.now;4823 const now = yield* DateTime.now;
4812 const firstAttempt = RunAttemptId.make("attempt-claude-echo-debris-1");4824 const firstAttempt = RunAttemptId.make("attempt-claude-echo-debris-1");
4813 const secondAttempt = RunAttemptId.make("attempt-claude-echo-debris-2");4825 const secondAttempt = RunAttemptId.make("attempt-claude-echo-debris-2");
4814 const stamp = (frame: SDKMessage, attemptId: RunAttemptId) =>4826 const stamp = Effect.fnUntraced(function* (frame: SDKMessage, attemptId: RunAttemptId) {
4815 claudeSdkFrame({4827 return claudeSdkFrame({
4816 ...frame,4828 ...frame,
4817 user_message_uuid: ClaudeAdapterV2.claudePromptUuid(attemptId),4829 user_message_uuid: yield* ClaudeAdapterV2.claudePromptUuid(attemptId),
4818 });4830 });
4831 });
48194832
4820 yield* harness.runtime.startTurn(4833 yield* harness.runtime.startTurn(
4821 makeClaudeTestTurnInput({4834 makeClaudeTestTurnInput({
...@@ -4830,14 +4843,14 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4830,14 +4843,14 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4830 // The first turn echoes on its first frame: this process echoes early.4843 // The first turn echoes on its first frame: this process echoes early.
4831 yield* Queue.offer(4844 yield* Queue.offer(
4832 harness.sdkMessages,4845 harness.sdkMessages,
4833 stamp(4846 yield* stamp(
4834 makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000760", text: "One." }),4847 makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000760", text: "One." }),
4835 firstAttempt,4848 firstAttempt,
4836 ),4849 ),
4837 );4850 );
4838 yield* Queue.offer(4851 yield* Queue.offer(
4839 harness.sdkMessages,4852 harness.sdkMessages,
4840 stamp(4853 yield* stamp(
4841 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000761", result: "One." }),4854 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000761", result: "One." }),
4842 firstAttempt,4855 firstAttempt,
4843 ),4856 ),
...@@ -4864,14 +4877,14 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4864,14 +4877,14 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4864 yield* Queue.offer(harness.sdkMessages, staleTaskNotificationResult);4877 yield* Queue.offer(harness.sdkMessages, staleTaskNotificationResult);
4865 yield* Queue.offer(4878 yield* Queue.offer(
4866 harness.sdkMessages,4879 harness.sdkMessages,
4867 stamp(4880 yield* stamp(
4868 makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000762", text: "Two." }),4881 makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000762", text: "Two." }),
4869 secondAttempt,4882 secondAttempt,
4870 ),4883 ),
4871 );4884 );
4872 yield* Queue.offer(4885 yield* Queue.offer(
4873 harness.sdkMessages,4886 harness.sdkMessages,
4874 stamp(4887 yield* stamp(
4875 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000763", result: "Two." }),4888 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000763", result: "Two." }),
4876 secondAttempt,4889 secondAttempt,
4877 ),4890 ),
...@@ -4909,11 +4922,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4909,11 +4922,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4909 const now = yield* DateTime.now;4922 const now = yield* DateTime.now;
4910 const firstAttempt = RunAttemptId.make("attempt-claude-echo-1");4923 const firstAttempt = RunAttemptId.make("attempt-claude-echo-1");
4911 const secondAttempt = RunAttemptId.make("attempt-claude-echo-2");4924 const secondAttempt = RunAttemptId.make("attempt-claude-echo-2");
4912 const stamp = (frame: SDKMessage, attemptId: RunAttemptId) =>4925 const stamp = Effect.fnUntraced(function* (frame: SDKMessage, attemptId: RunAttemptId) {
4913 claudeSdkFrame({4926 return claudeSdkFrame({
4914 ...frame,4927 ...frame,
4915 user_message_uuid: ClaudeAdapterV2.claudePromptUuid(attemptId),4928 user_message_uuid: yield* ClaudeAdapterV2.claudePromptUuid(attemptId),
4916 });4929 });
4930 });
4917 const assistantTexts = () =>4931 const assistantTexts = () =>
4918 harness.events.flatMap((event) =>4932 harness.events.flatMap((event) =>
4919 event.type === "message.updated" && event.message.role === "assistant"4933 event.type === "message.updated" && event.message.role === "assistant"
...@@ -4935,18 +4949,18 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4935,18 +4949,18 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4935 );4949 );
4936 assert.equal(4950 assert.equal(
4937 harness.offeredMessages[0]?.uuid,4951 harness.offeredMessages[0]?.uuid,
4938 ClaudeAdapterV2.claudePromptUuid(firstAttempt),4952 yield* ClaudeAdapterV2.claudePromptUuid(firstAttempt),
4939 );4953 );
4940 yield* Queue.offer(4954 yield* Queue.offer(
4941 harness.sdkMessages,4955 harness.sdkMessages,
4942 stamp(4956 yield* stamp(
4943 makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000701", text: "One." }),4957 makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000701", text: "One." }),
4944 firstAttempt,4958 firstAttempt,
4945 ),4959 ),
4946 );4960 );
4947 yield* Queue.offer(4961 yield* Queue.offer(
4948 harness.sdkMessages,4962 harness.sdkMessages,
4949 stamp(4963 yield* stamp(
4950 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000702", result: "One." }),4964 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000702", result: "One." }),
4951 firstAttempt,4965 firstAttempt,
4952 ),4966 ),
...@@ -5995,11 +6009,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -5995,11 +6009,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
5995 const userAttempt = RunAttemptId.make("attempt-claude-wake-subagent-2");6009 const userAttempt = RunAttemptId.make("attempt-claude-wake-subagent-2");
5996 const continuationAttempt = RunAttemptId.make("attempt-claude-wake-subagent-3");6010 const continuationAttempt = RunAttemptId.make("attempt-claude-wake-subagent-3");
5997 const runOf = (attemptId: RunAttemptId) => RunId.make(`run-${attemptId}`);6011 const runOf = (attemptId: RunAttemptId) => RunId.make(`run-${attemptId}`);
5998 const stamp = (frame: SDKMessage, attemptId: RunAttemptId) =>6012 const stamp = Effect.fnUntraced(function* (frame: SDKMessage, attemptId: RunAttemptId) {
5999 claudeSdkFrame({6013 return claudeSdkFrame({
6000 ...frame,6014 ...frame,
6001 user_message_uuid: ClaudeAdapterV2.claudePromptUuid(attemptId),6015 user_message_uuid: yield* ClaudeAdapterV2.claudePromptUuid(attemptId),
6002 });6016 });
6017 });
60036018
6004 yield* harness.runtime.startTurn(6019 yield* harness.runtime.startTurn(
6005 makeClaudeTestTurnInput({6020 makeClaudeTestTurnInput({
...@@ -6011,10 +6026,10 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -6011,10 +6026,10 @@ describe("ClaudeAdapterV2 background wake turns", () => {
6011 attachments: [],6026 attachments: [],
6012 }),6027 }),
6013 );6028 );
6014 yield* Queue.offer(harness.sdkMessages, stamp(wakeTaskStarted, firstAttempt));6029 yield* Queue.offer(harness.sdkMessages, yield* stamp(wakeTaskStarted, firstAttempt));
6015 yield* Queue.offer(6030 yield* Queue.offer(
6016 harness.sdkMessages,6031 harness.sdkMessages,
6017 stamp(6032 yield* stamp(
6018 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000770", result: "STARTED" }),6033 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000770", result: "STARTED" }),
6019 firstAttempt,6034 firstAttempt,
6020 ),6035 ),
...@@ -6083,14 +6098,14 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -6083,14 +6098,14 @@ describe("ClaudeAdapterV2 background wake turns", () => {
6083 session_id: WAKE_NATIVE_SESSION,6098 session_id: WAKE_NATIVE_SESSION,
6084 }),6099 }),
6085 wakeResult,6100 wakeResult,
6086 stamp(6101 yield* stamp(
6087 makeAssistantTextFrame({6102 makeAssistantTextFrame({
6088 uuid: "00000000-0000-4000-8000-000000000776",6103 uuid: "00000000-0000-4000-8000-000000000776",
6089 text: "USER_REPLY",6104 text: "USER_REPLY",
6090 }),6105 }),
6091 userAttempt,6106 userAttempt,
6092 ),6107 ),
6093 stamp(6108 yield* stamp(
6094 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000777", result: "USER_REPLY" }),6109 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000777", result: "USER_REPLY" }),
6095 userAttempt,6110 userAttempt,
6096 ),6111 ),
...@@ -7018,6 +7033,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -7018,6 +7033,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
7018 attachmentsDir,7033 attachmentsDir,
7019 fileSystem,7034 fileSystem,
7020 path: yield* Path.Path,7035 path: yield* Path.Path,
7036 crypto: yield* Crypto.Crypto,
7021 idAllocator,7037 idAllocator,
7022 continuationRequests: {7038 continuationRequests: {
7023 offer: (request) =>7039 offer: (request) =>
...@@ -7201,6 +7217,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -7201,6 +7217,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
7201 attachmentsDir,7217 attachmentsDir,
7202 fileSystem,7218 fileSystem,
7203 path: yield* Path.Path,7219 path: yield* Path.Path,
7220 crypto: yield* Crypto.Crypto,
7204 idAllocator,7221 idAllocator,
7205 continuationRequests: {7222 continuationRequests: {
7206 offer: (request) =>7223 offer: (request) =>
...@@ -7436,6 +7453,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -7436,6 +7453,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
7436 attachmentsDir,7453 attachmentsDir,
7437 fileSystem,7454 fileSystem,
7438 path: yield* Path.Path,7455 path: yield* Path.Path,
7456 crypto: yield* Crypto.Crypto,
7439 idAllocator,7457 idAllocator,
7440 continuationRequests: {7458 continuationRequests: {
7441 offer: (request) =>7459 offer: (request) =>
...@@ -7628,6 +7646,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -7628,6 +7646,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
7628 attachmentsDir,7646 attachmentsDir,
7629 fileSystem,7647 fileSystem,
7630 path: yield* Path.Path,7648 path: yield* Path.Path,
7649 crypto: yield* Crypto.Crypto,
7631 idAllocator,7650 idAllocator,
7632 continuationRequests: { offer: () => Effect.void },7651 continuationRequests: { offer: () => Effect.void },
7633 queryRunner: {7652 queryRunner: {
...@@ -7794,6 +7813,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -7794,6 +7813,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
7794 attachmentsDir,7813 attachmentsDir,
7795 fileSystem,7814 fileSystem,
7796 path: yield* Path.Path,7815 path: yield* Path.Path,
7816 crypto: yield* Crypto.Crypto,
7797 idAllocator,7817 idAllocator,
7798 continuationRequests: {7818 continuationRequests: {
7799 offer: () => Effect.void,7819 offer: () => Effect.void,
...@@ -7923,6 +7943,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -7923,6 +7943,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
7923 attachmentsDir,7943 attachmentsDir,
7924 fileSystem,7944 fileSystem,
7925 path: yield* Path.Path,7945 path: yield* Path.Path,
7946 crypto: yield* Crypto.Crypto,
7926 idAllocator,7947 idAllocator,
7927 continuationRequests: {7948 continuationRequests: {
7928 offer: (request) =>7949 offer: (request) =>
...@@ -8117,6 +8138,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -8117,6 +8138,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
8117 attachmentsDir,8138 attachmentsDir,
8118 fileSystem,8139 fileSystem,
8119 path: yield* Path.Path,8140 path: yield* Path.Path,
8141 crypto: yield* Crypto.Crypto,
8120 idAllocator,8142 idAllocator,
8121 continuationRequests: {8143 continuationRequests: {
8122 offer: () => Effect.void,8144 offer: () => Effect.void,
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts+5-1
...@@ -1580,7 +1580,11 @@ async function recordClaudeStreamingQuery(input: {...@@ -1580,7 +1580,11 @@ async function recordClaudeStreamingQuery(input: {
1580 // Like the adapter, give each prompt a uuid Claude echoes on its turn.1580 // Like the adapter, give each prompt a uuid Claude echoes on its turn.
1581 const message = ClaudeAdapterV2.makeClaudeUserMessage({1581 const message = ClaudeAdapterV2.makeClaudeUserMessage({
1582 text: prompt,1582 text: prompt,
1583 uuid: ClaudeAdapterV2.claudePromptUuid(`${input.sessionId}:prompt:${index + 1}`),1583 uuid: await Effect.runPromise(
1584 ClaudeAdapterV2.claudePromptUuid(`${input.sessionId}:prompt:${index + 1}`).pipe(
1585 Effect.provide(NodeServices.layer),
1586 ),
1587 ),
1584 });1588 });
1585 input.entries.push({1589 input.entries.push({
1586 type: "expect_outbound",1590 type: "expect_outbound",
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+39-24
...@@ -1,5 +1,3 @@...@@ -1,5 +1,3 @@
1import * as NodeCrypto from "node:crypto";
2
3import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts";1import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts";
4import {2import {
5 dynamicToolTitle,3 dynamicToolTitle,
...@@ -73,6 +71,7 @@ import {...@@ -73,6 +71,7 @@ import {
73import * as Cause from "effect/Cause";71import * as Cause from "effect/Cause";
74import * as Context from "effect/Context";72import * as Context from "effect/Context";
75import * as Crypto from "effect/Crypto";73import * as Crypto from "effect/Crypto";
74import * as Hex from "effect/encoding/Hex";
76import * as DateTime from "effect/DateTime";75import * as DateTime from "effect/DateTime";
77import * as Deferred from "effect/Deferred";76import * as Deferred from "effect/Deferred";
78import * as Effect from "effect/Effect";77import * as Effect from "effect/Effect";
...@@ -1420,11 +1419,17 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input:...@@ -1420,11 +1419,17 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input:
14201419
1421// Stable per run attempt, so a replayed prompt offer matches its recording.1420// Stable per run attempt, so a replayed prompt offer matches its recording.
1422// Claude echoes it back as user_message_uuid on the turn that answers it.1421// Claude echoes it back as user_message_uuid on the turn that answers it.
1423export function claudePromptUuid(attemptId: string): NonNullable<SDKUserMessage["uuid"]> {1422export const claudePromptUuid = Effect.fn("claudePromptUuid")(function* (attemptId: string) {
1424 const hex = NodeCrypto.createHash("sha256").update(`t3-claude-prompt:${attemptId}`).digest("hex");1423 const crypto = yield* Crypto.Crypto;
1424 const digest = yield* crypto
1425 .digest("SHA-256", new TextEncoder().encode(`t3-claude-prompt:${attemptId}`))
1426 .pipe(Effect.orDie);
1427 const hex = Hex.encode(digest);
1425 const variant = ((Number.parseInt(hex[16]!, 16) & 0x3) | 0x8).toString(16);1428 const variant = ((Number.parseInt(hex[16]!, 16) & 0x3) | 0x8).toString(16);
1426 return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${variant}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;1429 const uuid: NonNullable<SDKUserMessage["uuid"]> =
1427}1430 `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${variant}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
1431 return uuid;
1432});
14281433
1429type ClaudeAssistantContentBlock = SDKAssistantMessage["message"]["content"][number];1434type ClaudeAssistantContentBlock = SDKAssistantMessage["message"]["content"][number];
1430type ClaudeToolUseContentBlock = Extract<1435type ClaudeToolUseContentBlock = Extract<
...@@ -2998,6 +3003,7 @@ export interface ClaudeAdapterV2Options {...@@ -2998,6 +3003,7 @@ export interface ClaudeAdapterV2Options {
2998 readonly attachmentsDir: string;3003 readonly attachmentsDir: string;
2999 readonly fileSystem: FileSystem.FileSystem;3004 readonly fileSystem: FileSystem.FileSystem;
3000 readonly path: Path.Path;3005 readonly path: Path.Path;
3006 readonly crypto: Crypto.Crypto;
3001 readonly idAllocator: IdAllocator.IdAllocatorV2Shape;3007 readonly idAllocator: IdAllocator.IdAllocatorV2Shape;
3002 readonly queryRunner: ClaudeAgentSdkQueryRunnerShape;3008 readonly queryRunner: ClaudeAgentSdkQueryRunnerShape;
3003 readonly scopedLimitNames?: Ref.Ref<ClaudeScopedLimitNames>;3009 readonly scopedLimitNames?: Ref.Ref<ClaudeScopedLimitNames>;
...@@ -3013,7 +3019,7 @@ export interface ClaudeAdapterV2Options {...@@ -3013,7 +3019,7 @@ export interface ClaudeAdapterV2Options {
3013export function makeClaudeAdapterV2(3019export function makeClaudeAdapterV2(
3014 adapterOptions: ClaudeAdapterV2Options,3020 adapterOptions: ClaudeAdapterV2Options,
3015): ProviderAdapter.ProviderAdapterV2Shape {3021): ProviderAdapter.ProviderAdapterV2Shape {
3016 const { attachmentsDir, fileSystem, path, idAllocator, queryRunner } = adapterOptions;3022 const { attachmentsDir, fileSystem, path, crypto, idAllocator, queryRunner } = adapterOptions;
3017 const continuationRequests = adapterOptions.continuationRequests ?? {3023 const continuationRequests = adapterOptions.continuationRequests ?? {
3018 offer: () => Effect.void,3024 offer: () => Effect.void,
3019 };3025 };
...@@ -7242,6 +7248,11 @@ export function makeClaudeAdapterV2(...@@ -7242,6 +7248,11 @@ export function makeClaudeAdapterV2(
7242 const startedAt = yield* DateTime.now;7248 const startedAt = yield* DateTime.now;
7243 const nativeThreadId = yield* getNativeThreadId(turnInput.providerThread);7249 const nativeThreadId = yield* getNativeThreadId(turnInput.providerThread);
7244 const nativeTurnId = `turn:${turnInput.attemptId}`;7250 const nativeTurnId = `turn:${turnInput.attemptId}`;
7251 const promptUuid = isClaudeProviderContinuationTurn(turnInput)
7252 ? null
7253 : yield* claudePromptUuid(turnInput.attemptId).pipe(
7254 Effect.provideService(Crypto.Crypto, crypto),
7255 );
7245 const providerTurnId = idAllocator.derive.providerTurn({7256 const providerTurnId = idAllocator.derive.providerTurn({
7246 driver: CLAUDE_PROVIDER,7257 driver: CLAUDE_PROVIDER,
7247 nativeTurnId,7258 nativeTurnId,
...@@ -7298,9 +7309,7 @@ export function makeClaudeAdapterV2(...@@ -7298,9 +7309,7 @@ export function makeClaudeAdapterV2(
7298 subagentsByToolUseId: new Map(),7309 subagentsByToolUseId: new Map(),
7299 subagentNodesByTaskId: new Map(),7310 subagentNodesByTaskId: new Map(),
7300 pendingSubagentLaunchesByToolUseId: new Map(),7311 pendingSubagentLaunchesByToolUseId: new Map(),
7301 promptUuid: isClaudeProviderContinuationTurn(turnInput)7312 promptUuid,
7302 ? null
7303 : claudePromptUuid(turnInput.attemptId),
7304 promptEcho: isClaudeProviderContinuationTurn(turnInput) ? "confirmed" : "pending",7313 promptEcho: isClaudeProviderContinuationTurn(turnInput) ? "confirmed" : "pending",
7305 gatedFramesBeforeEcho: 0,7314 gatedFramesBeforeEcho: 0,
7306 heldRootFrames: [],7315 heldRootFrames: [],
...@@ -7309,20 +7318,20 @@ export function makeClaudeAdapterV2(...@@ -7309,20 +7318,20 @@ export function makeClaudeAdapterV2(
7309 // produced instead of prompting it again: drain the buffered wake7318 // produced instead of prompting it again: drain the buffered wake
7310 // messages into this turn and let any still-streaming messages7319 // messages into this turn and let any still-streaming messages
7311 // follow live. The continuation prompt text never reaches the CLI.7320 // follow live. The continuation prompt text never reaches the CLI.
7312 const isContinuationTurn = context.promptUuid === null;7321 const userMessage =
7313 const userMessage = isContinuationTurn7322 promptUuid === null
7314 ? null7323 ? null
7315 : yield* makeClaudeUserMessageWithAttachments({7324 : yield* makeClaudeUserMessageWithAttachments({
7316 text: applyClaudePromptEffortPrefix(7325 text: applyClaudePromptEffortPrefix(
7317 turnInput.message.text,7326 turnInput.message.text,
7318 compileClaudeModelSelection(turnInput.modelSelection).promptEffort,7327 compileClaudeModelSelection(turnInput.modelSelection).promptEffort,
7319 ),7328 ),
7320 attachments: turnInput.message.attachments,7329 attachments: turnInput.message.attachments,
7321 attachmentsDir,7330 attachmentsDir,
7322 fileSystem,7331 fileSystem,
7323 skillNames: yield* userInvocableSkillNames(turnInput.runtimePolicy.cwd),7332 skillNames: yield* userInvocableSkillNames(turnInput.runtimePolicy.cwd),
7324 uuid: claudePromptUuid(turnInput.attemptId),7333 uuid: promptUuid,
7325 });7334 });
7326 const querySession = yield* openQuery(turnInput, nativeThreadId);7335 const querySession = yield* openQuery(turnInput, nativeThreadId);
7327 yield* Ref.set(activeTurn, context);7336 yield* Ref.set(activeTurn, context);
7328 yield* emitProviderEvent({7337 yield* emitProviderEvent({
...@@ -7906,6 +7915,7 @@ export function makeClaudeAdapterV2(...@@ -7906,6 +7915,7 @@ export function makeClaudeAdapterV2(
79067915
7907export type ClaudeAdapterV2DriverEnv =7916export type ClaudeAdapterV2DriverEnv =
7908 | ClaudeAgentSdkQueryRunner7917 | ClaudeAgentSdkQueryRunner
7918 | Crypto.Crypto
7909 | FileSystem.FileSystem7919 | FileSystem.FileSystem
7910 | IdAllocator.IdAllocatorV27920 | IdAllocator.IdAllocatorV2
7911 | Path.Path7921 | Path.Path
...@@ -7926,6 +7936,7 @@ export const createClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2Driver.create")(...@@ -7926,6 +7936,7 @@ export const createClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2Driver.create")(
7926 const baseEnvironment = mergeProviderInstanceEnvironment(environment, hostEnvironment);7936 const baseEnvironment = mergeProviderInstanceEnvironment(environment, hostEnvironment);
7927 const claudeEnvironment = yield* makeClaudeEnvironment(config, baseEnvironment);7937 const claudeEnvironment = yield* makeClaudeEnvironment(config, baseEnvironment);
7928 const path = yield* Path.Path;7938 const path = yield* Path.Path;
7939 const crypto = yield* Crypto.Crypto;
7929 const binaryPath = yield* resolveClaudeSdkExecutablePath(7940 const binaryPath = yield* resolveClaudeSdkExecutablePath(
7930 expandHomePath(config.binaryPath),7941 expandHomePath(config.binaryPath),
7931 claudeEnvironment,7942 claudeEnvironment,
...@@ -7937,6 +7948,7 @@ export const createClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2Driver.create")(...@@ -7937,6 +7948,7 @@ export const createClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2Driver.create")(
7937 attachmentsDir: serverConfig.attachmentsDir,7948 attachmentsDir: serverConfig.attachmentsDir,
7938 fileSystem,7949 fileSystem,
7939 path,7950 path,
7951 crypto,
7940 idAllocator,7952 idAllocator,
7941 queryRunner,7953 queryRunner,
7942 continuationRequests,7954 continuationRequests,
...@@ -7970,6 +7982,7 @@ export const ClaudeAdapterV2Driver: ProviderAdapterDriver<...@@ -7970,6 +7982,7 @@ export const ClaudeAdapterV2Driver: ProviderAdapterDriver<
7970const makeDefaultClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2.layer")(function* () {7982const makeDefaultClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2.layer")(function* () {
7971 const fileSystem = yield* FileSystem.FileSystem;7983 const fileSystem = yield* FileSystem.FileSystem;
7972 const path = yield* Path.Path;7984 const path = yield* Path.Path;
7985 const crypto = yield* Crypto.Crypto;
7973 const hostEnvironment = yield* HostProcessEnvironment;7986 const hostEnvironment = yield* HostProcessEnvironment;
7974 const idAllocator = yield* IdAllocator.IdAllocatorV2;7987 const idAllocator = yield* IdAllocator.IdAllocatorV2;
7975 const queryRunner = yield* ClaudeAgentSdkQueryRunner;7988 const queryRunner = yield* ClaudeAgentSdkQueryRunner;
...@@ -7983,6 +7996,7 @@ const makeDefaultClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2.layer")(function*...@@ -7983,6 +7996,7 @@ const makeDefaultClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2.layer")(function*
7983 attachmentsDir: serverConfig.attachmentsDir,7996 attachmentsDir: serverConfig.attachmentsDir,
7984 fileSystem,7997 fileSystem,
7985 path,7998 path,
7999 crypto,
7986 idAllocator,8000 idAllocator,
7987 queryRunner,8001 queryRunner,
7988 continuationRequests,8002 continuationRequests,
...@@ -7993,6 +8007,7 @@ const layer: Layer.Layer<...@@ -7993,6 +8007,7 @@ const layer: Layer.Layer<
7993 ProviderAdapter.ProviderAdapterV2,8007 ProviderAdapter.ProviderAdapterV2,
7994 never,8008 never,
7995 | ClaudeAgentSdkQueryRunner8009 | ClaudeAgentSdkQueryRunner
8010 | Crypto.Crypto
7996 | FileSystem.FileSystem8011 | FileSystem.FileSystem
7997 | IdAllocator.IdAllocatorV28012 | IdAllocator.IdAllocatorV2
7998 | Path.Path8013 | Path.Path
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts+175-160
...@@ -35,6 +35,7 @@ import * as CodexClient from "effect-codex-app-server/client";...@@ -35,6 +35,7 @@ import * as CodexClient from "effect-codex-app-server/client";
35import * as CodexReplay from "effect-codex-app-server/replay";35import * as CodexReplay from "effect-codex-app-server/replay";
36import * as DateTime from "effect/DateTime";36import * as DateTime from "effect/DateTime";
37import * as Deferred from "effect/Deferred";37import * as Deferred from "effect/Deferred";
38import * as Crypto from "effect/Crypto";
38import * as Effect from "effect/Effect";39import * as Effect from "effect/Effect";
39import * as Predicate from "effect/Predicate";40import * as Predicate from "effect/Predicate";
40import * as FileSystem from "effect/FileSystem";41import * as FileSystem from "effect/FileSystem";
...@@ -801,183 +802,195 @@ describe("CodexAdapterV2 process spawning", () => {...@@ -801,183 +802,195 @@ describe("CodexAdapterV2 process spawning", () => {
801});802});
802803
803describe("CodexAdapterV2 dynamic tool projection", () => {804describe("CodexAdapterV2 dynamic tool projection", () => {
804 it("uses the CUA call title while leaving other MCP titles as tool arguments", () => {805 it.effect("uses the CUA call title while leaving other MCP titles as tool arguments", () =>
805 const call = {806 Effect.gen(function* () {
806 type: "mcpToolCall" as const,807 const call = {
807 id: "inspect",808 type: "mcpToolCall" as const,
808 server: "cua_repl",809 id: "inspect",
809 tool: "js",810 server: "cua_repl",
810 status: "completed" as const,811 tool: "js",
811 arguments: {812 status: "completed" as const,
812 code: "await game.getAXStateAndScreenshot();",813 arguments: {
813 title: "Inspect Saga music screen",814 code: "await game.getAXStateAndScreenshot();",
814 },815 title: "Inspect Saga music screen",
815 result: { content: [] },816 },
816 };817 result: { content: [] },
817 assert.equal(818 };
818 CodexAdapterV2.projectCodexDynamicToolItem(call).title,819 assert.equal(
819 "Inspect Saga music screen",820 (yield* CodexAdapterV2.projectCodexDynamicToolItem(call)).title,
820 );821 "Inspect Saga music screen",
821 assert.equal(822 );
822 CodexAdapterV2.projectCodexDynamicToolItem({ ...call, arguments: { title: " " } }).title,823 assert.equal(
823 "js",824 (yield* CodexAdapterV2.projectCodexDynamicToolItem({ ...call, arguments: { title: " " } }))
824 );825 .title,
825 assert.equal(826 "js",
826 CodexAdapterV2.projectCodexDynamicToolItem({ ...call, server: "github" }).title,827 );
827 "js",828 assert.equal(
828 );829 (yield* CodexAdapterV2.projectCodexDynamicToolItem({ ...call, server: "github" })).title,
829 });830 "js",
831 );
832 }).pipe(Effect.provide(NodeServices.layer)),
833 );
830834
831 it.each(["inProgress", "completed", "failed"] as const)(835 it.effect.each(["inProgress", "completed", "failed"] as const)(
832 "presents ordinary MCP calls when %s",836 "presents ordinary MCP calls when %s",
833 (status) => {837 (status) =>
834 const projection = CodexAdapterV2.projectCodexDynamicToolItem({838 Effect.gen(function* () {
839 const projection = yield* CodexAdapterV2.projectCodexDynamicToolItem({
840 type: "mcpToolCall",
841 id: "weather-call",
842 server: "weather",
843 tool: "get_weather",
844 status,
845 arguments: { city: "Berlin" },
846 });
847 assert.equal(projection.title, "get weather");
848 assert.deepEqual(projection.toolSource, {
849 key: "mcp:weather",
850 name: "weather",
851 kind: "integration",
852 });
853 assert.deepEqual(projection.input, { city: "Berlin" });
854 }).pipe(Effect.provide(NodeServices.layer)),
855 );
856
857 it.effect("uses Codex connector names without reading a display title from arguments", () =>
858 Effect.gen(function* () {
859 const projection = yield* CodexAdapterV2.projectCodexDynamicToolItem({
835 type: "mcpToolCall",860 type: "mcpToolCall",
836 id: "weather-call",861 id: "connector-call",
837 server: "weather",862 server: "_apps",
838 tool: "get_weather",863 tool: "connector_get_weather",
839 status,864 status: "completed",
840 arguments: { city: "Berlin" },865 arguments: { title: "Argument, not display metadata" },
866 appContext: {
867 connectorId: "weather-app",
868 appName: "Weather",
869 actionName: "Check weather",
870 },
871 result: {
872 content: [],
873 _meta: { source: { logoUrl: "https://example.com/weather.png" } },
874 },
841 });875 });
842 assert.equal(projection.title, "get weather");876 assert.equal(projection.title, "Check weather");
843 assert.deepEqual(projection.toolSource, {877 assert.equal(projection.toolSource?.name, "Weather");
844 key: "mcp:weather",878 assert.deepEqual(projection.toolIcon, {
845 name: "weather",879 _tag: "themed-logo",
846 kind: "integration",880 logoUrl: "https://example.com/weather.png",
847 });881 });
848 assert.deepEqual(projection.input, { city: "Berlin" });882 assert.deepEqual(projection.toolSource?.icon, projection.toolIcon);
849 },883 }).pipe(Effect.provide(NodeServices.layer)),
850 );884 );
851885
852 it("uses Codex connector names without reading a display title from arguments", () => {886 it.effect("preserves native browser and app icons alongside MCP tool output", () =>
853 const projection = CodexAdapterV2.projectCodexDynamicToolItem({887 Effect.gen(function* () {
854 type: "mcpToolCall",888 const browser = yield* CodexAdapterV2.projectCodexDynamicToolItem({
855 id: "connector-call",889 type: "mcpToolCall",
856 server: "_apps",890 id: "browser",
857 tool: "connector_get_weather",891 server: "browser",
858 status: "completed",892 tool: "open",
859 arguments: { title: "Argument, not display metadata" },893 status: "completed",
860 appContext: {894 arguments: {},
861 connectorId: "weather-app",895 result: {
862 appName: "Weather",896 content: [],
863 actionName: "Check weather",897 _meta: {
864 },898 "codex/toolSurface": {
865 result: {899 kind: "browserUse",
866 content: [],900 browserFamily: "Chrome",
867 _meta: { source: { logoUrl: "https://example.com/weather.png" } },901 screenshot: {
868 },902 pageUrl: "https://example.com/docs",
869 });903 faviconUrl: "https://example.com/icon.png",
870 assert.equal(projection.title, "Check weather");904 },
871 assert.equal(projection.toolSource?.name, "Weather");
872 assert.deepEqual(projection.toolIcon, {
873 _tag: "themed-logo",
874 logoUrl: "https://example.com/weather.png",
875 });
876 assert.deepEqual(projection.toolSource?.icon, projection.toolIcon);
877 });
878
879 it("preserves native browser and app icons alongside MCP tool output", () => {
880 const browser = CodexAdapterV2.projectCodexDynamicToolItem({
881 type: "mcpToolCall",
882 id: "browser",
883 server: "browser",
884 tool: "open",
885 status: "completed",
886 arguments: {},
887 result: {
888 content: [],
889 _meta: {
890 "codex/toolSurface": {
891 kind: "browserUse",
892 browserFamily: "Chrome",
893 screenshot: {
894 pageUrl: "https://example.com/docs",
895 faviconUrl: "https://example.com/icon.png",
896 },905 },
897 },906 },
898 },907 },
899 },908 });
900 });909 assert.equal(browser.toolSurface, "browser");
901 assert.equal(browser.toolSurface, "browser");910 assert.deepEqual(browser.toolIcon, {
902 assert.deepEqual(browser.toolIcon, {911 _tag: "website",
903 _tag: "website",912 pageUrl: "https://example.com/docs",
904 pageUrl: "https://example.com/docs",913 faviconUrl: "https://example.com/icon.png",
905 faviconUrl: "https://example.com/icon.png",914 });
906 });915 assert.equal(browser.toolSource?.name, "Chrome");
907 assert.equal(browser.toolSource?.name, "Chrome");916 const app = yield* CodexAdapterV2.projectCodexDynamicToolItem({
908 const app = CodexAdapterV2.projectCodexDynamicToolItem({917 type: "mcpToolCall",
909 type: "mcpToolCall",918 id: "app",
910 id: "app",919 server: "computer",
911 server: "computer",920 tool: "click",
912 tool: "click",921 status: "completed",
913 status: "completed",922 arguments: {},
914 arguments: {},923 result: {
915 result: {924 content: [],
916 content: [],925 _meta: {
917 _meta: {926 "codex/toolSurface": {
918 "codex/toolSurface": {927 kind: "computerUse",
919 kind: "computerUse",928 app: { kind: "appId", appId: "com.apple.finder" },
920 app: { kind: "appId", appId: "com.apple.finder" },929 },
921 },930 },
922 },931 },
923 },932 });
924 });933 assert.deepEqual(app.toolIcon, {
925 assert.deepEqual(app.toolIcon, {934 _tag: "native-app",
926 _tag: "native-app",935 app: { _tag: "app-id", appId: "com.apple.finder" },
927 app: { _tag: "app-id", appId: "com.apple.finder" },936 });
928 });937 assert.equal(app.toolSource?.name, "Finder");
929 assert.equal(app.toolSource?.name, "Finder");938 }).pipe(Effect.provide(NodeServices.layer)),
930 });939 );
931940
932 it("preserves MCP arguments and prefers structured output", () => {941 it.effect("preserves MCP arguments and prefers structured output", () =>
933 const projection = CodexAdapterV2.projectCodexDynamicToolItem({942 Effect.gen(function* () {
934 type: "mcpToolCall",943 const projection = yield* CodexAdapterV2.projectCodexDynamicToolItem({
935 id: "call-create-threads",944 type: "mcpToolCall",
936 server: "t3-code",945 id: "call-create-threads",
937 tool: "create_threads",946 server: "t3-code",
938 status: "completed",947 tool: "create_threads",
939 arguments: {948 status: "completed",
940 threads: [{ title: "Fixture child", prompt: "fixture child prompt" }],949 arguments: {
941 },950 threads: [{ title: "Fixture child", prompt: "fixture child prompt" }],
942 result: {
943 content: [{ type: "text", text: '{"threads":[{"threadId":"thread:mcp:fixture:0"}]}' }],
944 structuredContent: {
945 threads: [{ threadId: "thread:mcp:fixture:0" }],
946 },951 },
947 },952 result: {
948 });953 content: [{ type: "text", text: '{"threads":[{"threadId":"thread:mcp:fixture:0"}]}' }],
954 structuredContent: {
955 threads: [{ threadId: "thread:mcp:fixture:0" }],
956 },
957 },
958 });
949959
950 assert.deepEqual(projection, {960 assert.deepEqual(projection, {
951 toolName: "t3-code.create_threads",961 toolName: "t3-code.create_threads",
952 input: {962 input: {
953 threads: [{ title: "Fixture child", prompt: "fixture child prompt" }],963 threads: [{ title: "Fixture child", prompt: "fixture child prompt" }],
954 },964 },
955 output: {965 output: {
956 threads: [{ threadId: "thread:mcp:fixture:0" }],966 threads: [{ threadId: "thread:mcp:fixture:0" }],
957 },967 },
958 status: "completed",968 status: "completed",
959 });969 });
960 });970 }).pipe(Effect.provide(NodeServices.layer)),
971 );
961972
962 it("preserves namespaced dynamic tool output", () => {973 it.effect("preserves namespaced dynamic tool output", () =>
963 const projection = CodexAdapterV2.projectCodexDynamicToolItem({974 Effect.gen(function* () {
964 type: "dynamicToolCall",975 const projection = yield* CodexAdapterV2.projectCodexDynamicToolItem({
965 id: "call-dynamic",976 type: "dynamicToolCall",
966 namespace: "workspace",977 id: "call-dynamic",
967 tool: "inspect",978 namespace: "workspace",
968 status: "failed",979 tool: "inspect",
969 arguments: { path: "package.json" },980 status: "failed",
970 contentItems: [{ type: "inputText", text: "inspection failed" }],981 arguments: { path: "package.json" },
971 success: false,982 contentItems: [{ type: "inputText", text: "inspection failed" }],
972 });983 success: false,
984 });
973985
974 assert.deepEqual(projection, {986 assert.deepEqual(projection, {
975 toolName: "workspace.inspect",987 toolName: "workspace.inspect",
976 input: { path: "package.json" },988 input: { path: "package.json" },
977 output: [{ type: "inputText", text: "inspection failed" }],989 output: [{ type: "inputText", text: "inspection failed" }],
978 status: "failed",990 status: "failed",
979 });991 });
980 });992 }).pipe(Effect.provide(NodeServices.layer)),
993 );
981});994});
982995
983describe("CodexAdapterV2 native protocol logging", () => {996describe("CodexAdapterV2 native protocol logging", () => {
...@@ -1703,6 +1716,7 @@ describe("CodexAdapterV2 session initialize", () => {...@@ -1703,6 +1716,7 @@ describe("CodexAdapterV2 session initialize", () => {
1703 ),1716 ),
1704 ),1717 ),
1705 },1718 },
1719 crypto: yield* Crypto.Crypto,
1706 fileSystem: yield* FileSystem.FileSystem,1720 fileSystem: yield* FileSystem.FileSystem,
1707 idAllocator: yield* IdAllocator.IdAllocatorV2,1721 idAllocator: yield* IdAllocator.IdAllocatorV2,
1708 serverConfig: yield* makeReplayServerConfig(transcript.scenario).pipe(Effect.orDie),1722 serverConfig: yield* makeReplayServerConfig(transcript.scenario).pipe(Effect.orDie),
...@@ -1899,6 +1913,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -1899,6 +1913,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
1899 settings: DEFAULT_CODEX_SETTINGS,1913 settings: DEFAULT_CODEX_SETTINGS,
1900 environment: {},1914 environment: {},
1901 clientFactory,1915 clientFactory,
1916 crypto: yield* Crypto.Crypto,
1902 fileSystem,1917 fileSystem,
1903 idAllocator,1918 idAllocator,
1904 serverConfig,1919 serverConfig,
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+35-23
...@@ -66,6 +66,7 @@ import * as CodexClient from "effect-codex-app-server/client";...@@ -66,6 +66,7 @@ import * as CodexClient from "effect-codex-app-server/client";
66import * as CodexErrors from "effect-codex-app-server/errors";66import * as CodexErrors from "effect-codex-app-server/errors";
67import * as CodexSchema from "effect-codex-app-server/schema";67import * as CodexSchema from "effect-codex-app-server/schema";
68import * as Context from "effect/Context";68import * as Context from "effect/Context";
69import * as Crypto from "effect/Crypto";
69import * as DateTime from "effect/DateTime";70import * as DateTime from "effect/DateTime";
70import * as Deferred from "effect/Deferred";71import * as Deferred from "effect/Deferred";
71import * as Effect from "effect/Effect";72import * as Effect from "effect/Effect";
...@@ -479,26 +480,29 @@ function codexDynamicToolOutput(...@@ -479,26 +480,29 @@ function codexDynamicToolOutput(
479 return item.success === false ? { success: false } : undefined;480 return item.success === false ? { success: false } : undefined;
480}481}
481482
482export function projectCodexDynamicToolItem(483export const projectCodexDynamicToolItem = Effect.fn("CodexAdapterV2.projectDynamicToolItem")(
483 item: CodexDynamicToolItem,484 function* (
484): CodexDynamicToolProjection {485 item: CodexDynamicToolItem,
485 const output =486 ): Effect.fn.Return<CodexDynamicToolProjection, never, Crypto.Crypto> {
486 item.type === "mcpToolCall" ? codexMcpToolOutput(item) : codexDynamicToolOutput(item);487 const output =
487 const toolName =488 item.type === "mcpToolCall" ? codexMcpToolOutput(item) : codexDynamicToolOutput(item);
488 item.type === "mcpToolCall"489 const toolName =
489 ? `${item.server}.${item.tool}`490 item.type === "mcpToolCall"
490 : [trimText(item.namespace), item.tool].filter(Boolean).join(".");491 ? `${item.server}.${item.tool}`
491 const presentation = item.type === "mcpToolCall" ? mcpToolPresentation(item) : {};492 : [trimText(item.namespace), item.tool].filter(Boolean).join(".");
492 const title = dynamicToolTitle(toolName, item.arguments) ?? presentation.title;493 const presentation: McpToolPresentation =
493 const projection: CodexDynamicToolProjection = {494 item.type === "mcpToolCall" ? yield* mcpToolPresentation(item) : {};
494 ...presentation,495 const title = dynamicToolTitle(toolName, item.arguments) ?? presentation.title;
495 toolName,496 const projection: CodexDynamicToolProjection = {
496 ...(title ? { title } : {}),497 ...presentation,
497 input: item.arguments,498 toolName,
498 status: codexItemStatus(item.status).turnItem,499 ...(title ? { title } : {}),
499 };500 input: item.arguments,
500 return output === undefined ? projection : { ...projection, output };501 status: codexItemStatus(item.status).turnItem,
501}502 };
503 return output === undefined ? projection : { ...projection, output };
504 },
505);
502506
503function codexNativeItemRef(nativeItemId: string) {507function codexNativeItemRef(nativeItemId: string) {
504 return {508 return {
...@@ -1513,6 +1517,7 @@ export const layerAppServerClientFactory: Layer.Layer<...@@ -1513,6 +1517,7 @@ export const layerAppServerClientFactory: Layer.Layer<
15131517
1514export type CodexAdapterV2DriverEnv =1518export type CodexAdapterV2DriverEnv =
1515 | CodexAppServerClientFactory1519 | CodexAppServerClientFactory
1520 | Crypto.Crypto
1516 | FileSystem.FileSystem1521 | FileSystem.FileSystem
1517 | IdAllocatorV21522 | IdAllocatorV2
1518 | Path.Path1523 | Path.Path
...@@ -1525,6 +1530,7 @@ export const createCodexAdapterV2 = (...@@ -1525,6 +1530,7 @@ export const createCodexAdapterV2 = (
1525 Effect.gen(function* () {1530 Effect.gen(function* () {
1526 const clientFactory = yield* CodexAppServerClientFactory;1531 const clientFactory = yield* CodexAppServerClientFactory;
1527 const continuationRequests = yield* ProviderContinuationRequests;1532 const continuationRequests = yield* ProviderContinuationRequests;
1533 const crypto = yield* Crypto.Crypto;
1528 const fileSystem = yield* FileSystem.FileSystem;1534 const fileSystem = yield* FileSystem.FileSystem;
1529 const hostEnvironment = yield* HostProcessEnvironment;1535 const hostEnvironment = yield* HostProcessEnvironment;
1530 const idAllocator = yield* IdAllocatorV2;1536 const idAllocator = yield* IdAllocatorV2;
...@@ -1555,6 +1561,7 @@ export const createCodexAdapterV2 = (...@@ -1555,6 +1561,7 @@ export const createCodexAdapterV2 = (
1555 settings,1561 settings,
1556 environment: mergeProviderInstanceEnvironment(environment, hostEnvironment),1562 environment: mergeProviderInstanceEnvironment(environment, hostEnvironment),
1557 clientFactory,1563 clientFactory,
1564 crypto,
1558 fileSystem,1565 fileSystem,
1559 idAllocator,1566 idAllocator,
1560 serverConfig,1567 serverConfig,
...@@ -1573,12 +1580,13 @@ export const CodexAdapterV2Driver: ProviderAdapterDriver<CodexSettings, CodexAda...@@ -1573,12 +1580,13 @@ export const CodexAdapterV2Driver: ProviderAdapterDriver<CodexSettings, CodexAda
1573const layer: Layer.Layer<1580const layer: Layer.Layer<
1574 ProviderAdapterV2,1581 ProviderAdapterV2,
1575 never,1582 never,
1576 CodexAppServerClientFactory | FileSystem.FileSystem | IdAllocatorV2 | ServerConfig1583 CodexAppServerClientFactory | Crypto.Crypto | FileSystem.FileSystem | IdAllocatorV2 | ServerConfig
1577> = Layer.effect(1584> = Layer.effect(
1578 ProviderAdapterV2,1585 ProviderAdapterV2,
1579 Effect.gen(function* () {1586 Effect.gen(function* () {
1580 const clientFactory = yield* CodexAppServerClientFactory;1587 const clientFactory = yield* CodexAppServerClientFactory;
1581 const continuationRequests = yield* ProviderContinuationRequests;1588 const continuationRequests = yield* ProviderContinuationRequests;
1589 const crypto = yield* Crypto.Crypto;
1582 const fileSystem = yield* FileSystem.FileSystem;1590 const fileSystem = yield* FileSystem.FileSystem;
1583 const hostEnvironment = yield* HostProcessEnvironment;1591 const hostEnvironment = yield* HostProcessEnvironment;
1584 const idAllocator = yield* IdAllocatorV2;1592 const idAllocator = yield* IdAllocatorV2;
...@@ -1589,6 +1597,7 @@ const layer: Layer.Layer<...@@ -1589,6 +1597,7 @@ const layer: Layer.Layer<
1589 settings: DEFAULT_CODEX_SETTINGS,1597 settings: DEFAULT_CODEX_SETTINGS,
1590 environment: hostEnvironment,1598 environment: hostEnvironment,
1591 clientFactory,1599 clientFactory,
1600 crypto,
1592 fileSystem,1601 fileSystem,
1593 idAllocator,1602 idAllocator,
1594 serverConfig,1603 serverConfig,
...@@ -1609,6 +1618,7 @@ export interface CodexAdapterV2Options {...@@ -1609,6 +1618,7 @@ export interface CodexAdapterV2Options {
1609 * Codex with a current access token.1618 * Codex with a current access token.
1610 */1619 */
1611 readonly resolveRuntime?: Effect.Effect<CodexEffectiveRuntime, ProviderSetupError, Scope.Scope>;1620 readonly resolveRuntime?: Effect.Effect<CodexEffectiveRuntime, ProviderSetupError, Scope.Scope>;
1621 readonly crypto: Crypto.Crypto;
1612 readonly fileSystem: FileSystem.FileSystem;1622 readonly fileSystem: FileSystem.FileSystem;
1613 readonly idAllocator: IdAllocatorV2Shape;1623 readonly idAllocator: IdAllocatorV2Shape;
1614 readonly serverConfig: ServerConfig["Service"];1624 readonly serverConfig: ServerConfig["Service"];
...@@ -1623,7 +1633,7 @@ export interface CodexAdapterV2Options {...@@ -1623,7 +1633,7 @@ export interface CodexAdapterV2Options {
1623}1633}
16241634
1625export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): ProviderAdapterV2Shape {1635export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): ProviderAdapterV2Shape {
1626 const { clientFactory, fileSystem, idAllocator, serverConfig } = adapterOptions;1636 const { clientFactory, crypto, fileSystem, idAllocator, serverConfig } = adapterOptions;
1627 const continuationRequests = adapterOptions.continuationRequests;1637 const continuationRequests = adapterOptions.continuationRequests;
16281638
1629 return ProviderAdapterV2.of({1639 return ProviderAdapterV2.of({
...@@ -3480,7 +3490,9 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi...@@ -3480,7 +3490,9 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
3480 nativeItemId: item.id,3490 nativeItemId: item.id,
3481 });3491 });
3482 const { ordinal, startedAt } = yield* resolveItemPosition(context, item.id);3492 const { ordinal, startedAt } = yield* resolveItemPosition(context, item.id);
3483 const projection = projectCodexDynamicToolItem(item);3493 const projection = yield* projectCodexDynamicToolItem(item).pipe(
3494 Effect.provideService(Crypto.Crypto, crypto),
3495 );
3484 const node: OrchestrationV2ExecutionNode = {3496 const node: OrchestrationV2ExecutionNode = {
3485 id: nodeId,3497 id: nodeId,
3486 threadId: context.projectionThreadId,3498 threadId: context.projectionThreadId,
apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts+20-15
...@@ -3,6 +3,7 @@...@@ -3,6 +3,7 @@
3 * driven through the real adapter and `@opencode/client` against a replayed3 * driven through the real adapter and `@opencode/client` against a replayed
4 * HTTP server. Frames reuse the shapes recorded against 2.0.18.4 * HTTP server. Frames reuse the shapes recorded against 2.0.18.
5 */5 */
6import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
6import { assert, it } from "@effect/vitest";7import { assert, it } from "@effect/vitest";
7import {8import {
8 CheckpointId,9 CheckpointId,
...@@ -2766,21 +2767,25 @@ describe("OpenCode2 adapter", () => {...@@ -2766,21 +2767,25 @@ describe("OpenCode2 adapter", () => {
2766 }).pipe(Effect.scoped),2767 }).pipe(Effect.scoped),
2767 );2768 );
27682769
2769 it("names each thread's MCP server within OpenCode's limits, one name per thread", () => {2770 it.effect("names each thread's MCP server within OpenCode's limits, one name per thread", () =>
2770 const project = "thread:project:ce04e4e2-6c29-4ff0-a1d7-b089dd63e258";2771 Effect.gen(function* () {
2771 const ids = [2772 const project = "thread:project:ce04e4e2-6c29-4ff0-a1d7-b089dd63e258";
2772 `${project}:d3b2d715-c4a1-4b63-bb65-1634c3a3a8c4`,2773 const ids = [
2773 `${project}:d3b2d715-c4a1-4b63-bb65-1634c3a3a8c5`,2774 `${project}:d3b2d715-c4a1-4b63-bb65-1634c3a3a8c4`,
2774 "thread:delegated-task:command%3Amcp%3A48bef2bf-6d0e-4f7a-9c3b-2e5d8a1f7c40%3Adelegate-task%3Around1",2775 `${project}:d3b2d715-c4a1-4b63-bb65-1634c3a3a8c5`,
2775 "thread:delegated-task:command%3Amcp%3A48bef2bf-6d0e-4f7a-9c3b-2e5d8a1f7c40%3Adelegate-task%3Around2",2776 "thread:delegated-task:command%3Amcp%3A48bef2bf-6d0e-4f7a-9c3b-2e5d8a1f7c40%3Adelegate-task%3Around1",
2776 ];2777 "thread:delegated-task:command%3Amcp%3A48bef2bf-6d0e-4f7a-9c3b-2e5d8a1f7c40%3Adelegate-task%3Around2",
2777 const names = ids.map(t3McpServerName);2778 ];
2778 for (const name of names) assert.match(name, /^t3-code-[A-Za-z0-9_-]{1,56}$/);2779 const names = yield* Effect.forEach(ids, t3McpServerName);
2779 assert.equal(new Set(names).size, ids.length);2780 for (const name of names) assert.match(name, /^t3-code-[A-Za-z0-9_-]{1,56}$/);
2780 assert.deepEqual(ids.map(t3McpServerName), names);2781 assert.equal(new Set(names).size, ids.length);
2781 // A name that already fits stays readable.2782 assert.deepEqual(yield* Effect.forEach(ids, t3McpServerName), names);
2782 assert.equal(t3McpServerName(threadId), "t3-code-thread_opencode2-adapter");2783 // A digested name is the one the synchronous node:crypto version produced.
2783 });2784 assert.equal(names[0], "t3-code-63abb5df2b188bdd");
2785 // A name that already fits stays readable.
2786 assert.equal(yield* t3McpServerName(threadId), "t3-code-thread_opencode2-adapter");
2787 }).pipe(Effect.provide(NodeCrypto.layer)),
2788 );
27842789
2785 it.effect("reads user and assistant text from the session's message list", () =>2790 it.effect("reads user and assistant text from the session's message list", () =>
2786 Effect.gen(function* () {2791 Effect.gen(function* () {
apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts+14-2
...@@ -284,7 +284,13 @@ function layerRegistry(...@@ -284,7 +284,13 @@ function layerRegistry(
284 Effect.map((adapter) => ProviderAdapterRegistry.layerFromAdapters([adapter])),284 Effect.map((adapter) => ProviderAdapterRegistry.layerFromAdapters([adapter])),
285 ),285 ),
286 ).pipe(286 ).pipe(
287 Layer.provide(Layer.mergeAll(layerReplayServerConfig(transcript.scenario), IdAllocator.layer)),287 Layer.provide(
288 Layer.mergeAll(
289 layerReplayServerConfig(transcript.scenario),
290 IdAllocator.layer,
291 NodeServices.layer,
292 ),
293 ),
288 );294 );
289}295}
290296
...@@ -322,7 +328,13 @@ export const openCode2ReplayRuntime = (...@@ -322,7 +328,13 @@ export const openCode2ReplayRuntime = (
322 },328 },
323 });329 });
324 }).pipe(330 }).pipe(
325 Effect.provide(Layer.mergeAll(layerReplayServerConfig("opencode2_adapter"), IdAllocator.layer)),331 Effect.provide(
332 Layer.mergeAll(
333 layerReplayServerConfig("opencode2_adapter"),
334 IdAllocator.layer,
335 NodeServices.layer,
336 ),
337 ),
326 );338 );
327339
328export const OpenCode2OrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness<340export const OpenCode2OrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness<
apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts+19-12
...@@ -21,7 +21,6 @@...@@ -21,7 +21,6 @@
21 *21 *
22 * @module orchestration-v2/Adapters/OpenCode2AdapterV222 * @module orchestration-v2/Adapters/OpenCode2AdapterV2
23 */23 */
24import * as NodeCrypto from "node:crypto";
2524
26import {25import {
27 AbsolutePath,26 AbsolutePath,
...@@ -59,7 +58,9 @@ import {...@@ -59,7 +58,9 @@ import {
59import type * as Cause from "effect/Cause";58import type * as Cause from "effect/Cause";
60import * as DateTime from "effect/DateTime";59import * as DateTime from "effect/DateTime";
61import * as Deferred from "effect/Deferred";60import * as Deferred from "effect/Deferred";
61import * as Crypto from "effect/Crypto";
62import * as Effect from "effect/Effect";62import * as Effect from "effect/Effect";
63import * as Hex from "effect/encoding/Hex";
63import * as Exit from "effect/Exit";64import * as Exit from "effect/Exit";
64import * as Option from "effect/Option";65import * as Option from "effect/Option";
65import * as Queue from "effect/Queue";66import * as Queue from "effect/Queue";
...@@ -453,12 +454,15 @@ const rule = (action: string, effect: Rule["effect"]): Rule => ({ action, resour...@@ -453,12 +454,15 @@ const rule = (action: string, effect: Rule["effect"]): Rule => ({ action, resour
453 * tool namespace limit), and its router rejects adding one over 100, so a454 * tool namespace limit), and its router rejects adding one over 100, so a
454 * thread id that does not fit is replaced by a digest of it.455 * thread id that does not fit is replaced by a digest of it.
455 */456 */
456export const t3McpServerName = (threadId: string) => {457export const t3McpServerName = Effect.fn("t3McpServerName")(function* (threadId: string) {
457 const name = `t3-code-${threadId.replaceAll(/[^a-zA-Z0-9_-]/g, "_")}`;458 const name = `t3-code-${threadId.replaceAll(/[^a-zA-Z0-9_-]/g, "_")}`;
458 if (name.length <= 64) return name;459 if (name.length <= 64) return name;
459 const digest = NodeCrypto.createHash("sha256").update(threadId).digest("hex");460 const crypto = yield* Crypto.Crypto;
460 return `t3-code-${digest.slice(0, 16)}`;461 const digest = yield* crypto
461};462 .digest("SHA-256", new TextEncoder().encode(threadId))
463 .pipe(Effect.orDie);
464 return `t3-code-${Hex.encode(digest).slice(0, 16)}`;
465});
462466
463/**467/**
464 * The rules that keep T3's MCP servers to their own thread, after the mode's:468 * The rules that keep T3's MCP servers to their own thread, after the mode's:
...@@ -466,19 +470,19 @@ export const t3McpServerName = (threadId: string) => {...@@ -466,19 +470,19 @@ export const t3McpServerName = (threadId: string) => {
466 * this thread's own is allowed again, in every mode. A subagent's session470 * this thread's own is allowed again, in every mode. A subagent's session
467 * inherits the thread's.471 * inherits the thread's.
468 */472 */
469const mcpRules = (threadId: string | null): ReadonlyArray<Rule> =>473const mcpRules = (mcpServerName: string | null): ReadonlyArray<Rule> =>
470 threadId === null474 mcpServerName === null
471 ? []475 ? []
472 : [476 : [
473 { action: "t3-code-*", resource: "*", effect: "deny" },477 { action: "t3-code-*", resource: "*", effect: "deny" },
474 { action: `${t3McpServerName(threadId)}_*`, resource: "*", effect: "allow" },478 { action: `${mcpServerName}_*`, resource: "*", effect: "allow" },
475 ];479 ];
476480
477const sessionRules = (481const sessionRules = (
478 policy: RulesPolicy,482 policy: RulesPolicy,
479 paths: ReadonlyArray<Rule>,483 paths: ReadonlyArray<Rule>,
480 grants: ReadonlyArray<Rule>,484 grants: ReadonlyArray<Rule>,
481 threadId: string | null,485 mcpServerName: string | null,
482): ReadonlyArray<Rule> => [486): ReadonlyArray<Rule> => [
483 ...(policy.runtimeMode === "full-access"487 ...(policy.runtimeMode === "full-access"
484 ? [rule("*", "allow")]488 ? [rule("*", "allow")]
...@@ -492,7 +496,7 @@ const sessionRules = (...@@ -492,7 +496,7 @@ const sessionRules = (
492 // are never denied: the free tier refuses sessions whose rules deny them.496 // are never denied: the free tier refuses sessions whose rules deny them.
493 ...(policy.interactionMode === "plan" ? [rule("edit", "deny")] : []),497 ...(policy.interactionMode === "plan" ? [rule("edit", "deny")] : []),
494 ...paths,498 ...paths,
495 ...mcpRules(threadId),499 ...mcpRules(mcpServerName),
496];500];
497501
498const sameRules = (left: ReadonlyArray<Rule> | undefined, right: ReadonlyArray<Rule>) =>502const sameRules = (left: ReadonlyArray<Rule> | undefined, right: ReadonlyArray<Rule>) =>
...@@ -828,7 +832,10 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr...@@ -828,7 +832,10 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
828 const idAllocator = yield* IdAllocator.IdAllocatorV2;832 const idAllocator = yield* IdAllocator.IdAllocatorV2;
829 const serverConfig = yield* ServerConfig.ServerConfig;833 const serverConfig = yield* ServerConfig.ServerConfig;
830 const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests;834 const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
835 const crypto = yield* Crypto.Crypto;
831 const driver = OPENCODE_PROVIDER;836 const driver = OPENCODE_PROVIDER;
837 const mcpServerNameFor = (threadId: string) =>
838 t3McpServerName(threadId).pipe(Effect.provideService(Crypto.Crypto, crypto));
832839
833 /**840 /**
834 * Lends the instance's server to a session until its scope closes. A spawned841 * Lends the instance's server to a session until its scope closes. A spawned
...@@ -2977,7 +2984,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr...@@ -2977,7 +2984,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
2977 policy,2984 policy,
2978 paths,2985 paths,
2979 policy.runtimeMode === "full-access" ? [] : thread.grants,2986 policy.runtimeMode === "full-access" ? [] : thread.grants,
2980 appThreadId,2987 appThreadId === null ? null : yield* mcpServerNameFor(appThreadId),
2981 );2988 );
2982 });2989 });
29832990
...@@ -3234,7 +3241,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr...@@ -3234,7 +3241,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
3234 ) {3241 ) {
3235 const mcpSession = McpProviderSession.readMcpProviderSession(turnInput.threadId);3242 const mcpSession = McpProviderSession.readMcpProviderSession(turnInput.threadId);
3236 const directory = turnInput.runtimePolicy.cwd ?? serverConfig.cwd;3243 const directory = turnInput.runtimePolicy.cwd ?? serverConfig.cwd;
3237 const name = t3McpServerName(turnInput.threadId);3244 const name = yield* mcpServerNameFor(turnInput.threadId);
3238 // An external server may not reach T3's MCP endpoint, as with 1.x.3245 // An external server may not reach T3's MCP endpoint, as with 1.x.
3239 const wanted =3246 const wanted =
3240 mcpSession === undefined || connection.external3247 mcpSession === undefined || connection.external
apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts+2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
1import { assert, it } from "@effect/vitest";2import { assert, it } from "@effect/vitest";
2import {3import {
3 CheckpointId,4 CheckpointId,
...@@ -274,6 +275,7 @@ it.layer(layerProjectionStoreTest)("CheckpointCaptureServiceV2", (it) => {...@@ -274,6 +275,7 @@ it.layer(layerProjectionStoreTest)("CheckpointCaptureServiceV2", (it) => {
274 Layer.provide(275 Layer.provide(
275 Layer.mergeAll(276 Layer.mergeAll(
276 IdAllocator.layer,277 IdAllocator.layer,
278 NodeCrypto.layer,
277 Layer.mock(CheckpointStore.CheckpointStore)({279 Layer.mock(CheckpointStore.CheckpointStore)({
278 isGitRepository: () => Effect.succeed(true),280 isGitRepository: () => Effect.succeed(true),
279 captureCheckpoint: () => Effect.void,281 captureCheckpoint: () => Effect.void,
apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts+10-9
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
1import { assert, it } from "@effect/vitest";2import { assert, it } from "@effect/vitest";
2import * as DateTime from "effect/DateTime";3import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
...@@ -24,6 +25,7 @@ const layerProjection = Layer.mergeAll(...@@ -24,6 +25,7 @@ const layerProjection = Layer.mergeAll(
24 ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)),25 ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)),
25 SqlitePersistence.layerMemory,26 SqlitePersistence.layerMemory,
26 IdAllocator.layer,27 IdAllocator.layer,
28 NodeCrypto.layer,
27);29);
28it.effect("resolves the thread baseline after a second root run replaces scope ownership", () =>30it.effect("resolves the thread baseline after a second root run replaces scope ownership", () =>
29 Effect.gen(function* () {31 Effect.gen(function* () {
...@@ -38,6 +40,11 @@ it.effect("resolves the thread baseline after a second root run replaces scope o...@@ -38,6 +40,11 @@ it.effect("resolves the thread baseline after a second root run replaces scope o
38 const providerThreadId = ProviderThreadId.make("provider-thread:audit-root-scope");40 const providerThreadId = ProviderThreadId.make("provider-thread:audit-root-scope");
39 const providerInstanceId = ProviderInstanceId.make("codex");41 const providerInstanceId = ProviderInstanceId.make("codex");
40 const scopeId = yield* ids.allocate.checkpointScope({ threadId, name: "root" });42 const scopeId = yield* ids.allocate.checkpointScope({ threadId, name: "root" });
43 const checkpointRefs = [
44 yield* checkpointRefForScopeOrdinal({ scopeId, ordinalWithinScope: 0 }),
45 yield* checkpointRefForScopeOrdinal({ scopeId, ordinalWithinScope: 1 }),
46 yield* checkpointRefForScopeOrdinal({ scopeId, ordinalWithinScope: 2 }),
47 ];
41 const baselineId = CheckpointId.make("checkpoint:audit-root-scope:0");48 const baselineId = CheckpointId.make("checkpoint:audit-root-scope:0");
42 const firstCheckpointId = CheckpointId.make("checkpoint:audit-root-scope:1");49 const firstCheckpointId = CheckpointId.make("checkpoint:audit-root-scope:1");
43 const secondCheckpointId = CheckpointId.make("checkpoint:audit-root-scope:2");50 const secondCheckpointId = CheckpointId.make("checkpoint:audit-root-scope:2");
...@@ -154,7 +161,7 @@ it.effect("resolves the thread baseline after a second root run replaces scope o...@@ -154,7 +161,7 @@ it.effect("resolves the thread baseline after a second root run replaces scope o
154 input.ordinal === 0 ? null : input.ordinal === 1 ? baselineId : firstCheckpointId,161 input.ordinal === 0 ? null : input.ordinal === 1 ? baselineId : firstCheckpointId,
155 ordinalWithinScope: input.ordinal,162 ordinalWithinScope: input.ordinal,
156 appRunOrdinal: input.appRunOrdinal,163 appRunOrdinal: input.appRunOrdinal,
157 ref: checkpointRefForScopeOrdinal({ scopeId, ordinalWithinScope: input.ordinal }),164 ref: checkpointRefs[input.ordinal]!,
158 status: "ready" as const,165 status: "ready" as const,
159 files: [],166 files: [],
160 capturedAt: now,167 capturedAt: now,
...@@ -214,14 +221,8 @@ it.effect("resolves the thread baseline after a second root run replaces scope o...@@ -214,14 +221,8 @@ it.effect("resolves the thread baseline after a second root run replaces scope o
214 Layer.mock(CheckpointStore.CheckpointStore)({221 Layer.mock(CheckpointStore.CheckpointStore)({
215 diffCheckpoints: (input) => {222 diffCheckpoints: (input) => {
216 assert.equal(input.cwd, "/prepared-repo");223 assert.equal(input.cwd, "/prepared-repo");
217 assert.equal(224 assert.equal(input.fromCheckpointRef, checkpointRefs[0]);
218 input.fromCheckpointRef,225 assert.equal(input.toCheckpointRef, checkpointRefs[2]);
219 checkpointRefForScopeOrdinal({ scopeId, ordinalWithinScope: 0 }),
220 );
221 assert.equal(
222 input.toCheckpointRef,
223 checkpointRefForScopeOrdinal({ scopeId, ordinalWithinScope: 2 }),
224 );
225 return Effect.succeed("two-run diff");226 return Effect.succeed("two-run diff");
226 },227 },
227 }),228 }),
apps/server/src/orchestration-v2/CheckpointService.test.ts+3-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
1import { assert, it, vi } from "@effect/vitest";2import { assert, it, vi } from "@effect/vitest";
2import {3import {
3 CheckpointScopeId,4 CheckpointScopeId,
...@@ -58,6 +59,7 @@ it.effect.each([false, true, "interrupt"] as const)(...@@ -58,6 +59,7 @@ it.effect.each([false, true, "interrupt"] as const)(
58 }),59 }),
59 ),60 ),
60 ),61 ),
62 Layer.provideMerge(NodeCrypto.layer),
61 );63 );
6264
63 return Effect.gen(function* () {65 return Effect.gen(function* () {
...@@ -88,7 +90,7 @@ it.effect.each([false, true, "interrupt"] as const)(...@@ -88,7 +90,7 @@ it.effect.each([false, true, "interrupt"] as const)(
88 assert.equal(baseline.ordinalWithinScope, 2);90 assert.equal(baseline.ordinalWithinScope, 2);
89 assert.equal(91 assert.equal(
90 baseline.ref,92 baseline.ref,
91 CheckpointService.checkpointRefForScopeOrdinal({93 yield* CheckpointService.checkpointRefForScopeOrdinal({
92 scopeId: scope.id,94 scopeId: scope.id,
93 ordinalWithinScope: 2,95 ordinalWithinScope: 2,
94 }),96 }),
apps/server/src/orchestration-v2/CheckpointService.ts+22-15
...@@ -9,11 +9,12 @@ import {...@@ -9,11 +9,12 @@ import {
9 RunId,9 RunId,
10 ThreadId,10 ThreadId,
11} from "@t3tools/contracts";11} from "@t3tools/contracts";
12import * as NodeCrypto from "node:crypto";
13import * as Context from "effect/Context";12import * as Context from "effect/Context";
13import * as Crypto from "effect/Crypto";
14import * as DateTime from "effect/DateTime";14import * as DateTime from "effect/DateTime";
15import * as Effect from "effect/Effect";15import * as Effect from "effect/Effect";
16import * as Base64Url from "effect/encoding/Base64Url";16import * as Base64Url from "effect/encoding/Base64Url";
17import * as Hex from "effect/encoding/Hex";
17import * as KeyedLock from "@t3tools/shared/KeyedLock";18import * as KeyedLock from "@t3tools/shared/KeyedLock";
18import * as Layer from "effect/Layer";19import * as Layer from "effect/Layer";
19import * as Schema from "effect/Schema";20import * as Schema from "effect/Schema";
...@@ -157,15 +158,18 @@ export class CheckpointServiceV2 extends Context.Service<...@@ -157,15 +158,18 @@ export class CheckpointServiceV2 extends Context.Service<
157 CheckpointServiceV2Shape158 CheckpointServiceV2Shape
158>()("t3/orchestration-v2/CheckpointService/CheckpointServiceV2") {}159>()("t3/orchestration-v2/CheckpointService/CheckpointServiceV2") {}
159160
160export function checkpointRefForScopeOrdinal(input: {161export const checkpointRefForScopeOrdinal = Effect.fn("checkpointRefForScopeOrdinal")(
161 readonly scopeId: CheckpointScopeId;162 function* (input: { readonly scopeId: CheckpointScopeId; readonly ordinalWithinScope: number }) {
162 readonly ordinalWithinScope: number;163 const crypto = yield* Crypto.Crypto;
163}): CheckpointRef {164 const digest = yield* crypto
164 const scopeKey = NodeCrypto.createHash("sha256").update(input.scopeId).digest("hex").slice(0, 32);165 .digest("SHA-256", new TextEncoder().encode(input.scopeId))
165 return CheckpointRef.make(166 .pipe(Effect.orDie);
166 `${CHECKPOINT_REFS_PREFIX}/${Base64Url.encode(scopeKey)}/ordinal/${input.ordinalWithinScope}`,167 const scopeKey = Hex.encode(digest).slice(0, 32);
167 );168 return CheckpointRef.make(
168}169 `${CHECKPOINT_REFS_PREFIX}/${Base64Url.encode(scopeKey)}/ordinal/${input.ordinalWithinScope}`,
170 );
171 },
172);
169173
170function checkpointIdForScopeOrdinal(174function checkpointIdForScopeOrdinal(
171 idAllocator: IdAllocator.IdAllocatorV2Shape,175 idAllocator: IdAllocator.IdAllocatorV2Shape,
...@@ -242,11 +246,14 @@ function makeCheckpoint(input: {...@@ -242,11 +246,14 @@ function makeCheckpoint(input: {
242export const layer: Layer.Layer<246export const layer: Layer.Layer<
243 CheckpointServiceV2,247 CheckpointServiceV2,
244 never,248 never,
245 CheckpointStore.CheckpointStore | IdAllocator.IdAllocatorV2249 CheckpointStore.CheckpointStore | Crypto.Crypto | IdAllocator.IdAllocatorV2
246> = Layer.effect(250> = Layer.effect(
247 CheckpointServiceV2,251 CheckpointServiceV2,
248 Effect.gen(function* () {252 Effect.gen(function* () {
249 const checkpointStore = yield* CheckpointStore.CheckpointStore;253 const checkpointStore = yield* CheckpointStore.CheckpointStore;
254 const crypto = yield* Crypto.Crypto;
255 const checkpointRefFor = (input: Parameters<typeof checkpointRefForScopeOrdinal>[0]) =>
256 checkpointRefForScopeOrdinal(input).pipe(Effect.provideService(Crypto.Crypto, crypto));
250 const idAllocator = yield* IdAllocator.IdAllocatorV2;257 const idAllocator = yield* IdAllocator.IdAllocatorV2;
251 const workspaceLocks = yield* KeyedLock.make<string>();258 const workspaceLocks = yield* KeyedLock.make<string>();
252 const withWorkspaceLock = <A, E, R>(cwd: string, effect: Effect.Effect<A, E, R>) =>259 const withWorkspaceLock = <A, E, R>(cwd: string, effect: Effect.Effect<A, E, R>) =>
...@@ -265,7 +272,7 @@ export const layer: Layer.Layer<...@@ -265,7 +272,7 @@ export const layer: Layer.Layer<
265 return;272 return;
266 }273 }
267274
268 const checkpointRef = checkpointRefForScopeOrdinal({275 const checkpointRef = yield* checkpointRefFor({
269 scopeId: input.scope.id,276 scopeId: input.scope.id,
270 ordinalWithinScope: input.ordinalWithinScope,277 ordinalWithinScope: input.ordinalWithinScope,
271 });278 });
...@@ -298,7 +305,7 @@ export const layer: Layer.Layer<...@@ -298,7 +305,7 @@ export const layer: Layer.Layer<
298 withWorkspaceLock(305 withWorkspaceLock(
299 input.scope.cwd,306 input.scope.cwd,
300 Effect.gen(function* () {307 Effect.gen(function* () {
301 const checkpointRef = checkpointRefForScopeOrdinal({308 const checkpointRef = yield* checkpointRefFor({
302 scopeId: input.scope.id,309 scopeId: input.scope.id,
303 ordinalWithinScope: input.ordinalWithinScope,310 ordinalWithinScope: input.ordinalWithinScope,
304 });311 });
...@@ -362,11 +369,11 @@ export const layer: Layer.Layer<...@@ -362,11 +369,11 @@ export const layer: Layer.Layer<
362 ordinalWithinScope: input.ordinalWithinScope - 1,369 ordinalWithinScope: input.ordinalWithinScope - 1,
363 })370 })
364 : null;371 : null;
365 const checkpointRef = checkpointRefForScopeOrdinal({372 const checkpointRef = yield* checkpointRefFor({
366 scopeId: input.scope.id,373 scopeId: input.scope.id,
367 ordinalWithinScope: input.ordinalWithinScope,374 ordinalWithinScope: input.ordinalWithinScope,
368 });375 });
369 const previousCheckpointRef = checkpointRefForScopeOrdinal({376 const previousCheckpointRef = yield* checkpointRefFor({
370 scopeId: input.scope.id,377 scopeId: input.scope.id,
371 ordinalWithinScope: Math.max(0, input.ordinalWithinScope - 1),378 ordinalWithinScope: Math.max(0, input.ordinalWithinScope - 1),
372 });379 });
apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts+2
...@@ -15,6 +15,7 @@ import {...@@ -15,6 +15,7 @@ import {
15 type OrchestrationV2DomainEvent,15 type OrchestrationV2DomainEvent,
16} from "@t3tools/contracts";16} from "@t3tools/contracts";
17import * as DateTime from "effect/DateTime";17import * as DateTime from "effect/DateTime";
18import * as Crypto from "effect/Crypto";
18import * as Effect from "effect/Effect";19import * as Effect from "effect/Effect";
19import * as FileSystem from "effect/FileSystem";20import * as FileSystem from "effect/FileSystem";
20import * as Fiber from "effect/Fiber";21import * as Fiber from "effect/Fiber";
...@@ -137,6 +138,7 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con...@@ -137,6 +138,7 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con
137 attachmentsDir: cwd,138 attachmentsDir: cwd,
138 fileSystem: yield* FileSystem.FileSystem,139 fileSystem: yield* FileSystem.FileSystem,
139 path: yield* Path.Path,140 path: yield* Path.Path,
141 crypto: yield* Crypto.Crypto,
140 idAllocator: yield* IdAllocator.IdAllocatorV2,142 idAllocator: yield* IdAllocator.IdAllocatorV2,
141 queryRunner: {143 queryRunner: {
142 allocateSessionId: Effect.succeed(sessionId),144 allocateSessionId: Effect.succeed(sessionId),
apps/server/src/orchestration-v2/ProviderTurnControlService.ts+17-10
...@@ -7,12 +7,12 @@ import {...@@ -7,12 +7,12 @@ import {
7 RunAttemptId,7 RunAttemptId,
8 ThreadId,8 ThreadId,
9} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import * as Clock from "effect/Clock";
10import * as Context from "effect/Context";11import * as Context from "effect/Context";
11import * as Effect from "effect/Effect";12import * as Effect from "effect/Effect";
12import * as Layer from "effect/Layer";13import * as Layer from "effect/Layer";
13import * as Option from "effect/Option";14import * as Option from "effect/Option";
14import * as Schema from "effect/Schema";15import * as Schema from "effect/Schema";
15import * as NodeTimersPromises from "node:timers/promises";
1616
17import * as ProjectionStore from "./ProjectionStore.ts";17import * as ProjectionStore from "./ProjectionStore.ts";
18import * as ProviderSessionManager from "./ProviderSessionManager.ts";18import * as ProviderSessionManager from "./ProviderSessionManager.ts";
...@@ -188,17 +188,24 @@ export const layer: Layer.Layer<...@@ -188,17 +188,24 @@ export const layer: Layer.Layer<
188 });188 });
189 // Give native terminal ingestion time to finish before the Stop189 // Give native terminal ingestion time to finish before the Stop
190 // follow-up repairs a run whose provider no longer reports on it.190 // follow-up repairs a run whose provider no longer reports on it.
191 const deadline = performance.now() + 2_000;191 // The wait is real time: ingestion runs on other fibers and never
192 while (loaded.providerTurn.status === "running" && performance.now() < deadline) {192 // advances a test clock, so a test clock would hold Stop forever.
193 const current = yield* projections.getProviderControlContext(input.threadId, input);193 yield* Effect.gen(function* () {
194 if (194 const deadline = (yield* Clock.currentTimeMillis) + 2_000;
195 current.providerTurn?.status !== "running" &&195 while (
196 current.attempt?.status !== "running"196 loaded.providerTurn.status === "running" &&
197 (yield* Clock.currentTimeMillis) < deadline
197 ) {198 ) {
198 break;199 const current = yield* projections.getProviderControlContext(input.threadId, input);
200 if (
201 current.providerTurn?.status !== "running" &&
202 current.attempt?.status !== "running"
203 ) {
204 return;
205 }
206 yield* Effect.sleep("10 millis");
199 }207 }
200 yield* Effect.promise(() => NodeTimersPromises.setTimeout(10));208 }).pipe(Effect.provideService(Clock.Clock, Clock.Clock.defaultValue()));
201 }
202 }).pipe(209 }).pipe(
203 Effect.mapError((cause) =>210 Effect.mapError((cause) =>
204 isProviderTurnControlError(cause)211 isProviderTurnControlError(cause)
apps/server/src/preview/PreviewBrowser.test.ts+6-2
...@@ -2,9 +2,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices";...@@ -2,9 +2,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";2import { expect, it } from "@effect/vitest";
3import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";3import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import * as Deferred from "effect/Deferred";4import * as Deferred from "effect/Deferred";
5import * as Crypto from "effect/Crypto";
5import type * as Duration from "effect/Duration";6import type * as Duration from "effect/Duration";
6import * as DateTime from "effect/DateTime";7import * as DateTime from "effect/DateTime";
7import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
9import * as Hex from "effect/encoding/Hex";
8import * as Fiber from "effect/Fiber";10import * as Fiber from "effect/Fiber";
9import * as FileSystem from "effect/FileSystem";11import * as FileSystem from "effect/FileSystem";
10import * as Option from "effect/Option";12import * as Option from "effect/Option";
...@@ -12,7 +14,6 @@ import * as Path from "effect/Path";...@@ -12,7 +14,6 @@ import * as Path from "effect/Path";
12import * as Stream from "effect/Stream";14import * as Stream from "effect/Stream";
13import * as TestClock from "effect/testing/TestClock";15import * as TestClock from "effect/testing/TestClock";
14import { HttpClient, HttpClientResponse } from "effect/http";16import { HttpClient, HttpClientResponse } from "effect/http";
15import * as NodeCrypto from "node:crypto";
16import * as NodeZlib from "node:zlib";17import * as NodeZlib from "node:zlib";
1718
18import * as PreviewBrowser from "./PreviewBrowser.ts";19import * as PreviewBrowser from "./PreviewBrowser.ts";
...@@ -87,6 +88,9 @@ const makeHarness = Effect.fn("test.makePreviewBrowser")(function* (...@@ -87,6 +88,9 @@ const makeHarness = Effect.fn("test.makePreviewBrowser")(function* (
87 const path = yield* Path.Path;88 const path = yield* Path.Path;
88 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-preview-browser-test-" });89 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-preview-browser-test-" });
89 const archive = options.archive ?? browserArchive;90 const archive = options.archive ?? browserArchive;
91 const crypto = yield* Crypto.Crypto;
92 const sha256 =
93 options.sha256 ?? Hex.encode(yield* crypto.digest("SHA-256", archive).pipe(Effect.orDie));
90 const requests: Array<string> = [];94 const requests: Array<string> = [];
91 const browser = yield* PreviewBrowser.makePreviewBrowser({95 const browser = yield* PreviewBrowser.makePreviewBrowser({
92 baseDir,96 baseDir,
...@@ -97,7 +101,7 @@ const makeHarness = Effect.fn("test.makePreviewBrowser")(function* (...@@ -97,7 +101,7 @@ const makeHarness = Effect.fn("test.makePreviewBrowser")(function* (
97 platform: "fixture",101 platform: "fixture",
98 url: "https://storage.googleapis.com/chrome-headless-shell-fixture.zip",102 url: "https://storage.googleapis.com/chrome-headless-shell-fixture.zip",
99 bytes: archive.byteLength,103 bytes: archive.byteLength,
100 sha256: options.sha256 ?? NodeCrypto.createHash("sha256").update(archive).digest("hex"),104 sha256,
101 },105 },
102 ...(options.wait === undefined ? {} : { wait: options.wait }),106 ...(options.wait === undefined ? {} : { wait: options.wait }),
103 }).pipe(107 }).pipe(
apps/server/src/provider/AntigravityInstallation.test.ts+20-13
...@@ -6,8 +6,10 @@ import {...@@ -6,8 +6,10 @@ import {
6 HostProcessIsExecutable,6 HostProcessIsExecutable,
7 HostProcessPlatform,7 HostProcessPlatform,
8} from "@t3tools/shared/hostProcess";8} from "@t3tools/shared/hostProcess";
9import * as Crypto from "effect/Crypto";
9import * as Deferred from "effect/Deferred";10import * as Deferred from "effect/Deferred";
10import * as Effect from "effect/Effect";11import * as Effect from "effect/Effect";
12import * as Hex from "effect/encoding/Hex";
11import * as Exit from "effect/Exit";13import * as Exit from "effect/Exit";
12import * as Fiber from "effect/Fiber";14import * as Fiber from "effect/Fiber";
13import * as FileSystem from "effect/FileSystem";15import * as FileSystem from "effect/FileSystem";
...@@ -21,7 +23,6 @@ import * as Sink from "effect/Sink";...@@ -21,7 +23,6 @@ import * as Sink from "effect/Sink";
21import * as Stream from "effect/Stream";23import * as Stream from "effect/Stream";
22import { HttpClient, HttpClientResponse } from "effect/http";24import { HttpClient, HttpClientResponse } from "effect/http";
23import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";25import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
24import * as NodeCrypto from "node:crypto";
2526
26import * as AntigravityInstallation from "./AntigravityInstallation.ts";27import * as AntigravityInstallation from "./AntigravityInstallation.ts";
27import { ANTIGRAVITY_AUTH_BROWSER_MARKER } from "./antigravityAuthSupport.ts";28import { ANTIGRAVITY_AUTH_BROWSER_MARKER } from "./antigravityAuthSupport.ts";
...@@ -70,14 +71,15 @@ const executableName = hostPlatform === "win32" ? "agy_acp_server.exe" : "agy_ac...@@ -70,14 +71,15 @@ const executableName = hostPlatform === "win32" ? "agy_acp_server.exe" : "agy_ac
70const harnessName =71const harnessName =
71 hostPlatform === "win32" ? "localharness_external.exe" : "localharness_external";72 hostPlatform === "win32" ? "localharness_external.exe" : "localharness_external";
7273
73function releaseAsset(74const releaseAsset = Effect.fn("test.antigravityReleaseAsset")(function* (
74 archive: Uint8Array = completeArchive,75 archive: Uint8Array = completeArchive,
75 platform: NodeJS.Platform = hostPlatform,76 platform: NodeJS.Platform = hostPlatform,
76) {77) {
78 const crypto = yield* Crypto.Crypto;
77 return {79 return {
78 version: "fixture-new",80 version: "fixture-new",
79 url: "https://dl.google.com/antigravity-test.zip",81 url: "https://dl.google.com/antigravity-test.zip",
80 sha256: NodeCrypto.createHash("sha256").update(archive).digest("hex"),82 sha256: Hex.encode(yield* crypto.digest("SHA-256", archive).pipe(Effect.orDie)),
81 archiveBytes: archive.byteLength,83 archiveBytes: archive.byteLength,
82 executable: {84 executable: {
83 name: platform === "win32" ? "agy_acp_server.exe" : "agy_acp_server.par",85 name: platform === "win32" ? "agy_acp_server.exe" : "agy_acp_server.par",
...@@ -88,7 +90,7 @@ function releaseAsset(...@@ -88,7 +90,7 @@ function releaseAsset(
88 bytes: Buffer.byteLength(harnessContents),90 bytes: Buffer.byteLength(harnessContents),
89 },91 },
90 } satisfies AntigravityReleaseAsset;92 } satisfies AntigravityReleaseAsset;
91}93});
9294
93const writeRelease = Effect.fn("test.writeAntigravityRelease")(function* (95const writeRelease = Effect.fn("test.writeAntigravityRelease")(function* (
94 managedDirectory: string,96 managedDirectory: string,
...@@ -146,11 +148,12 @@ const makeHarness = Effect.fn("test.makeAntigravityInstallation")(function* (...@@ -146,11 +148,12 @@ const makeHarness = Effect.fn("test.makeAntigravityInstallation")(function* (
146 options.baseDir ?? (yield* fs.makeTempDirectoryScoped({ prefix: "t3-agy-test-" }));148 options.baseDir ?? (yield* fs.makeTempDirectoryScoped({ prefix: "t3-agy-test-" }));
147 const platform = options.platform ?? hostPlatform;149 const platform = options.platform ?? hostPlatform;
148 const archive = options.archive ?? completeArchive;150 const archive = options.archive ?? completeArchive;
149 const asset = options.asset === undefined ? releaseAsset(archive, platform) : options.asset;151 const asset =
152 options.asset === undefined ? yield* releaseAsset(archive, platform) : options.asset;
150 const managedDirectory = path.join(baseDir, "tools", "antigravity-acp", `${platform}-x64`);153 const managedDirectory = path.join(baseDir, "tools", "antigravity-acp", `${platform}-x64`);
151 if (options.previous) {154 if (options.previous) {
152 yield* writeRelease(managedDirectory, {155 yield* writeRelease(managedDirectory, {
153 ...releaseAsset(archive, platform),156 ...(yield* releaseAsset(archive, platform)),
154 sha256: previousReleaseId,157 sha256: previousReleaseId,
155 version: previousVersion,158 version: previousVersion,
156 });159 });
...@@ -301,9 +304,9 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => {...@@ -301,9 +304,9 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => {
301 expect(yield* fs.readFileString(selected.executablePath)).toBe(serverContents);304 expect(yield* fs.readFileString(selected.executablePath)).toBe(serverContents);
302 expect(yield* fs.readFileString(selected.harnessPath)).toBe(harnessContents);305 expect(yield* fs.readFileString(selected.harnessPath)).toBe(harnessContents);
303 expect(yield* fs.readDirectory(path.join(installation.managedDirectory, "versions"))).toEqual(306 expect(yield* fs.readDirectory(path.join(installation.managedDirectory, "versions"))).toEqual(
304 expect.arrayContaining([previousReleaseId, releaseAsset().sha256]),307 expect.arrayContaining([previousReleaseId, (yield* releaseAsset()).sha256]),
305 );308 );
306 expect(requests).toEqual([releaseAsset().url]);309 expect(requests).toEqual([(yield* releaseAsset()).url]);
307 }),310 }),
308 );311 );
309312
...@@ -469,12 +472,12 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => {...@@ -469,12 +472,12 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => {
469 );472 );
470473
471 it.effect.each([474 it.effect.each([
472 { name: "checksum mismatch", asset: { ...releaseAsset(), sha256: "2".repeat(64) } },475 { name: "checksum mismatch", completeAsset: { sha256: "2".repeat(64) } },
473 { name: "short download", archive: completeArchive.subarray(0, -1), asset: releaseAsset() },476 { name: "short download", archive: completeArchive.subarray(0, -1), completeAsset: {} },
474 {477 {
475 name: "oversized download",478 name: "oversized download",
476 archive: Buffer.concat([completeArchive, Buffer.from("extra")]),479 archive: Buffer.concat([completeArchive, Buffer.from("extra")]),
477 asset: releaseAsset(),480 completeAsset: {},
478 },481 },
479 { name: "wrong Content-Length", contentLength: completeArchive.byteLength + 1 },482 { name: "wrong Content-Length", contentLength: completeArchive.byteLength + 1 },
480 { name: "missing harness", archive: Buffer.from(zipFixtures.missingHarness, "base64") },483 { name: "missing harness", archive: Buffer.from(zipFixtures.missingHarness, "base64") },
...@@ -482,10 +485,14 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => {...@@ -482,10 +485,14 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => {
482 { name: "path traversal", archive: Buffer.from(zipFixtures.traversal, "base64") },485 { name: "path traversal", archive: Buffer.from(zipFixtures.traversal, "base64") },
483 { name: "symbolic link", archive: Buffer.from(zipFixtures.symlink, "base64") },486 { name: "symbolic link", archive: Buffer.from(zipFixtures.symlink, "base64") },
484 { name: "oversized member", archive: Buffer.from(zipFixtures.oversizedMember, "base64") },487 { name: "oversized member", archive: Buffer.from(zipFixtures.oversizedMember, "base64") },
485 ])("rejects $name before runtime validation", (options) =>488 ])("rejects $name before runtime validation", ({ completeAsset, ...options }) =>
486 Effect.gen(function* () {489 Effect.gen(function* () {
490 // Pin the asset to the complete archive so the download itself is what disagrees.
491 const asset =
492 completeAsset === undefined ? undefined : { ...(yield* releaseAsset()), ...completeAsset };
487 const { installation, validations, stagingReleased, fs, path } = yield* makeHarness({493 const { installation, validations, stagingReleased, fs, path } = yield* makeHarness({
488 ...options,494 ...options,
495 ...(asset === undefined ? {} : { asset }),
489 previous: true,496 previous: true,
490 });497 });
491 yield* installation.start;498 yield* installation.start;
...@@ -893,7 +900,7 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => {...@@ -893,7 +900,7 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => {
893 const fs = yield* FileSystem.FileSystem;900 const fs = yield* FileSystem.FileSystem;
894 const path = yield* Path.Path;901 const path = yield* Path.Path;
895 const archive = Buffer.from(zipFixtures.windows, "base64");902 const archive = Buffer.from(zipFixtures.windows, "base64");
896 const asset = releaseAsset(archive, "win32");903 const asset = yield* releaseAsset(archive, "win32");
897 let denyPointerRename = true;904 let denyPointerRename = true;
898 const renameTargets: string[] = [];905 const renameTargets: string[] = [];
899 const { installation, requests, validations } = yield* makeHarness({906 const { installation, requests, validations } = yield* makeHarness({
apps/server/src/provider/CodexInstallation.test.ts+5-3
...@@ -6,15 +6,16 @@ import {...@@ -6,15 +6,16 @@ import {
6 HostProcessEnvironment,6 HostProcessEnvironment,
7 HostProcessPlatform,7 HostProcessPlatform,
8} from "@t3tools/shared/hostProcess";8} from "@t3tools/shared/hostProcess";
9import * as Crypto from "effect/Crypto";
9import * as Deferred from "effect/Deferred";10import * as Deferred from "effect/Deferred";
10import * as Effect from "effect/Effect";11import * as Effect from "effect/Effect";
12import * as Hex from "effect/encoding/Hex";
11import * as FileSystem from "effect/FileSystem";13import * as FileSystem from "effect/FileSystem";
12import * as Option from "effect/Option";14import * as Option from "effect/Option";
13import * as Exit from "effect/Exit";15import * as Exit from "effect/Exit";
14import * as Scope from "effect/Scope";16import * as Scope from "effect/Scope";
15import * as Stream from "effect/Stream";17import * as Stream from "effect/Stream";
16import { HttpClient, HttpClientResponse } from "effect/http";18import { HttpClient, HttpClientResponse } from "effect/http";
17import * as NodeCrypto from "node:crypto";
18import * as CodexInstallation from "./CodexInstallation.ts";19import * as CodexInstallation from "./CodexInstallation.ts";
1920
20const archive = Buffer.from(21const archive = Buffer.from(
...@@ -25,7 +26,6 @@ const asset = {...@@ -25,7 +26,6 @@ const asset = {
25 version: "0.156.1",26 version: "0.156.1",
26 target: "aarch64-apple-darwin",27 target: "aarch64-apple-darwin",
27 url: "https://github.com/openai/codex/releases/download/test/package.tar.gz",28 url: "https://github.com/openai/codex/releases/download/test/package.tar.gz",
28 sha256: NodeCrypto.createHash("sha256").update(archive).digest("hex"),
29 archiveBytes: archive.length,29 archiveBytes: archive.length,
30};30};
31const makeHarness = Effect.fn("test.makeCodexInstallation")(function* (31const makeHarness = Effect.fn("test.makeCodexInstallation")(function* (
...@@ -52,9 +52,11 @@ const makeHarness = Effect.fn("test.makeCodexInstallation")(function* (...@@ -52,9 +52,11 @@ const makeHarness = Effect.fn("test.makeCodexInstallation")(function* (
52 );52 );
53 }53 }
54 let downloads = 0;54 let downloads = 0;
55 const crypto = yield* Crypto.Crypto;
56 const sha256 = Hex.encode(yield* crypto.digest("SHA-256", archive).pipe(Effect.orDie));
55 const installation = yield* CodexInstallation.makeCodexInstallation({57 const installation = yield* CodexInstallation.makeCodexInstallation({
56 baseDir,58 baseDir,
57 releaseAsset: asset,59 releaseAsset: { ...asset, sha256 },
58 validate: () => Effect.void,60 validate: () => Effect.void,
59 ...input.options,61 ...input.options,
60 }).pipe(62 }).pipe(
apps/server/src/provider/CodexToolPresentation.ts+114-105
...@@ -1,9 +1,11 @@...@@ -1,9 +1,11 @@
1import * as NodeCrypto from "node:crypto";
2import type {1import type {
3 ToolActivityIcon,2 ToolActivityIcon,
4 ToolActivityNativeAppReference,3 ToolActivityNativeAppReference,
5 ToolActivitySource,4 ToolActivitySource,
6} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import * as Crypto from "effect/Crypto";
7import * as Effect from "effect/Effect";
8import * as Hex from "effect/encoding/Hex";
7import type * as EffectCodexSchema from "effect-codex-app-server/schema";9import type * as EffectCodexSchema from "effect-codex-app-server/schema";
810
9import {11import {
...@@ -42,12 +44,17 @@ function normalizedSourceKeyPart(value: string): string {...@@ -42,12 +44,17 @@ function normalizedSourceKeyPart(value: string): string {
42 return value.trim().toLowerCase();44 return value.trim().toLowerCase();
43}45}
4446
45function nativeAppSourceKey(appId: string): string {47const nativeAppSourceKey = Effect.fn("CodexToolPresentation.nativeAppSourceKey")(function* (
48 appId: string,
49) {
46 const key = `native-app:${appId.toLowerCase()}`;50 const key = `native-app:${appId.toLowerCase()}`;
47 if (key.length <= 512) return key;51 if (key.length <= 512) return key;
48 const digest = NodeCrypto.createHash("sha256").update(key).digest("hex");52 const crypto = yield* Crypto.Crypto;
53 const digest = Hex.encode(
54 yield* crypto.digest("SHA-256", new TextEncoder().encode(key)).pipe(Effect.orDie),
55 );
49 return `${key.slice(0, 512 - digest.length - 1)}:${digest}`;56 return `${key.slice(0, 512 - digest.length - 1)}:${digest}`;
50}57});
5158
52function browserDisplayName(value: unknown): string | undefined {59function browserDisplayName(value: unknown): string | undefined {
53 const normalized = normalizedDisplayName(value)?.toLowerCase();60 const normalized = normalizedDisplayName(value)?.toLowerCase();
...@@ -123,105 +130,107 @@ export interface McpToolPresentation {...@@ -123,105 +130,107 @@ export interface McpToolPresentation {
123 readonly toolSource?: ToolActivitySource;130 readonly toolSource?: ToolActivitySource;
124}131}
125132
126export function mcpToolPresentation(133export const mcpToolPresentation = Effect.fn("CodexToolPresentation.mcpToolPresentation")(
127 item: Extract<CodexLifecycleItem, { readonly type: "mcpToolCall" }>,134 function* (
128): McpToolPresentation {135 item: Extract<CodexLifecycleItem, { readonly type: "mcpToolCall" }>,
129 const result = asUnknownRecord(item.result);136 ): Effect.fn.Return<McpToolPresentation, never, Crypto.Crypto> {
130 const metadata = asUnknownRecord(result?._meta);137 const result = asUnknownRecord(item.result);
131 const surface = asUnknownRecord(metadata?.["codex/toolSurface"]);138 const metadata = asUnknownRecord(result?._meta);
132 const sourceMetadata = asUnknownRecord(metadata?.source);139 const surface = asUnknownRecord(metadata?.["codex/toolSurface"]);
133 const appContext = asUnknownRecord(item.appContext);140 const sourceMetadata = asUnknownRecord(metadata?.source);
134 const sourceLogo = themedLogoIcon(surface, sourceMetadata, appContext);141 const appContext = asUnknownRecord(item.appContext);
135 if (surface?.kind === "browserUse") {142 const sourceLogo = themedLogoIcon(surface, sourceMetadata, appContext);
136 const screenshot = asUnknownRecord(surface.screenshot);143 if (surface?.kind === "browserUse") {
137 const browserUse = asUnknownRecord(metadata?.browser_use);144 const screenshot = asUnknownRecord(surface.screenshot);
138 const openTabs = Array.isArray(surface.openTabs) ? surface.openTabs : [];145 const browserUse = asUnknownRecord(metadata?.browser_use);
139 const latestOpenTab = openTabs146 const openTabs = Array.isArray(surface.openTabs) ? surface.openTabs : [];
140 .toReversed()147 const latestOpenTab = openTabs
141 .map(asUnknownRecord)148 .toReversed()
142 .find((tab) => normalizedHttpUrl(tab?.url) !== undefined);149 .map(asUnknownRecord)
143 const selectedPage = [150 .find((tab) => normalizedHttpUrl(tab?.url) !== undefined);
144 { record: screenshot, url: screenshot?.pageUrl },151 const selectedPage = [
145 { record: browserUse, url: browserUse?.url },152 { record: screenshot, url: screenshot?.pageUrl },
146 { record: latestOpenTab, url: latestOpenTab?.url },153 { record: browserUse, url: browserUse?.url },
147 ]154 { record: latestOpenTab, url: latestOpenTab?.url },
148 .map((candidate) => ({ ...candidate, pageUrl: normalizedHttpUrl(candidate.url) }))155 ]
149 .find((candidate) => candidate.pageUrl !== undefined);156 .map((candidate) => ({ ...candidate, pageUrl: normalizedHttpUrl(candidate.url) }))
150 const pageUrl = selectedPage?.pageUrl;157 .find((candidate) => candidate.pageUrl !== undefined);
151 const faviconUrl = normalizedImageUrl(158 const pageUrl = selectedPage?.pageUrl;
152 selectedPage?.record?.faviconUrl ?? selectedPage?.record?.favIconUrl,159 const faviconUrl = normalizedImageUrl(
153 );160 selectedPage?.record?.faviconUrl ?? selectedPage?.record?.favIconUrl,
154 const faviconUrlDark = normalizedImageUrl(161 );
155 selectedPage?.record?.faviconUrlDark ?? selectedPage?.record?.favIconUrlDark,162 const faviconUrlDark = normalizedImageUrl(
156 );163 selectedPage?.record?.faviconUrlDark ?? selectedPage?.record?.favIconUrlDark,
157 const name =164 );
158 browserDisplayName(appContext?.appName) ??165 const name =
159 browserDisplayName(surface.browserFamily) ??166 browserDisplayName(appContext?.appName) ??
160 browserDisplayName(surface.backend) ??167 browserDisplayName(surface.browserFamily) ??
161 "Browser";168 browserDisplayName(surface.backend) ??
162 const nativeBrowserIcon = browserNativeAppReference(name);169 "Browser";
163 const sourceIcon =170 const nativeBrowserIcon = browserNativeAppReference(name);
164 sourceLogo ??171 const sourceIcon =
165 (nativeBrowserIcon ? ({ _tag: "native-app", app: nativeBrowserIcon } as const) : undefined);172 sourceLogo ??
166 const sourceKeyPart = normalizedSourceKeyPart(name) || "browser";173 (nativeBrowserIcon ? ({ _tag: "native-app", app: nativeBrowserIcon } as const) : undefined);
167 return {174 const sourceKeyPart = normalizedSourceKeyPart(name) || "browser";
168 toolSurface: "browser",175 return {
169 ...(pageUrl176 toolSurface: "browser",
170 ? {177 ...(pageUrl
171 toolIcon: {178 ? {
172 _tag: "website",179 toolIcon: {
173 pageUrl,180 _tag: "website",
174 ...(faviconUrl ? { faviconUrl } : {}),181 pageUrl,
175 ...(faviconUrlDark ? { faviconUrlDark } : {}),182 ...(faviconUrl ? { faviconUrl } : {}),
176 } as const,183 ...(faviconUrlDark ? { faviconUrlDark } : {}),
177 }184 } as const,
178 : {}),185 }
179 toolSource: {186 : {}),
180 key: `browser-use:${sourceKeyPart}`,187 toolSource: {
181 name,188 key: `browser-use:${sourceKeyPart}`,
182 kind: name === "Browser" ? "browser" : "integration",189 name,
183 ...(sourceIcon ? { icon: sourceIcon } : {}),190 kind: name === "Browser" ? "browser" : "integration",
184 },191 ...(sourceIcon ? { icon: sourceIcon } : {}),
185 };192 },
186 }193 };
187 if (surface?.kind === "computerUse") {194 }
188 const app = nativeAppReference(surface.app);195 if (surface?.kind === "computerUse") {
189 const args = asUnknownRecord(item.arguments);196 const app = nativeAppReference(surface.app);
190 const argumentAppName =197 const args = asUnknownRecord(item.arguments);
191 normalizedDisplayName(args?.appName) ??198 const argumentAppName =
192 normalizedDisplayName(args?.application) ??199 normalizedDisplayName(args?.appName) ??
193 normalizedDisplayName(typeof args?.app === "string" ? args.app : undefined);200 normalizedDisplayName(args?.application) ??
194 const name =201 normalizedDisplayName(typeof args?.app === "string" ? args.app : undefined);
195 normalizedDisplayName(appContext?.appName) ??202 const name =
196 argumentAppName ??203 normalizedDisplayName(appContext?.appName) ??
197 (app?._tag === "display-name" ? app.displayName : undefined) ??204 argumentAppName ??
198 (app?._tag === "app-id" ? appDisplayNameFromId(app.appId) : undefined) ??205 (app?._tag === "display-name" ? app.displayName : undefined) ??
199 "Computer Use";206 (app?._tag === "app-id" ? appDisplayNameFromId(app.appId) : undefined) ??
200 const sourceIcon = sourceLogo ?? (app ? ({ _tag: "native-app", app } as const) : undefined);207 "Computer Use";
201 const sourceKey = app208 const sourceIcon = sourceLogo ?? (app ? ({ _tag: "native-app", app } as const) : undefined);
202 ? app._tag === "app-id"209 const sourceKey = app
203 ? nativeAppSourceKey(app.appId)210 ? app._tag === "app-id"
204 : `native-app-name:${normalizedSourceKeyPart(app.displayName)}`211 ? yield* nativeAppSourceKey(app.appId)
205 : "computer-use";212 : `native-app-name:${normalizedSourceKeyPart(app.displayName)}`
206 return {213 : "computer-use";
207 toolSurface: "computer",214 return {
208 ...(app ? { toolIcon: { _tag: "native-app", app } as const } : {}),215 toolSurface: "computer",
209 toolSource: {216 ...(app ? { toolIcon: { _tag: "native-app", app } as const } : {}),
210 key: sourceKey,217 toolSource: {
211 name,218 key: sourceKey,
212 kind: "computer",219 name,
213 ...(sourceIcon ? { icon: sourceIcon } : {}),220 kind: "computer",
214 },221 ...(sourceIcon ? { icon: sourceIcon } : {}),
215 };222 },
216 }223 };
224 }
217225
218 return integrationToolPresentation({226 return integrationToolPresentation({
219 serverName: appContext?.connectorId ?? item.server,227 serverName: appContext?.connectorId ?? item.server,
220 toolName: item.tool,228 toolName: item.tool,
221 title: appContext?.actionName,229 title: appContext?.actionName,
222 serverDisplayName: appContext?.appName,230 serverDisplayName: appContext?.appName,
223 source: sourceMetadata,231 source: sourceMetadata,
224 iconUrl: sourceLogo?._tag === "themed-logo" ? sourceLogo.logoUrl : undefined,232 iconUrl: sourceLogo?._tag === "themed-logo" ? sourceLogo.logoUrl : undefined,
225 iconUrlDark: sourceLogo?._tag === "themed-logo" ? sourceLogo.logoUrlDark : undefined,233 iconUrlDark: sourceLogo?._tag === "themed-logo" ? sourceLogo.logoUrlDark : undefined,
226 });234 });
227}235 },
236);
apps/server/src/provider/OpenCodeProvider.test.ts+2-2
...@@ -1,5 +1,4 @@...@@ -1,5 +1,4 @@
1import * as NodeAssert from "node:assert/strict";1import * as NodeAssert from "node:assert/strict";
2import * as NodeCrypto from "node:crypto";
32
4import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
5import { it } from "@effect/vitest";4import { it } from "@effect/vitest";
...@@ -81,7 +80,8 @@ it.effect("reads Go limits with the instance's XDG credentials and preserves res...@@ -81,7 +80,8 @@ it.effect("reads Go limits with the instance's XDG credentials and preserves res
81 NodeAssert.equal(limits.unavailable, undefined);80 NodeAssert.equal(limits.unavailable, undefined);
82 NodeAssert.equal(81 NodeAssert.equal(
83 limits.credentialFingerprint,82 limits.credentialFingerprint,
84 NodeCrypto.createHash("sha256").update("opencode-go\0instance-key").digest("hex"),83 // SHA-256 of "opencode-go\0instance-key".
84 "aba48e85c981a8edc1c9fb4575121accc7235fa55d0f8689f66f153de5566a37",
85 );85 );
86 NodeAssert.deepEqual(86 NodeAssert.deepEqual(
87 limits.windows.map(({ kind, usedPercent, resetsAt: reset }) => ({87 limits.windows.map(({ kind, usedPercent, resetsAt: reset }) => ({
apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts+11-5
...@@ -1,10 +1,11 @@...@@ -1,10 +1,11 @@
1import * as NodeCrypto from "node:crypto";
2import type {1import type {
3 AcpRegistrySettings,2 AcpRegistrySettings,
4 ProviderInstanceEnvironment,3 ProviderInstanceEnvironment,
5 ProviderInstanceId,4 ProviderInstanceId,
6} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import * as Crypto from "effect/Crypto";
7import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
8import * as Hex from "effect/encoding/Hex";
8import * as FileSystem from "effect/FileSystem";9import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";10import * as Path from "effect/Path";
10import * as Ref from "effect/Ref";11import * as Ref from "effect/Ref";
...@@ -16,8 +17,13 @@ import { writeFileStringAtomically } from "../../atomicWrite.ts";...@@ -16,8 +17,13 @@ import { writeFileStringAtomically } from "../../atomicWrite.ts";
16const decodeState = Schema.decodeUnknownEffect(17const decodeState = Schema.decodeUnknownEffect(
17 Schema.fromJsonString(Schema.Struct({ binding: Schema.String, authenticated: Schema.Boolean })),18 Schema.fromJsonString(Schema.Struct({ binding: Schema.String, authenticated: Schema.Boolean })),
18);19);
19const hash = (value: unknown) =>20const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
20 NodeCrypto.createHash("sha256").update(JSON.stringify(value)).digest("hex");21const hash = Effect.fn("AcpRegistryAuthenticationState.hash")(function* (value: unknown) {
22 const crypto = yield* Crypto.Crypto;
23 const json = yield* encodeJson(value).pipe(Effect.orDie);
24 const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(json)).pipe(Effect.orDie);
25 return Hex.encode(digest);
26});
2127
22/** Remember explicit sign-in success, never discovery success or the agent's credentials. */28/** Remember explicit sign-in success, never discovery success or the agent's credentials. */
23export const makeAcpRegistryAuthenticationState = Effect.fn("makeAcpRegistryAuthenticationState")(29export const makeAcpRegistryAuthenticationState = Effect.fn("makeAcpRegistryAuthenticationState")(
...@@ -30,10 +36,10 @@ export const makeAcpRegistryAuthenticationState = Effect.fn("makeAcpRegistryAuth...@@ -30,10 +36,10 @@ export const makeAcpRegistryAuthenticationState = Effect.fn("makeAcpRegistryAuth
30 }) {36 }) {
31 const fs = yield* FileSystem.FileSystem;37 const fs = yield* FileSystem.FileSystem;
32 const path = yield* Path.Path;38 const path = yield* Path.Path;
33 const filePath = path.join(input.cacheDir, `acp-auth-${hash(input.instanceId)}.json`);39 const filePath = path.join(input.cacheDir, `acp-auth-${yield* hash(input.instanceId)}.json`);
34 // Cosmetic settings and model discovery can rebuild the driver without40 // Cosmetic settings and model discovery can rebuild the driver without
35 // changing the account. Credential overrides and profile paths cannot.41 // changing the account. Credential overrides and profile paths cannot.
36 const binding = hash({42 const binding = yield* hash({
37 ...(input.settings.source === "local"43 ...(input.settings.source === "local"
38 ? { source: "local", commandArgs: input.settings.commandArgs }44 ? { source: "local", commandArgs: input.settings.commandArgs }
39 : {}),45 : {}),
apps/server/src/provider/acp/AcpRegistrySupport.test.ts+4-3
...@@ -16,7 +16,6 @@ import * as Layer from "effect/Layer";...@@ -16,7 +16,6 @@ import * as Layer from "effect/Layer";
16import * as Schema from "effect/Schema";16import * as Schema from "effect/Schema";
17import { HttpClient, HttpClientResponse } from "effect/http";17import { HttpClient, HttpClientResponse } from "effect/http";
18import * as TestClock from "effect/testing/TestClock";18import * as TestClock from "effect/testing/TestClock";
19import * as NodeCrypto from "node:crypto";
20import { ChildProcess, ChildProcessSpawner } from "effect/process";19import { ChildProcess, ChildProcessSpawner } from "effect/process";
2120
22import * as AcpRegistrySupport from "./AcpRegistrySupport.ts";21import * as AcpRegistrySupport from "./AcpRegistrySupport.ts";
...@@ -587,7 +586,8 @@ describe("AcpRegistrySupport", () => {...@@ -587,7 +586,8 @@ describe("AcpRegistrySupport", () => {
587 archive: archiveUrl,586 archive: archiveUrl,
588 cmd: "./bin/example-agent",587 cmd: "./bin/example-agent",
589 args: ["acp"],588 args: ["acp"],
590 sha256: NodeCrypto.createHash("sha256").update(binaryBytes).digest("hex"),589 // SHA-256 of binaryBytes.
590 sha256: "97b29a636d7ddf7bf3567ae4d48c0f2a9b03943fd808b6efbf74f1dd3db131b7",
591 },591 },
592 },592 },
593 });593 });
...@@ -646,7 +646,8 @@ describe("AcpRegistrySupport", () => {...@@ -646,7 +646,8 @@ describe("AcpRegistrySupport", () => {
646 archive: archiveUrl,646 archive: archiveUrl,
647 cmd: "./bin/example-agent",647 cmd: "./bin/example-agent",
648 args: ["acp"],648 args: ["acp"],
649 sha256: NodeCrypto.createHash("sha256").update(binaryBytes).digest("hex"),649 // SHA-256 of binaryBytes.
650 sha256: "e1eb7c905b91c400af2ec858628aec22866bfda1383b93ceb73ae01d9769fb31",
650 },651 },
651 },652 },
652 });653 });
apps/server/src/provider/acp/AcpRegistrySupport.ts+33-27
...@@ -25,7 +25,9 @@ import {...@@ -25,7 +25,9 @@ import {
25import * as Clock from "effect/Clock";25import * as Clock from "effect/Clock";
26import * as Context from "effect/Context";26import * as Context from "effect/Context";
27import * as Duration from "effect/Duration";27import * as Duration from "effect/Duration";
28import * as Crypto from "effect/Crypto";
28import * as Effect from "effect/Effect";29import * as Effect from "effect/Effect";
30import * as Hex from "effect/encoding/Hex";
29import * as FileSystem from "effect/FileSystem";31import * as FileSystem from "effect/FileSystem";
30import * as Layer from "effect/Layer";32import * as Layer from "effect/Layer";
31import * as Option from "effect/Option";33import * as Option from "effect/Option";
...@@ -35,9 +37,9 @@ import * as Ref from "effect/Ref";...@@ -35,9 +37,9 @@ import * as Ref from "effect/Ref";
35import * as Schema from "effect/Schema";37import * as Schema from "effect/Schema";
36import * as Semaphore from "effect/Semaphore";38import * as Semaphore from "effect/Semaphore";
37import * as Stream from "effect/Stream";39import * as Stream from "effect/Stream";
40import { sha256 } from "@noble/hashes/sha2";
38import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";41import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
39import { ChildProcess, ChildProcessSpawner } from "effect/process";42import { ChildProcess, ChildProcessSpawner } from "effect/process";
40import * as NodeCrypto from "node:crypto";
4143
42import { collectUint8StreamText } from "../../stream/collectUint8StreamText.ts";44import { collectUint8StreamText } from "../../stream/collectUint8StreamText.ts";
43import * as ServerSettings from "../../serverSettings.ts";45import * as ServerSettings from "../../serverSettings.ts";
...@@ -597,11 +599,15 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct...@@ -597,11 +599,15 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct
597 AcpRegistryCatalog["Service"],599 AcpRegistryCatalog["Service"],
598 never,600 never,
599 | ChildProcessSpawner.ChildProcessSpawner601 | ChildProcessSpawner.ChildProcessSpawner
602 | Crypto.Crypto
600 | FileSystem.FileSystem603 | FileSystem.FileSystem
601 | HttpClient.HttpClient604 | HttpClient.HttpClient
602 | Path.Path605 | Path.Path
603 | ServerSettings.ServerSettingsService606 | ServerSettings.ServerSettingsService
604> {607> {
608 const crypto = yield* Crypto.Crypto;
609 const sha256Hex = (data: Uint8Array) =>
610 crypto.digest("SHA-256", data).pipe(Effect.map(Hex.encode), Effect.orDie);
605 const fileSystem = yield* FileSystem.FileSystem;611 const fileSystem = yield* FileSystem.FileSystem;
606 const path = yield* Path.Path;612 const path = yield* Path.Path;
607 const httpClient = yield* HttpClient.HttpClient;613 const httpClient = yield* HttpClient.HttpClient;
...@@ -731,17 +737,20 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct...@@ -731,17 +737,20 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct
731 .readFileString(registryCachePath)737 .readFileString(registryCachePath)
732 .pipe(Effect.flatMap(decodeRegistryText), Effect.option);738 .pipe(Effect.flatMap(decodeRegistryText), Effect.option);
733739
734 const writeRegistryCache = (text: string) => {740 const writeRegistryCache = (text: string) =>
735 const temporaryPath = `${registryCachePath}.${process.pid}-${NodeCrypto.randomUUID()}.tmp`;741 crypto.randomUUIDv4.pipe(
736 return fileSystem742 Effect.flatMap((id) => {
737 .makeDirectory(registryDirectory, { recursive: true })743 const temporaryPath = `${registryCachePath}.${process.pid}-${id}.tmp`;
738 .pipe(744 return fileSystem
739 Effect.andThen(fileSystem.writeFileString(temporaryPath, text)),745 .makeDirectory(registryDirectory, { recursive: true })
740 Effect.andThen(fileSystem.rename(temporaryPath, registryCachePath)),746 .pipe(
741 Effect.ensuring(fileSystem.remove(temporaryPath, { force: true }).pipe(Effect.ignore)),747 Effect.andThen(fileSystem.writeFileString(temporaryPath, text)),
742 Effect.ignore,748 Effect.andThen(fileSystem.rename(temporaryPath, registryCachePath)),
743 );749 Effect.ensuring(fileSystem.remove(temporaryPath, { force: true }).pipe(Effect.ignore)),
744 };750 );
751 }),
752 Effect.ignore,
753 );
745754
746 const fetchRegistry = Effect.fn("AcpRegistryCatalog.fetchRegistry")(function* () {755 const fetchRegistry = Effect.fn("AcpRegistryCatalog.fetchRegistry")(function* () {
747 yield* assertHttpsUrl(registryUrl, "ACP Registry index URL must use HTTPS.");756 yield* assertHttpsUrl(registryUrl, "ACP Registry index URL must use HTTPS.");
...@@ -928,17 +937,12 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct...@@ -928,17 +937,12 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct
928 );937 );
929 });938 });
930939
931 const packageReceiptPath = (940 const packageReceiptPath = (agentId: string, distribution: "npx" | "uvx", managerPath: string) =>
932 agentId: string,941 sha256Hex(new TextEncoder().encode(`${distribution}\0${managerPath}`)).pipe(
933 distribution: "npx" | "uvx",942 Effect.map((digest) =>
934 managerPath: string,943 path.join(packageReceiptsDirectory, `${agentId}-${digest.slice(0, 16)}.json`),
935 ) => {944 ),
936 const managerId = NodeCrypto.createHash("sha256")945 );
937 .update(`${distribution}\0${managerPath}`)
938 .digest("hex")
939 .slice(0, 16);
940 return path.join(packageReceiptsDirectory, `${agentId}-${managerId}.json`);
941 };
942946
943 const readPackageReceipt = Effect.fn("AcpRegistryCatalog.readPackageReceipt")(function* (947 const readPackageReceipt = Effect.fn("AcpRegistryCatalog.readPackageReceipt")(function* (
944 agent: AcpRegistryAgent,948 agent: AcpRegistryAgent,
...@@ -947,7 +951,7 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct...@@ -947,7 +951,7 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct
947 managerPath: string,951 managerPath: string,
948 ) {952 ) {
949 const receipt = yield* fileSystem953 const receipt = yield* fileSystem
950 .readFileString(packageReceiptPath(agent.id, distribution, managerPath))954 .readFileString(yield* packageReceiptPath(agent.id, distribution, managerPath))
951 .pipe(955 .pipe(
952 Effect.map(decodePackageInstallReceipt),956 Effect.map(decodePackageInstallReceipt),
953 Effect.orElseSucceed(() => Option.none()),957 Effect.orElseSucceed(() => Option.none()),
...@@ -991,7 +995,7 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct...@@ -991,7 +995,7 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct
991995
992 const writePackageReceipt = Effect.fn("AcpRegistryCatalog.writePackageReceipt")(996 const writePackageReceipt = Effect.fn("AcpRegistryCatalog.writePackageReceipt")(
993 function* (receipt: AcpRegistryPackageInstallReceipt) {997 function* (receipt: AcpRegistryPackageInstallReceipt) {
994 const receiptPath = packageReceiptPath(998 const receiptPath = yield* packageReceiptPath(
995 receipt.agentId,999 receipt.agentId,
996 receipt.distribution,1000 receipt.distribution,
997 receipt.managerPath,1001 receipt.managerPath,
...@@ -1409,7 +1413,9 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct...@@ -1409,7 +1413,9 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct
1409 ),1413 ),
1410 }),1414 }),
1411 );1415 );
1412 const hash = NodeCrypto.createHash("sha256");1416 // Hash while streaming: archives can be up to MAX_ARCHIVE_BYTES, and
1417 // Effect's Crypto only digests a whole buffer.
1418 const hash = sha256.create();
1413 let downloadedBytes = 0;1419 let downloadedBytes = 0;
1414 yield* response.stream.pipe(1420 yield* response.stream.pipe(
1415 Stream.tap((chunk) => {1421 Stream.tap((chunk) => {
...@@ -1447,7 +1453,7 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct...@@ -1447,7 +1453,7 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct
1447 }),1453 }),
1448 );1454 );
1449 if (target.sha256 !== undefined) {1455 if (target.sha256 !== undefined) {
1450 const actual = hash.digest("hex");1456 const actual = Hex.encode(hash.digest());
1451 if (actual !== target.sha256.toLowerCase()) {1457 if (actual !== target.sha256.toLowerCase()) {
1452 return yield* new AcpRegistryError({1458 return yield* new AcpRegistryError({
1453 reason: "checksum_mismatch",1459 reason: "checksum_mismatch",
apps/server/src/provider/opencode2/OpenCode2Client.live.test.ts+3-2
...@@ -10,6 +10,7 @@...@@ -10,6 +10,7 @@
10import * as NodeServices from "@effect/platform-node/NodeServices";10import * as NodeServices from "@effect/platform-node/NodeServices";
11import { AbsolutePath, Location, Model, Provider } from "@opencode/client/effect";11import { AbsolutePath, Location, Model, Provider } from "@opencode/client/effect";
12import { assert, it } from "@effect/vitest";12import { assert, it } from "@effect/vitest";
13import * as Crypto from "effect/Crypto";
13import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
14import * as Base64Url from "effect/encoding/Base64Url";15import * as Base64Url from "effect/encoding/Base64Url";
15import * as Fiber from "effect/Fiber";16import * as Fiber from "effect/Fiber";
...@@ -21,7 +22,6 @@ import * as Path from "effect/Path";...@@ -21,7 +22,6 @@ import * as Path from "effect/Path";
21import * as Stream from "effect/Stream";22import * as Stream from "effect/Stream";
22import { FetchHttpClient } from "effect/http";23import { FetchHttpClient } from "effect/http";
23import { ChildProcess, ChildProcessSpawner } from "effect/process";24import { ChildProcess, ChildProcessSpawner } from "effect/process";
24import * as NodeCrypto from "node:crypto";
25import { describe } from "vite-plus/test";25import { describe } from "vite-plus/test";
2626
27import * as OpenCode2Client from "./OpenCode2Client.ts";27import * as OpenCode2Client from "./OpenCode2Client.ts";
...@@ -36,7 +36,8 @@ const startServer = Effect.fn("OpenCode2ClientLive.startServer")(function* (bina...@@ -36,7 +36,8 @@ const startServer = Effect.fn("OpenCode2ClientLive.startServer")(function* (bina
36 const directory = path.join(root, "work");36 const directory = path.join(root, "work");
37 yield* fs.makeDirectory(directory);37 yield* fs.makeDirectory(directory);
38 // Non-ASCII on purpose: OpenCode decodes Basic credentials as UTF-8.38 // Non-ASCII on purpose: OpenCode decodes Basic credentials as UTF-8.
39 const password = `${Base64Url.encode(NodeCrypto.randomBytes(32))}-pässwörd€`;39 const crypto = yield* Crypto.Crypto;
40 const password = `${Base64Url.encode(yield* crypto.randomBytes(32))}-pässwörd€`;
40 const child = yield* spawner.spawn(41 const child = yield* spawner.spawn(
41 ChildProcess.make(binary, ["serve", "--hostname=127.0.0.1", "--port=0"], {42 ChildProcess.make(binary, ["serve", "--hostname=127.0.0.1", "--port=0"], {
42 cwd: directory,43 cwd: directory,
apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts+1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createHmac.
1import * as NodeCrypto from "node:crypto";2import * as NodeCrypto from "node:crypto";
23
3import * as NodePlatformCrypto from "@effect/platform-node/NodeCrypto";4import * as NodePlatformCrypto from "@effect/platform-node/NodeCrypto";
apps/server/src/scheduledTasks/webhookRoute.test.ts+1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync.
2import * as NodeCrypto from "node:crypto";3import * as NodeCrypto from "node:crypto";
3import * as Clock from "effect/Clock";4import * as Clock from "effect/Clock";
4import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
apps/server/src/scheduledTasks/webhookVerification.test.ts+1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createHmac.
1import * as NodeCrypto from "node:crypto";2import * as NodeCrypto from "node:crypto";
23
3import { assert, describe, it } from "@effect/vitest";4import { assert, describe, it } from "@effect/vitest";
apps/server/src/scheduledTasks/webhookVerification.ts+1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createHmac or timingSafeEqual.
1import * as NodeCrypto from "node:crypto";2import * as NodeCrypto from "node:crypto";
23
3import type { ScheduledTaskWebhookSignature } from "@t3tools/contracts";4import type { ScheduledTaskWebhookSignature } from "@t3tools/contracts";
apps/server/src/secrets/SecretRequests.ts+1
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16 type SecretRequestAnswerInput,16 type SecretRequestAnswerInput,
17 type ThreadId,17 type ThreadId,
18} from "@t3tools/contracts";18} from "@t3tools/contracts";
19// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createHmac.
19import * as NodeCrypto from "node:crypto";20import * as NodeCrypto from "node:crypto";
2021
21import * as Clock from "effect/Clock";22import * as Clock from "effect/Clock";
apps/server/src/telemetry/Identify.test.ts+11-6
...@@ -1,7 +1,8 @@...@@ -1,7 +1,8 @@
1import * as NodeCrypto from "node:crypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";2import { assert, it } from "@effect/vitest";
3import * as Crypto from "effect/Crypto";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Hex from "effect/encoding/Hex";
5import * as FileSystem from "effect/FileSystem";6import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
7import * as Logger from "effect/Logger";8import * as Logger from "effect/Logger";
...@@ -17,8 +18,12 @@ interface CapturedLog {...@@ -17,8 +18,12 @@ interface CapturedLog {
17 readonly annotations: Readonly<Record<string, unknown>>;18 readonly annotations: Readonly<Record<string, unknown>>;
18}19}
1920
20const sha256 = (value: string) =>21const sha256 = Effect.fn("test.sha256")(function* (value: string) {
21 NodeCrypto.createHash("sha256").update(value, "utf8").digest("hex");22 const crypto = yield* Crypto.Crypto;
23 return Hex.encode(
24 yield* crypto.digest("SHA-256", new TextEncoder().encode(value)).pipe(Effect.orDie),
25 );
26});
2227
23const makeCaptureLogger = (logs: CapturedLog[]) =>28const makeCaptureLogger = (logs: CapturedLog[]) =>
24 Logger.make(({ fiber, message }) => {29 Logger.make(({ fiber, message }) => {
...@@ -48,7 +53,7 @@ it.layer(NodeServices.layer)("telemetry identity", (it) => {...@@ -48,7 +53,7 @@ it.layer(NodeServices.layer)("telemetry identity", (it) => {
48 path.join(config.baseDir, "home"),53 path.join(config.baseDir, "home"),
49 );54 );
5055
51 assert.equal(identifier, sha256(anonymousId));56 assert.equal(identifier, yield* sha256(anonymousId));
52 }).pipe(57 }).pipe(
53 Effect.provide(58 Effect.provide(
54 ServerConfig.layerTest(process.cwd(), {59 ServerConfig.layerTest(process.cwd(), {
...@@ -132,7 +137,7 @@ it.layer(NodeServices.layer)("telemetry identity", (it) => {...@@ -132,7 +137,7 @@ it.layer(NodeServices.layer)("telemetry identity", (it) => {
132137
133 const identifier = yield* Identify.getTelemetryIdentifierForHome(homeDirectory);138 const identifier = yield* Identify.getTelemetryIdentifierForHome(homeDirectory);
134139
135 assert.equal(identifier, sha256(anonymousId));140 assert.equal(identifier, yield* sha256(anonymousId));
136 assert.isUndefined(findIdentityLog(logs, "codex", "TelemetryIdentityDecodeError"));141 assert.isUndefined(findIdentityLog(logs, "codex", "TelemetryIdentityDecodeError"));
137 assert.isUndefined(findIdentityLog(logs, "codex", "TelemetryIdentityReadError"));142 assert.isUndefined(findIdentityLog(logs, "codex", "TelemetryIdentityReadError"));
138 const allLogs = logs143 const allLogs = logs
...@@ -175,7 +180,7 @@ it.layer(NodeServices.layer)("telemetry identity", (it) => {...@@ -175,7 +180,7 @@ it.layer(NodeServices.layer)("telemetry identity", (it) => {
175180
176 const identifier = yield* Identify.getTelemetryIdentifierForHome(homeDirectory);181 const identifier = yield* Identify.getTelemetryIdentifierForHome(homeDirectory);
177182
178 assert.equal(identifier, sha256(anonymousId));183 assert.equal(identifier, yield* sha256(anonymousId));
179 const decodeLog = findIdentityLog(logs, "codex", "TelemetryIdentityDecodeError");184 const decodeLog = findIdentityLog(logs, "codex", "TelemetryIdentityDecodeError");
180 assert.isDefined(decodeLog);185 assert.isDefined(decodeLog);
181 assert.equal(186 assert.equal(
apps/server/src/usage/cliproxyApi.test.ts+8-3
...@@ -1,10 +1,11 @@...@@ -1,10 +1,11 @@
1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
1import { describe, expect, it } from "@effect/vitest";2import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";4import * as Schema from "effect/Schema";
4import * as TestClock from "effect/testing/TestClock";5import * as TestClock from "effect/testing/TestClock";
5import { HttpClient, HttpClientResponse } from "effect/http";6import { HttpClient, HttpClientResponse } from "effect/http";
67
7import { creditRedeemRequestId, makeCliproxyApi } from "./cliproxyApi.ts";8import { makeCliproxyApi } from "./cliproxyApi.ts";
89
9const config = {10const config = {
10 kind: "cliproxy",11 kind: "cliproxy",
...@@ -106,7 +107,10 @@ function fixture(...@@ -106,7 +107,10 @@ function fixture(
106 );107 );
107 return {108 return {
108 requests,109 requests,
109 api: makeCliproxyApi.pipe(Effect.provideService(HttpClient.HttpClient, http)),110 api: makeCliproxyApi.pipe(
111 Effect.provideService(HttpClient.HttpClient, http),
112 Effect.provide(NodeCrypto.layer),
113 ),
110 };114 };
111}115}
112116
...@@ -222,7 +226,8 @@ describe("CLIProxyAPI built-in management API", () => {...@@ -222,7 +226,8 @@ describe("CLIProxyAPI built-in management API", () => {
222 expect(redemptions[0]?.body?.data).toBe(redemptions[1]?.body?.data);226 expect(redemptions[0]?.body?.data).toBe(redemptions[1]?.body?.data);
223 expect(redemptions[0]?.body?.data).toBe(227 expect(redemptions[0]?.body?.data).toBe(
224 encodeJson({228 encodeJson({
225 redeem_request_id: creditRedeemRequestId("account-b", "credit-b"),229 // UUIDv5 of "account-b:credit-b"; must stay stable so retries deduplicate.
230 redeem_request_id: "519d5243-011a-5b7b-91f3-44d85f095705",
226 credit_id: "credit-b",231 credit_id: "credit-b",
227 }),232 }),
228 );233 );
apps/server/src/usage/cliproxyApi.ts+22-12
...@@ -1,5 +1,3 @@...@@ -1,5 +1,3 @@
1import * as NodeCrypto from "node:crypto";
2
3import {1import {
4 ProviderDriverKind,2 ProviderDriverKind,
5 UsageLimitSourceError,3 UsageLimitSourceError,
...@@ -7,8 +5,10 @@ import {...@@ -7,8 +5,10 @@ import {
7 type UsageLimitSourceAccount,5 type UsageLimitSourceAccount,
8 type UsageLimitSourceConfig,6 type UsageLimitSourceConfig,
9} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import * as Crypto from "effect/Crypto";
10import * as DateTime from "effect/DateTime";9import * as DateTime from "effect/DateTime";
11import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
11import * as Hex from "effect/encoding/Hex";
12import * as Schema from "effect/Schema";12import * as Schema from "effect/Schema";
13import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";13import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
1414
...@@ -99,21 +99,31 @@ const decodeConsumeResponse = Schema.decodeUnknownEffect(...@@ -99,21 +99,31 @@ const decodeConsumeResponse = Schema.decodeUnknownEffect(
99const CODEX_BASE = "https://chatgpt.com/backend-api/wham";99const CODEX_BASE = "https://chatgpt.com/backend-api/wham";
100const CREDIT_URL = `${CODEX_BASE}/rate-limit-reset-credits`;100const CREDIT_URL = `${CODEX_BASE}/rate-limit-reset-credits`;
101101
102// 6f1c2a9e-2d4b-4c1e-9a7f-3b8d5e0c1a42
103const CREDIT_REDEEM_NAMESPACE = new Uint8Array([
104 0x6f, 0x1c, 0x2a, 0x9e, 0x2d, 0x4b, 0x4c, 0x1e, 0x9a, 0x7f, 0x3b, 0x8d, 0x5e, 0x0c, 0x1a, 0x42,
105]);
106
102// UUIDv5 per account and credit also deduplicates retries across T3 environments.107// UUIDv5 per account and credit also deduplicates retries across T3 environments.
103export function creditRedeemRequestId(accountId: string, creditId: string): string {108const creditRedeemRequestId = Effect.fn("CliproxyApi.creditRedeemRequestId")(function* (
104 const bytes = NodeCrypto.createHash("sha1")109 accountId: string,
105 .update(Buffer.from("6f1c2a9e2d4b4c1e9a7f3b8d5e0c1a42", "hex"))110 creditId: string,
106 .update(`${accountId}:${creditId}`)111) {
107 .digest()112 const crypto = yield* Crypto.Crypto;
108 .subarray(0, 16);113 const name = new TextEncoder().encode(`${accountId}:${creditId}`);
114 const input = new Uint8Array(CREDIT_REDEEM_NAMESPACE.length + name.length);
115 input.set(CREDIT_REDEEM_NAMESPACE);
116 input.set(name, CREDIT_REDEEM_NAMESPACE.length);
117 const bytes = (yield* crypto.digest("SHA-1", input).pipe(Effect.orDie)).slice(0, 16);
109 bytes[6] = (bytes[6]! & 0x0f) | 0x50;118 bytes[6] = (bytes[6]! & 0x0f) | 0x50;
110 bytes[8] = (bytes[8]! & 0x3f) | 0x80;119 bytes[8] = (bytes[8]! & 0x3f) | 0x80;
111 const hex = bytes.toString("hex");120 const hex = Hex.encode(bytes);
112 return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;121 return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
113}122});
114123
115export const makeCliproxyApi = Effect.gen(function* () {124export const makeCliproxyApi = Effect.gen(function* () {
116 const client = yield* HttpClient.HttpClient;125 const client = yield* HttpClient.HttpClient;
126 const crypto = yield* Crypto.Crypto;
117127
118 const management = Effect.fn("CliproxyApi.management")(function* (128 const management = Effect.fn("CliproxyApi.management")(function* (
119 config: UsageLimitSourceConfig,129 config: UsageLimitSourceConfig,
...@@ -323,10 +333,10 @@ export const makeCliproxyApi = Effect.gen(function* () {...@@ -323,10 +333,10 @@ export const makeCliproxyApi = Effect.gen(function* () {
323 });333 });
324 }334 }
325 const body = yield* apiCall(config, account, `${CREDIT_URL}/consume`, {335 const body = yield* apiCall(config, account, `${CREDIT_URL}/consume`, {
326 redeem_request_id: creditRedeemRequestId(336 redeem_request_id: yield* creditRedeemRequestId(
327 account.id_token?.chatgpt_account_id ?? account.id,337 account.id_token?.chatgpt_account_id ?? account.id,
328 creditId,338 creditId,
329 ),339 ).pipe(Effect.provideService(Crypto.Crypto, crypto)),
330 credit_id: creditId,340 credit_id: creditId,
331 });341 });
332 const response = yield* decodeConsumeResponse(body);342 const response = yield* decodeConsumeResponse(body);
apps/server/src/zipArchive.ts+1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import * as Effect from "effect/Effect";1import * as Effect from "effect/Effect";
2// @effect-diagnostics-next-line nodeBuiltinImport:off -- type-only, to adapt a Node Readable into Effect.
2import type * as NodeStream from "node:stream";3import type * as NodeStream from "node:stream";
3import * as Yauzl from "yauzl";4import * as Yauzl from "yauzl";
45
infra/relay/src/agentActivity/ApnsClient.test.ts+1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync.
1import * as NodeCrypto from "node:crypto";2import * as NodeCrypto from "node:crypto";
23
3import { EnvironmentId, ThreadId } from "@t3tools/contracts";4import { EnvironmentId, ThreadId } from "@t3tools/contracts";
infra/relay/src/agentActivity/ApnsDeliveries.test.ts+1
...@@ -4,6 +4,7 @@ import type {...@@ -4,6 +4,7 @@ import type {
4} from "@t3tools/contracts/relay";4} from "@t3tools/contracts/relay";
5import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto";5import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto";
6import { describe, expect, it } from "@effect/vitest";6import { describe, expect, it } from "@effect/vitest";
7// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync.
7import * as NodeCrypto from "node:crypto";8import * as NodeCrypto from "node:crypto";
8import * as Deferred from "effect/Deferred";9import * as Deferred from "effect/Deferred";
9import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
infra/relay/src/agentActivity/ApnsProviderTokens.test.ts+1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync.
1import * as NodeCrypto from "node:crypto";2import * as NodeCrypto from "node:crypto";
23
3import { describe, expect, it } from "@effect/vitest";4import { describe, expect, it } from "@effect/vitest";
infra/relay/src/agentActivity/FcmClient.test.ts+1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync or verify.
1import * as NodeCrypto from "node:crypto";2import * as NodeCrypto from "node:crypto";
2import { describe, expect, it } from "@effect/vitest";3import { describe, expect, it } from "@effect/vitest";
3import * as Deferred from "effect/Deferred";4import * as Deferred from "effect/Deferred";
infra/relay/src/agentActivity/apnsDeliveryJobs.ts+1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createHmac or timingSafeEqual.
1import * as NodeCrypto from "node:crypto";2import * as NodeCrypto from "node:crypto";
23
3import {4import {
infra/relay/src/agentActivity/apnsJwt.ts+1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createPrivateKey.
1import * as NodeCrypto from "node:crypto";2import * as NodeCrypto from "node:crypto";
23
3import { p256 } from "@noble/curves/nist";4import { p256 } from "@noble/curves/nist";
infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts+1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync or sign.
1import * as NodeCrypto from "node:crypto";2import * as NodeCrypto from "node:crypto";
23
3import { describe, expect, it } from "@effect/vitest";4import { describe, expect, it } from "@effect/vitest";
infra/relay/src/auth/RelayTokens.test.ts+1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync.
1import * as NodeCrypto from "node:crypto";2import * as NodeCrypto from "node:crypto";
23
3import { describe, expect, it } from "@effect/vitest";4import { describe, expect, it } from "@effect/vitest";
infra/relay/src/environments/EnvironmentConnector.test.ts+1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync or sign.
1import * as NodeCrypto from "node:crypto";2import * as NodeCrypto from "node:crypto";
2import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto";3import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto";
34
infra/relay/src/environments/EnvironmentLinker.test.ts+1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync or sign.
1import * as NodeCrypto from "node:crypto";2import * as NodeCrypto from "node:crypto";
2import type {3import type {
3 RelayEnvironmentLinkProofPayload,4 RelayEnvironmentLinkProofPayload,
infra/relay/src/environments/EnvironmentPublishSignatures.test.ts+1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync or sign.
1import * as NodeCrypto from "node:crypto";2import * as NodeCrypto from "node:crypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
3import type {4import type {
infra/relay/src/environments/ManagedEndpointProvider.test.ts+16-11
...@@ -1,4 +1,3 @@...@@ -1,4 +1,3 @@
1import * as NodeCrypto from "node:crypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
32
4import { describe, expect, it } from "@effect/vitest";3import { describe, expect, it } from "@effect/vitest";
...@@ -365,20 +364,26 @@ function layerProvider(...@@ -365,20 +364,26 @@ function layerProvider(
365 );364 );
366}365}
367366
367// First 16 hex chars of SHA-256(`dev_julius:${userId}:${environmentId}`), pinned so a
368// change to the endpoint naming scheme fails here instead of silently matching.
369const MANAGED_ENDPOINT_HASHES: Record<string, string> = {
370 "user_ABC:env_ABC": "d101ac68108a423e",
371 "user_ABC:env_shared": "d7e6356aaf8863aa",
372 "user_DEF:env_shared": "a2fc84ac1b8c1c35",
373};
374
375function expectedManagedEndpointHash(environmentId: string, userId: string): string {
376 const hash = MANAGED_ENDPOINT_HASHES[`${userId}:${environmentId}`];
377 if (hash === undefined) throw new Error(`No pinned hash for ${userId}:${environmentId}`);
378 return hash;
379}
380
368function expectedManagedHostname(environmentId: string, userId = "user_ABC"): string {381function expectedManagedHostname(environmentId: string, userId = "user_ABC"): string {
369 const hash = NodeCrypto.createHash("sha256")382 return `dev-julius-${expectedManagedEndpointHash(environmentId, userId)}.t3code.test`;
370 .update(`dev_julius:${userId}:${environmentId}`)
371 .digest("hex")
372 .slice(0, 16);
373 return `dev-julius-${hash}.t3code.test`;
374}383}
375384
376function expectedManagedTunnelName(environmentId: string, userId = "user_ABC"): string {385function expectedManagedTunnelName(environmentId: string, userId = "user_ABC"): string {
377 const hash = NodeCrypto.createHash("sha256")386 return `t3coderelay-managedendpoint-dev-julius-${expectedManagedEndpointHash(environmentId, userId)}`;
378 .update(`dev_julius:${userId}:${environmentId}`)
379 .digest("hex")
380 .slice(0, 16);
381 return `t3coderelay-managedendpoint-dev-julius-${hash}`;
382}387}
383388
384describe("ManagedEndpointProvider", () => {389describe("ManagedEndpointProvider", () => {
infra/relay/src/hooks/HookForwarder.test.ts+1
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";1import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
2import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
3// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync.
3import * as NodeCrypto from "node:crypto";4import * as NodeCrypto from "node:crypto";
4import * as EffectNodeCrypto from "@effect/platform-node/NodeCrypto";5import * as EffectNodeCrypto from "@effect/platform-node/NodeCrypto";
5import { describe, expect, it } from "@effect/vitest";6import { describe, expect, it } from "@effect/vitest";
infra/relay/src/http/Api.test.ts+1
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7import * as EnvironmentLinker from "../environments/EnvironmentLinker.ts";7import * as EnvironmentLinker from "../environments/EnvironmentLinker.ts";
8import * as RelayTokens from "../auth/RelayTokens.ts";8import * as RelayTokens from "../auth/RelayTokens.ts";
9import * as Devices from "../agentActivity/Devices.ts";9import * as Devices from "../agentActivity/Devices.ts";
10// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync.
10import * as NodeCrypto from "node:crypto";11import * as NodeCrypto from "node:crypto";
11import { createClerkClient, verifyToken } from "@clerk/backend";12import { createClerkClient, verifyToken } from "@clerk/backend";
12import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";13import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
packages/shared/src/desktopBootstrapToken.ts+1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createHmac or timingSafeEqual.
1import * as NodeCrypto from "node:crypto";2import * as NodeCrypto from "node:crypto";
23
3/**4/**
packages/shared/src/dpop.test.ts+1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no KeyObject, generateKeyPairSync, or sign.
1import * as NodeCrypto from "node:crypto";2import * as NodeCrypto from "node:crypto";
23
3import { assert, describe, it } from "@effect/vitest";4import { assert, describe, it } from "@effect/vitest";
packages/shared/src/relayJwt.test.ts+1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync.
1import * as NodeCrypto from "node:crypto";2import * as NodeCrypto from "node:crypto";
23
3import { describe, expect, it } from "@effect/vitest";4import { describe, expect, it } from "@effect/vitest";
packages/ssh/src/command.test.ts+25
...@@ -14,6 +14,7 @@ import {...@@ -14,6 +14,7 @@ import {
14 baseSshArgs,14 baseSshArgs,
15 getLastNonEmptyOutputLine,15 getLastNonEmptyOutputLine,
16 parseSshResolveOutput,16 parseSshResolveOutput,
17 remoteStateKey,
17 runSshCommand,18 runSshCommand,
18} from "./command.ts";19} from "./command.ts";
19import { SshCommandError } from "./errors.ts";20import { SshCommandError } from "./errors.ts";
...@@ -98,6 +99,30 @@ describe("ssh command", () => {...@@ -98,6 +99,30 @@ describe("ssh command", () => {
98 }),99 }),
99 );100 );
100101
102 // Remote servers store state under this key, so it must not change across releases.
103 it.effect("derives a stable remote state key", () =>
104 Effect.gen(function* () {
105 assert.equal(
106 yield* remoteStateKey({
107 alias: "devbox",
108 hostname: "devbox.example.com",
109 username: "julius",
110 port: 2222,
111 }),
112 "711bc738002d72fd",
113 );
114 assert.equal(
115 yield* remoteStateKey({
116 alias: "fixture",
117 hostname: "fixture",
118 username: null,
119 port: null,
120 }),
121 "326264c4f08c8a0c",
122 );
123 }).pipe(Effect.provide(NodeServices.layer)),
124 );
125
101 it.effect("reads the last non-empty ssh output line", () =>126 it.effect("reads the last non-empty ssh output line", () =>
102 Effect.sync(() => {127 Effect.sync(() => {
103 assert.equal(128 assert.equal(
packages/ssh/src/command.ts+12-8
...@@ -1,9 +1,9 @@...@@ -1,9 +1,9 @@
1import * as NodeCrypto from "node:crypto";
2
3import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts";1import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts";
4import { HostProcessPlatform } from "@t3tools/shared/hostProcess";2import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3import * as Crypto from "effect/Crypto";
5import * as Duration from "effect/Duration";4import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
6import * as Hex from "effect/encoding/Hex";
7import * as FileSystem from "effect/FileSystem";7import * as FileSystem from "effect/FileSystem";
8import * as Option from "effect/Option";8import * as Option from "effect/Option";
9import * as Path from "effect/Path";9import * as Path from "effect/Path";
...@@ -72,12 +72,16 @@ export function targetConnectionKey(target: DesktopSshEnvironmentTarget): string...@@ -72,12 +72,16 @@ export function targetConnectionKey(target: DesktopSshEnvironmentTarget): string
72 return `${target.alias}\u0000${target.hostname}\u0000${target.username ?? ""}\u0000${target.port ?? ""}`;72 return `${target.alias}\u0000${target.hostname}\u0000${target.username ?? ""}\u0000${target.port ?? ""}`;
73}73}
7474
75export function remoteStateKey(target: DesktopSshEnvironmentTarget): string {75/** Names the remote state directory for a target: the first 16 hex chars of its SHA-256 key. */
76 return NodeCrypto.createHash("sha256")76export const remoteStateKey = Effect.fn("ssh/command.remoteStateKey")(function* (
77 .update(targetConnectionKey(target))77 target: DesktopSshEnvironmentTarget,
78 .digest("hex")78): Effect.fn.Return<string, never, Crypto.Crypto> {
79 .slice(0, 16);79 const crypto = yield* Crypto.Crypto;
80}80 const digest = yield* crypto
81 .digest("SHA-256", encoder.encode(targetConnectionKey(target)))
82 .pipe(Effect.orDie);
83 return Hex.encode(digest).slice(0, 16);
84});
8185
82function buildSshHostSpec(target: DesktopSshEnvironmentTarget): string {86function buildSshHostSpec(target: DesktopSshEnvironmentTarget): string {
83 const destination = target.alias.trim() || target.hostname.trim();87 const destination = target.alias.trim() || target.hostname.trim();
packages/ssh/src/runnerProcess.test.ts+9-6
...@@ -11,6 +11,7 @@ import * as Stream from "effect/Stream";...@@ -11,6 +11,7 @@ import * as Stream from "effect/Stream";
11import { ChildProcess, ChildProcessSpawner } from "effect/process";11import { ChildProcess, ChildProcessSpawner } from "effect/process";
12import * as NodeNet from "node:net";12import * as NodeNet from "node:net";
1313
14import { remoteStateKey } from "./command.ts";
14import { buildRemoteStopScript, buildRemoteT3RunnerScript } from "./tunnel.ts";15import { buildRemoteStopScript, buildRemoteT3RunnerScript } from "./tunnel.ts";
1516
16const Started = Schema.Struct({17const Started = Schema.Struct({
...@@ -186,12 +187,14 @@ server.listen(0, "127.0.0.1", () => {...@@ -186,12 +187,14 @@ server.listen(0, "127.0.0.1", () => {
186 for (const [name, contents] of Object.entries(savedState)) {187 for (const [name, contents] of Object.entries(savedState)) {
187 yield* fs.writeFileString(path.join(fixture, name), contents);188 yield* fs.writeFileString(path.join(fixture, name), contents);
188 }189 }
189 const script = buildRemoteStopScript({190 const script = buildRemoteStopScript(
190 alias: "fixture",191 yield* remoteStateKey({
191 hostname: "fixture",192 alias: "fixture",
192 username: null,193 hostname: "fixture",
193 port: null,194 username: null,
194 });195 port: null,
196 }),
197 );
195 // Redirect only the state directory. Never use the developer's SSH state.198 // Redirect only the state directory. Never use the developer's SSH state.
196 const isolatedScript = script.replace(199 const isolatedScript = script.replace(
197 /^STATE_DIR=.*$/mu,200 /^STATE_DIR=.*$/mu,
packages/ssh/src/tunnel.test.ts+8-13
...@@ -233,12 +233,7 @@ describe("ssh tunnel scripts", () => {...@@ -233,12 +233,7 @@ describe("ssh tunnel scripts", () => {
233 });233 });
234234
235 it("uses the remote t3 runner for launch and pairing scripts", () => {235 it("uses the remote t3 runner for launch and pairing scripts", () => {
236 const target = {236 const stateKey = "711bc738002d72fd";
237 alias: "devbox",
238 hostname: "devbox.example.com",
239 username: "julius",
240 port: 2222,
241 } as const;
242 const launch = SshTunnel.buildRemoteLaunchScript(ARCHIVE);237 const launch = SshTunnel.buildRemoteLaunchScript(ARCHIVE);
243 const devLaunch = SshTunnel.buildRemoteLaunchScript({238 const devLaunch = SshTunnel.buildRemoteLaunchScript({
244 ...NODE_SCRIPT,239 ...NODE_SCRIPT,
...@@ -265,28 +260,28 @@ describe("ssh tunnel scripts", () => {...@@ -265,28 +260,28 @@ describe("ssh tunnel scripts", () => {
265 assert.include(launch, "It wrote nothing to %s");260 assert.include(launch, "It wrote nothing to %s");
266 assert.include(launch, "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'");261 assert.include(launch, "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'");
267 assert.include(262 assert.include(
268 SshTunnel.buildRemotePairingScript(target, ARCHIVE),263 SshTunnel.buildRemotePairingScript(stateKey, ARCHIVE),
269 '"$RUNNER_FILE" auth pairing create --base-dir "$PAIRING_BASE_DIR" --json',264 '"$RUNNER_FILE" auth pairing create --base-dir "$PAIRING_BASE_DIR" --json',
270 );265 );
271 assert.include(266 assert.include(
272 SshTunnel.buildRemotePairingScript(target, ARCHIVE),267 SshTunnel.buildRemotePairingScript(stateKey, ARCHIVE),
273 'PAIRING_BASE_DIR="$DEFAULT_SERVER_HOME"',268 'PAIRING_BASE_DIR="$DEFAULT_SERVER_HOME"',
274 );269 );
275 assert.notInclude(SshTunnel.buildRemotePairingScript(target, ARCHIVE), "server-home");270 assert.notInclude(SshTunnel.buildRemotePairingScript(stateKey, ARCHIVE), "server-home");
276 assert.include(271 assert.include(
277 SshTunnel.buildRemotePairingScript(target, ARCHIVE),272 SshTunnel.buildRemotePairingScript(stateKey, ARCHIVE),
278 "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'",273 "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'",
279 );274 );
280 assert.include(275 assert.include(
281 SshTunnel.buildRemoteStopScript(target),276 SshTunnel.buildRemoteStopScript(stateKey),
282 'if [ "$REMOTE_MANAGED" != "external" ] && [ -n "$REMOTE_PID" ]',277 'if [ "$REMOTE_MANAGED" != "external" ] && [ -n "$REMOTE_PID" ]',
283 );278 );
284 assert.include(279 assert.include(
285 SshTunnel.buildRemoteStopScript(target),280 SshTunnel.buildRemoteStopScript(stateKey),
286 'kill "$REMOTE_PID" 2>/dev/null || true',281 'kill "$REMOTE_PID" 2>/dev/null || true',
287 );282 );
288 assert.include(283 assert.include(
289 SshTunnel.buildRemoteStopScript(target),284 SshTunnel.buildRemoteStopScript(stateKey),
290 'rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE"',285 'rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE"',
291 );286 );
292 assert.include(287 assert.include(
packages/ssh/src/tunnel.ts+29-24
...@@ -12,6 +12,7 @@ import * as NetService from "@t3tools/shared/Net";...@@ -12,6 +12,7 @@ import * as NetService from "@t3tools/shared/Net";
12import { extractJsonObject, fromLenientJson } from "@t3tools/shared/schemaJson";12import { extractJsonObject, fromLenientJson } from "@t3tools/shared/schemaJson";
13import { satisfiesSemverRange } from "@t3tools/shared/semver";13import { satisfiesSemverRange } from "@t3tools/shared/semver";
14import * as Context from "effect/Context";14import * as Context from "effect/Context";
15import * as Crypto from "effect/Crypto";
15import * as Effect from "effect/Effect";16import * as Effect from "effect/Effect";
16import * as Exit from "effect/Exit";17import * as Exit from "effect/Exit";
17import * as FileSystem from "effect/FileSystem";18import * as FileSystem from "effect/FileSystem";
...@@ -98,6 +99,7 @@ interface SshTunnelEntry {...@@ -98,6 +99,7 @@ interface SshTunnelEntry {
9899
99type SshEnvironmentEffectContext =100type SshEnvironmentEffectContext =
100 | ChildProcessSpawner.ChildProcessSpawner101 | ChildProcessSpawner.ChildProcessSpawner
102 | Crypto.Crypto
101 | FileSystem.FileSystem103 | FileSystem.FileSystem
102 | Path.Path104 | Path.Path
103 | HttpClient.HttpClient105 | HttpClient.HttpClient
...@@ -837,25 +839,22 @@ export function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string {...@@ -837,25 +839,22 @@ export function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string {
837 });839 });
838}840}
839841
840export function buildRemotePairingScript(842export function buildRemotePairingScript(stateKey: string, input?: RemoteT3RunnerOptions): string {
841 target: DesktopSshEnvironmentTarget,
842 input?: RemoteT3RunnerOptions,
843): string {
844 return applyScriptPlaceholders(REMOTE_PAIRING_SCRIPT, {843 return applyScriptPlaceholders(REMOTE_PAIRING_SCRIPT, {
845 T3_STATE_KEY: remoteStateKey(target),844 T3_STATE_KEY: stateKey,
846 T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)),845 T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)),
847 });846 });
848}847}
849848
850export function buildRemoteStopScript(target: DesktopSshEnvironmentTarget): string {849export function buildRemoteStopScript(stateKey: string): string {
851 return applyScriptPlaceholders(REMOTE_STOP_SCRIPT, {850 return applyScriptPlaceholders(REMOTE_STOP_SCRIPT, {
852 T3_STATE_KEY: remoteStateKey(target),851 T3_STATE_KEY: stateKey,
853 });852 });
854}853}
855854
856function buildRemoteLogTailScript(target: DesktopSshEnvironmentTarget): string {855function buildRemoteLogTailScript(stateKey: string): string {
857 return applyScriptPlaceholders(REMOTE_LOG_TAIL_SCRIPT, {856 return applyScriptPlaceholders(REMOTE_LOG_TAIL_SCRIPT, {
858 T3_STATE_KEY: remoteStateKey(target),857 T3_STATE_KEY: stateKey,
859 });858 });
860}859}
861860
...@@ -867,15 +866,16 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo...@@ -867,15 +866,16 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo
867 ): Effect.fn.Return<866 ): Effect.fn.Return<
868 { readonly remotePort: number; readonly remoteServerKind: "external" | "managed" | null },867 { readonly remotePort: number; readonly remoteServerKind: "external" | "managed" | null },
869 SshCommandError | SshInvalidTargetError | SshLaunchError,868 SshCommandError | SshInvalidTargetError | SshLaunchError,
870 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path869 ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path
871 > {870 > {
871 const stateKey = yield* remoteStateKey(target);
872 yield* Effect.logInfo("ssh.remoteServer.launch.start", {872 yield* Effect.logInfo("ssh.remoteServer.launch.start", {
873 ...sshTargetLogFields(target),873 ...sshTargetLogFields(target),
874 ...sshRunnerLogFields(runner),874 ...sshRunnerLogFields(runner),
875 stateKey: remoteStateKey(target),875 stateKey,
876 });876 });
877 const result = yield* runSshCommand(target, {877 const result = yield* runSshCommand(target, {
878 remoteCommandArgs: ["sh", "-l", "-s", "--", remoteStateKey(target)],878 remoteCommandArgs: ["sh", "-l", "-s", "--", stateKey],
879 stdin: buildRemoteLaunchScript(runner),879 stdin: buildRemoteLaunchScript(runner),
880 timeoutMs: isNodeScriptRunner(runner)880 timeoutMs: isNodeScriptRunner(runner)
881 ? REMOTE_LAUNCH_TIMEOUT_MS881 ? REMOTE_LAUNCH_TIMEOUT_MS
...@@ -910,7 +910,7 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo...@@ -910,7 +910,7 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo
910 ...sshTargetLogFields(target),910 ...sshTargetLogFields(target),
911 remotePort: parsed.remotePort,911 remotePort: parsed.remotePort,
912 remoteServerKind: parsed.serverKind ?? null,912 remoteServerKind: parsed.serverKind ?? null,
913 stateKey: remoteStateKey(target),913 stateKey,
914 });914 });
915 return {915 return {
916 remotePort: parsed.remotePort,916 remotePort: parsed.remotePort,
...@@ -928,15 +928,16 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT...@@ -928,15 +928,16 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT
928 readonly credential: string;928 readonly credential: string;
929 },929 },
930 SshCommandError | SshInvalidTargetError | SshPairingError,930 SshCommandError | SshInvalidTargetError | SshPairingError,
931 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path931 ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path
932> {932> {
933 const stateKey = yield* remoteStateKey(target);
933 yield* Effect.logDebug("ssh.remoteServer.pairingToken.start", {934 yield* Effect.logDebug("ssh.remoteServer.pairingToken.start", {
934 ...sshTargetLogFields(target),935 ...sshTargetLogFields(target),
935 stateKey: remoteStateKey(target),936 stateKey,
936 });937 });
937 const result = yield* runSshCommand(target, {938 const result = yield* runSshCommand(target, {
938 remoteCommandArgs: ["sh", "-s"],939 remoteCommandArgs: ["sh", "-s"],
939 stdin: buildRemotePairingScript(target, runner),940 stdin: buildRemotePairingScript(stateKey, runner),
940 // Pairing may be the first command on a cold remote, so it can install941 // Pairing may be the first command on a cold remote, so it can install
941 // the archive on the way.942 // the archive on the way.
942 ...(isNodeScriptRunner(runner) ? {} : { timeoutMs: REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS }),943 ...(isNodeScriptRunner(runner) ? {} : { timeoutMs: REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS }),
...@@ -968,7 +969,7 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT...@@ -968,7 +969,7 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT
968 }969 }
969 yield* Effect.logDebug("ssh.remoteServer.pairingToken.created", {970 yield* Effect.logDebug("ssh.remoteServer.pairingToken.created", {
970 ...sshTargetLogFields(target),971 ...sshTargetLogFields(target),
971 stateKey: remoteStateKey(target),972 stateKey,
972 });973 });
973 return {974 return {
974 credential: parsed.credential,975 credential: parsed.credential,
...@@ -981,22 +982,23 @@ const stopRemoteServer = Effect.fn("ssh/tunnel.stopRemoteServer")(function* (...@@ -981,22 +982,23 @@ const stopRemoteServer = Effect.fn("ssh/tunnel.stopRemoteServer")(function* (
981): Effect.fn.Return<982): Effect.fn.Return<
982 void,983 void,
983 SshCommandError | SshInvalidTargetError,984 SshCommandError | SshInvalidTargetError,
984 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path985 ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path
985> {986> {
987 const stateKey = yield* remoteStateKey(target);
986 yield* Effect.logInfo("ssh.remoteServer.stop.start", {988 yield* Effect.logInfo("ssh.remoteServer.stop.start", {
987 ...sshTargetLogFields(target),989 ...sshTargetLogFields(target),
988 stateKey: remoteStateKey(target),990 stateKey,
989 });991 });
990 yield* runSshCommand(target, {992 yield* runSshCommand(target, {
991 remoteCommandArgs: ["sh", "-s"],993 remoteCommandArgs: ["sh", "-s"],
992 stdin: buildRemoteStopScript(target),994 stdin: buildRemoteStopScript(stateKey),
993 ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }),995 ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }),
994 ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }),996 ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }),
995 ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }),997 ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }),
996 });998 });
997 yield* Effect.logInfo("ssh.remoteServer.stop.succeeded", {999 yield* Effect.logInfo("ssh.remoteServer.stop.succeeded", {
998 ...sshTargetLogFields(target),1000 ...sshTargetLogFields(target),
999 stateKey: remoteStateKey(target),1001 stateKey,
1000 });1002 });
1001});1003});
10021004
...@@ -1006,11 +1008,11 @@ const readRemoteServerLogTail = Effect.fn("ssh/tunnel.readRemoteServerLogTail")(...@@ -1006,11 +1008,11 @@ const readRemoteServerLogTail = Effect.fn("ssh/tunnel.readRemoteServerLogTail")(
1006): Effect.fn.Return<1008): Effect.fn.Return<
1007 string,1009 string,
1008 SshCommandError | SshInvalidTargetError,1010 SshCommandError | SshInvalidTargetError,
1009 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path1011 ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path
1010> {1012> {
1011 const result = yield* runSshCommand(target, {1013 const result = yield* runSshCommand(target, {
1012 remoteCommandArgs: ["sh", "-s"],1014 remoteCommandArgs: ["sh", "-s"],
1013 stdin: buildRemoteLogTailScript(target),1015 stdin: buildRemoteLogTailScript(yield* remoteStateKey(target)),
1014 timeoutMs: 10_000,1016 timeoutMs: 10_000,
1015 ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }),1017 ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }),
1016 ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }),1018 ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }),
...@@ -1109,6 +1111,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input:...@@ -1109,6 +1111,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input:
1109 SshTunnelEntry,1111 SshTunnelEntry,
1110 SshCommandError | SshInvalidTargetError | SshReadinessError,1112 SshCommandError | SshInvalidTargetError | SshReadinessError,
1111 | ChildProcessSpawner.ChildProcessSpawner1113 | ChildProcessSpawner.ChildProcessSpawner
1114 | Crypto.Crypto
1112 | FileSystem.FileSystem1115 | FileSystem.FileSystem
1113 | Path.Path1116 | Path.Path
1114 | HttpClient.HttpClient1117 | HttpClient.HttpClient
...@@ -1538,6 +1541,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma...@@ -1538,6 +1541,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma
1538 );1541 );
1539 tunnels.set(input.key, tunnelEntry);1542 tunnels.set(input.key, tunnelEntry);
1540 const spawnerService = yield* ChildProcessSpawner.ChildProcessSpawner;1543 const spawnerService = yield* ChildProcessSpawner.ChildProcessSpawner;
1544 const cryptoService = yield* Crypto.Crypto;
1541 const fileSystemService = yield* FileSystem.FileSystem;1545 const fileSystemService = yield* FileSystem.FileSystem;
1542 const pathService = yield* Path.Path;1546 const pathService = yield* Path.Path;
1543 yield* Scope.addFinalizer(1547 yield* Scope.addFinalizer(
...@@ -1577,6 +1581,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma...@@ -1577,6 +1581,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma
1577 },1581 },
1578 ).pipe(1582 ).pipe(
1579 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawnerService),1583 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawnerService),
1584 Effect.provideService(Crypto.Crypto, cryptoService),
1580 Effect.provideService(FileSystem.FileSystem, fileSystemService),1585 Effect.provideService(FileSystem.FileSystem, fileSystemService),
1581 Effect.provideService(Path.Path, pathService),1586 Effect.provideService(Path.Path, pathService),
1582 );1587 );
...@@ -1757,7 +1762,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma...@@ -1757,7 +1762,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma
1757});1762});
17581763
1759/**1764/**
1760 * @effect-expect-leaking ChildProcessSpawner | FileSystem | HttpClient | NetService | Path | SshPasswordPrompt1765 * @effect-expect-leaking ChildProcessSpawner | Crypto | FileSystem | HttpClient | NetService | Path | SshPasswordPrompt
1761 */1766 */
1762export class SshEnvironmentManager extends Context.Service<1767export class SshEnvironmentManager extends Context.Service<
1763 SshEnvironmentManager,1768 SshEnvironmentManager,
pnpm-lock.yaml+37-34
...@@ -13,8 +13,8 @@ catalogs:...@@ -13,8 +13,8 @@ catalogs:
13 specifier: 4.0.113 specifier: 4.0.1
14 version: 4.0.114 version: 4.0.1
15 '@effect/tsgo':15 '@effect/tsgo':
16 specifier: 0.41.016 specifier: 0.46.1
17 version: 0.41.017 version: 0.46.1
18 '@legendapp/list':18 '@legendapp/list':
19 specifier: 3.3.519 specifier: 3.3.5
20 version: 3.3.520 version: 3.3.5
...@@ -131,7 +131,7 @@ importers:...@@ -131,7 +131,7 @@ importers:
131 version: 1.1.0131 version: 1.1.0
132 '@effect/tsgo':132 '@effect/tsgo':
133 specifier: 'catalog:'133 specifier: 'catalog:'
134 version: 0.41.0134 version: 0.46.1
135 '@shadcn/lint':135 '@shadcn/lint':
136 specifier: 0.1.5136 specifier: 0.1.5
137 version: 0.1.5137 version: 0.1.5
...@@ -540,6 +540,9 @@ importers:...@@ -540,6 +540,9 @@ importers:
540 '@napi-rs/keyring':540 '@napi-rs/keyring':
541 specifier: ^1.3.0541 specifier: ^1.3.0
542 version: 1.3.0542 version: 1.3.0
543 '@noble/hashes':
544 specifier: 'catalog:'
545 version: 1.8.0
543 '@opencode-ai/sdk':546 '@opencode-ai/sdk':
544 specifier: ^1.3.15547 specifier: ^1.3.15
545 version: 1.15.13548 version: 1.15.13
...@@ -2384,43 +2387,43 @@ packages:...@@ -2384,43 +2387,43 @@ packages:
2384 peerDependencies:2387 peerDependencies:
2385 effect: 4.0.12388 effect: 4.0.1
23862389
2387 '@effect/tsgo-darwin-arm64@0.41.0':2390 '@effect/tsgo-darwin-arm64@0.46.1':
2388 resolution: {integrity: sha512-3iEPtcHF72yDjv7T5YpnX+Io1LDLywJb1oGG3Fp/Fth4xmqiT1Vacyz5wnPCfEWGQ6CyrlyCZSSjUxPyJ70+DQ==}2391 resolution: {integrity: sha512-ErehNqI9p8me4XKO8N4bzdDWgBbSNlJ3CIZGfXoAW6Fo4Oo7x4ZbZV7KNsMSn9aJSiUUOQUoXjGBdslU5OYMdw==}
2389 cpu: [arm64]2392 cpu: [arm64]
2390 os: [darwin]2393 os: [darwin]
23912394
2392 '@effect/tsgo-darwin-x64@0.41.0':2395 '@effect/tsgo-darwin-x64@0.46.1':
2393 resolution: {integrity: sha512-rrVVNacJ/Qh8DfdfgNJDuzcVmR3xPnqes3z+F1kio99umJwPIRB8iPWRAY0SrO+Y84A/y2SSb0AjMGqA5V3Aiw==}2396 resolution: {integrity: sha512-zsQI1gjGMYF/C2FWDUwbStBAf0tJDVgXeWwTAZ+VUiNY06fR1SrZHK0L+22PE49Nim3biWK2h+XXRFVzql+ojg==}
2394 cpu: [x64]2397 cpu: [x64]
2395 os: [darwin]2398 os: [darwin]
23962399
2397 '@effect/tsgo-linux-arm64@0.41.0':2400 '@effect/tsgo-linux-arm64@0.46.1':
2398 resolution: {integrity: sha512-RVI+7pG3tP56LfH06a2aq4KLp2RQ0HohW8mmoGmrBPyNEL9DnzFEGB98ZoMJ+ERYUJoIp74Jy3nKRVbnzVhxFw==}2401 resolution: {integrity: sha512-e283BZEQC3mJpk1N7vdUhoF/aYHc4YwCE/fwz/80d11OSvG35U+c49grxS7V7+dAl2ld6QiT8D1rcL8PYu8DLQ==}
2399 cpu: [arm64]2402 cpu: [arm64]
2400 os: [linux]2403 os: [linux]
24012404
2402 '@effect/tsgo-linux-arm@0.41.0':2405 '@effect/tsgo-linux-arm@0.46.1':
2403 resolution: {integrity: sha512-JddS91IaupLa22oc4YQiYppe7iAZAEyx/2iR9sAAAhpVanWACsIAcyUy4RJrs8sPLLoLsZQaKQa+9rbQO1FLsg==}2406 resolution: {integrity: sha512-Y12wOcJ6HgwiCgg/oDQH4tdRQKRsWnul/KjCOwMxzVT4kO+ndczu1sKUZT+qIxV7qNk4Sq+uSWKEnFBA3ETI8w==}
2404 cpu: [arm]2407 cpu: [arm]
2405 os: [linux]2408 os: [linux]
24062409
2407 '@effect/tsgo-linux-x64@0.41.0':2410 '@effect/tsgo-linux-x64@0.46.1':
2408 resolution: {integrity: sha512-U3+kMDVe2Opa5mxbN4B6PgOizWx5B4LXLN98CZDUl2vYtRGfwrRRhKQiB+de3ELaZ0MuDjPlCvU7S2mXQ8UiuQ==}2411 resolution: {integrity: sha512-NYGBC3Scfo9yZJfs7cg0KMcHgtukJ4+xff18nqIrON9Zzc/qD6w1AwEwYKYCsbai5hTQBb3VLIWmHM8BJ54WJg==}
2409 cpu: [x64]2412 cpu: [x64]
2410 os: [linux]2413 os: [linux]
24112414
2412 '@effect/tsgo-win32-arm64@0.41.0':2415 '@effect/tsgo-win32-arm64@0.46.1':
2413 resolution: {integrity: sha512-H6/xkn+B4B63OPKc0UAzQQFvLs0MXKigiD9dtvQeCU7czWGztOQILuLkyHnM1Dlc+uy4cJ5eXHiMXd+lwl+E8g==}2416 resolution: {integrity: sha512-MK93SN9qjowrCjch+2wGGncd6Y7KgDyJvLKVVFCJV8cPpHoL3xDCpxFCGqQWe120mVpLmnzbM2evKdh0M5l5PA==}
2414 cpu: [arm64]2417 cpu: [arm64]
2415 os: [win32]2418 os: [win32]
24162419
2417 '@effect/tsgo-win32-x64@0.41.0':2420 '@effect/tsgo-win32-x64@0.46.1':
2418 resolution: {integrity: sha512-vRN/Mhi381xEGrvV68IN/VO4Lc0pnlVQzHSVmNCJXLwGLmJUvuvPoyZ7Lv4wwHRX9iZe8Rch21xUGu5jSQtUSw==}2421 resolution: {integrity: sha512-hlRbeNlp0N/dfZZFl+xVzT3XfHYqPmkXzCadR6e5yoe/vyA3O9RnO5UrOBHsXvL2RTE6hrh8bRfx2Uu6g3LL5w==}
2419 cpu: [x64]2422 cpu: [x64]
2420 os: [win32]2423 os: [win32]
24212424
2422 '@effect/tsgo@0.41.0':2425 '@effect/tsgo@0.46.1':
2423 resolution: {integrity: sha512-C/U7lFM0AXsfpqRilDOgwu+llBhAo8bcdIlzgbRWf1LWlU4KZKB2UsUvBPL3qPnm+wmbCvQLeTQc4BjV9oJrcg==}2426 resolution: {integrity: sha512-DMcBCg5nWLufejRlyqFEkXmbtaLUJ56NKny6L1LTtimvz0U3VNj9H6ZUXV4WeeWvJzVyL0y7G2wsCBAFUgy1jw==}
2424 hasBin: true2427 hasBin: true
24252428
2426 '@effect/vitest@4.0.1':2429 '@effect/vitest@4.0.1':
...@@ -13055,36 +13058,36 @@ snapshots:...@@ -13055,36 +13058,36 @@ snapshots:
13055 dependencies:13058 dependencies:
13056 effect: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f)13059 effect: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f)
1305713060
13058 '@effect/tsgo-darwin-arm64@0.41.0':13061 '@effect/tsgo-darwin-arm64@0.46.1':
13059 optional: true13062 optional: true
1306013063
13061 '@effect/tsgo-darwin-x64@0.41.0':13064 '@effect/tsgo-darwin-x64@0.46.1':
13062 optional: true13065 optional: true
1306313066
13064 '@effect/tsgo-linux-arm64@0.41.0':13067 '@effect/tsgo-linux-arm64@0.46.1':
13065 optional: true13068 optional: true
1306613069
13067 '@effect/tsgo-linux-arm@0.41.0':13070 '@effect/tsgo-linux-arm@0.46.1':
13068 optional: true13071 optional: true
1306913072
13070 '@effect/tsgo-linux-x64@0.41.0':13073 '@effect/tsgo-linux-x64@0.46.1':
13071 optional: true13074 optional: true
1307213075
13073 '@effect/tsgo-win32-arm64@0.41.0':13076 '@effect/tsgo-win32-arm64@0.46.1':
13074 optional: true13077 optional: true
1307513078
13076 '@effect/tsgo-win32-x64@0.41.0':13079 '@effect/tsgo-win32-x64@0.46.1':
13077 optional: true13080 optional: true
1307813081
13079 '@effect/tsgo@0.41.0':13082 '@effect/tsgo@0.46.1':
13080 optionalDependencies:13083 optionalDependencies:
13081 '@effect/tsgo-darwin-arm64': 0.41.013084 '@effect/tsgo-darwin-arm64': 0.46.1
13082 '@effect/tsgo-darwin-x64': 0.41.013085 '@effect/tsgo-darwin-x64': 0.46.1
13083 '@effect/tsgo-linux-arm': 0.41.013086 '@effect/tsgo-linux-arm': 0.46.1
13084 '@effect/tsgo-linux-arm64': 0.41.013087 '@effect/tsgo-linux-arm64': 0.46.1
13085 '@effect/tsgo-linux-x64': 0.41.013088 '@effect/tsgo-linux-x64': 0.46.1
13086 '@effect/tsgo-win32-arm64': 0.41.013089 '@effect/tsgo-win32-arm64': 0.46.1
13087 '@effect/tsgo-win32-x64': 0.41.013090 '@effect/tsgo-win32-x64': 0.46.1
1308813091
13089 '@effect/vitest@4.0.1(patch_hash=359f6fb2f7b3ec145bb72208edb9034f02489791aa2491a55cdbd69bd56ee0d2)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)':13092 '@effect/vitest@4.0.1(patch_hash=359f6fb2f7b3ec145bb72208edb9034f02489791aa2491a55cdbd69bd56ee0d2)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)':
13090 dependencies:13093 dependencies:
pnpm-workspace.yaml+1-1
...@@ -36,7 +36,7 @@ catalog:...@@ -36,7 +36,7 @@ catalog:
36 "@effect/platform-node-shared": 4.0.136 "@effect/platform-node-shared": 4.0.1
37 "@effect/sql-pg": 4.0.137 "@effect/sql-pg": 4.0.1
38 "@effect/sql-sqlite-do": 4.0.138 "@effect/sql-sqlite-do": 4.0.1
39 "@effect/tsgo": 0.41.039 "@effect/tsgo": 0.46.1
40 "@effect/vitest": 4.0.140 "@effect/vitest": 4.0.1
41 "@legendapp/list": 3.3.541 "@legendapp/list": 3.3.5
42 "@noble/curves": 1.9.142 "@noble/curves": 1.9.1
tsconfig.base.json-1
...@@ -23,7 +23,6 @@...@@ -23,7 +23,6 @@
23 "name": "@effect/language-service",23 "name": "@effect/language-service",
24 "namespaceImportPackages": ["@effect/platform-node", "effect"],24 "namespaceImportPackages": ["@effect/platform-node", "effect"],
25 "diagnosticSeverity": {25 "diagnosticSeverity": {
26 "importFromBarrel": "error",
27 "anyUnknownInErrorContext": "error",26 "anyUnknownInErrorContext": "error",
28 "unsafeEffectTypeAssertion": "error",27 "unsafeEffectTypeAssertion": "error",
29 "instanceOfSchema": "error",28 "instanceOfSchema": "error",