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 @@
11import { assert, describe, it } from "@effect/vitest";
22import * as Cause from "effect/Cause";
33import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
45import * as NodeServices from "@effect/platform-node/NodeServices";
56import * as FileSystem from "effect/FileSystem";
67import * as Layer from "effect/Layer";
8import * as TestClock from "effect/testing/TestClock";
79import { beforeEach, vi } from "vite-plus/test";
810
911const { handleMock, netFetchMock, unhandleMock } = vi.hoisted(() => ({
......@@ -174,7 +176,11 @@ describe("ElectronProtocol", () => {
174176 targetOrigin: new URL("http://127.0.0.1:5733/"),
175177 clerkFrontendApiHostname: undefined,
176178 });
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);
178184 }),
179185 );
180186
......@@ -183,6 +189,50 @@ describe("ElectronProtocol", () => {
183189 }).pipe(Effect.provide(layerProtocol)),
184190 );
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
186236 it.effect("preserves protocol registration failures", () =>
187237 Effect.gen(function* () {
188238 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";
33import * as FileSystem from "effect/FileSystem";
44import * as Layer from "effect/Layer";
55import * as Option from "effect/Option";
6import * as NodeTimersPromises from "node:timers/promises";
76import * as Path from "effect/Path";
87import * as Mime from "effect/http/Mime";
98import * as Ref from "effect/Ref";
9import * as Schedule from "effect/Schedule";
1010import * as Schema from "effect/Schema";
1111import * as Scope from "effect/Scope";
1212
......@@ -150,11 +150,29 @@ const registerDesktopSchemePrivileges = Effect.sync(registerDesktopSchemePrivile
150150
151151export 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* (
154172 request: Request,
155173 targetOrigin: URL,
156174 contentSecurityPolicy: string,
157): Promise<Response> {
175) {
158176 const requestUrl = new URL(request.url);
159177 if (requestUrl.host !== DESKTOP_HOST) {
160178 return new Response(null, { status: 404 });
......@@ -190,12 +208,10 @@ async function proxyRequest(
190208 }
191209 const response =
192210 request.method === "GET" || request.method === "HEAD"
193 ? await fetchWithTransientRetry(targetUrl.toString(), init)
194 : await Electron.net.fetch(targetUrl.toString(), init);
211 ? yield* fetchWithTransientRetry(targetUrl.toString(), init)
212 : yield* netFetch(targetUrl.toString(), init);
195213 return withContentSecurityPolicy(response, contentSecurityPolicy);
196}
197
198const TRANSIENT_FETCH_RETRY_DELAYS_MS = [0, 50, 150] as const;
214});
199215
200216// Serves the packaged web client without a backend: files resolve within the
201217// 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* (
238254 });
239255});
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
259257/** @public Service construction is part of the canonical Effect module API. */
260258export const make = Effect.gen(function* () {
261259 const registered = yield* Ref.make(false);
......@@ -278,7 +276,14 @@ export const make = Effect.gen(function* () {
278276 contentSecurityPolicy,
279277 );
280278 }
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 );
282287 });
283288 },
284289 catch: (cause) => new ElectronProtocolRegistrationError({ scheme: input.scheme, cause }),
apps/desktop/src/ssh/DesktopSshEnvironment.ts+2
......@@ -18,6 +18,7 @@ import {
1818} from "@t3tools/ssh/errors";
1919import * as SshTunnel from "@t3tools/ssh/tunnel";
2020import * as Context from "effect/Context";
21import type * as Crypto from "effect/Crypto";
2122import * as Effect from "effect/Effect";
2223import * as FileSystem from "effect/FileSystem";
2324import * as Layer from "effect/Layer";
......@@ -29,6 +30,7 @@ import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts";
2930
3031export type DesktopSshEnvironmentRuntimeServices =
3132 | ChildProcessSpawner.ChildProcessSpawner
33 | Crypto.Crypto
3234 | FileSystem.FileSystem
3335 | Path.Path
3436 | HttpClient.HttpClient
apps/server/package.json+1
......@@ -34,6 +34,7 @@
3434 "@effect/platform-node-shared": "catalog:",
3535 "@ff-labs/fff-node": "0.9.4",
3636 "@napi-rs/keyring": "^1.3.0",
37 "@noble/hashes": "catalog:",
3738 "@opencode-ai/sdk": "^1.3.15",
3839 "@opencode/client": "2.0.23",
3940 "@opencode/protocol": "2.0.23",
apps/server/src/auth/ReusableDevAuth.ts+1
......@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no timingSafeEqual.
12import * as NodeCrypto from "node:crypto";
23import { AuthSessionId } from "@t3tools/contracts";
34import * as DateTime from "effect/DateTime";
apps/server/src/auth/utils.ts+1
......@@ -4,6 +4,7 @@ import type {
44 AuthClientPresentationMetadata,
55} from "@t3tools/contracts";
66import type * as HttpServerRequest from "effect/http/HttpServerRequest";
7// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createHmac or timingSafeEqual.
78import * as NodeCrypto from "node:crypto";
89import * as Base64Url from "effect/encoding/Base64Url";
910import * as Result from "effect/Result";
apps/server/src/checkpointing/CheckpointDiffQuery.test.ts+3-1
......@@ -1,3 +1,4 @@
1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
12import { assert, it, vi } from "@effect/vitest";
23import { CheckpointRef, CheckpointScopeId, RunId, ThreadId } from "@t3tools/contracts";
34import * as Effect from "effect/Effect";
......@@ -59,6 +60,7 @@ function layerFor(input: {
5960 }),
6061 ),
6162 ),
63 Layer.provideMerge(NodeCrypto.layer),
6264 );
6365}
6466
......@@ -80,7 +82,7 @@ it.effect("computes V2 run diffs from projected checkpoint scopes", () => {
8082 });
8183 assert.deepEqual(diffCheckpoints.mock.calls[0]?.[0], {
8284 cwd: "/repo",
83 fromCheckpointRef: checkpointRefForScopeOrdinal({
85 fromCheckpointRef: yield* checkpointRefForScopeOrdinal({
8486 scopeId: firstScopeId,
8587 ordinalWithinScope: 0,
8688 }),
apps/server/src/checkpointing/CheckpointDiffQuery.ts+14-13
......@@ -15,6 +15,7 @@ import {
1515 type ThreadId,
1616} from "@t3tools/contracts";
1717import * as Context from "effect/Context";
18import * as Crypto from "effect/Crypto";
1819import * as Effect from "effect/Effect";
1920import * as Layer from "effect/Layer";
2021import * as Schema from "effect/Schema";
......@@ -77,6 +78,7 @@ function buildTurnDiffResult(
7778export const make = Effect.gen(function* () {
7879 const threads = yield* ThreadManagement.ThreadManagementService;
7980 const checkpointStore = yield* CheckpointStore.CheckpointStore;
81 const crypto = yield* Crypto.Crypto;
8082
8183 const getTurnDiff: CheckpointDiffQuery["Service"]["getTurnDiff"] = Effect.fn("getTurnDiff")(
8284 function* (input) {
......@@ -155,21 +157,20 @@ export const make = Effect.gen(function* () {
155157 });
156158 }
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;
158166 const fromCheckpointRef =
159167 input.fromTurnCount === 0
160 ? (() => {
161 // The root scope is shared by every run in this thread. Its
162 // runId tracks the latest owner, while ordinal zero stays the baseline.
163 const firstScope = projection.checkpointScopes.find(
164 (scope) => scope.kind === "root_run",
165 );
166 return firstScope === undefined
167 ? undefined
168 : checkpointRefForScopeOrdinal({
169 scopeId: firstScope.id,
170 ordinalWithinScope: 0,
171 });
172 })()
168 ? firstScope === undefined
169 ? undefined
170 : yield* checkpointRefForScopeOrdinal({
171 scopeId: firstScope.id,
172 ordinalWithinScope: 0,
173 }).pipe(Effect.provideService(Crypto.Crypto, crypto))
173174 : readyCheckpoints.find((checkpoint) => checkpoint.appRunOrdinal === input.fromTurnCount)
174175 ?.ref;
175176 if (fromCheckpointRef === undefined) {
apps/server/src/cloud/CloudLink.ts+1
......@@ -4,6 +4,7 @@
44 * and mint requests, and keeping the managed tunnel registered, recovered and
55 * released. HTTP handlers, server startup and shutdown all go through it.
66 */
7// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createPublicKey.
78import * as NodeCrypto from "node:crypto";
89import {
910 AuthStandardClientScopes,
apps/server/src/cloud/environmentKeys.ts+1
......@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync.
12import * as NodeCrypto from "node:crypto";
23import * as Effect from "effect/Effect";
34import * 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'));
4141if (process.env.AGENT_DEVICE_DAEMON_BASE_URL) process.exit(2);`,
4242 );
4343 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 );
4547 for (const [index, file] of files.entries())
4648 yield* writeAgentDeviceConfig(file, {
4749 baseUrl: `http://127.0.0.1:${1000 + index}`,
......@@ -73,8 +75,8 @@ if (process.env.AGENT_DEVICE_DAEMON_BASE_URL) process.exit(2);`,
7375 });
7476 expect((yield* Effect.promise(() => invoke(files[0]!))).daemonAuthToken).toBe("new");
7577 expect(yield* fs.readFileString(files[1]!)).toBe(second);
76 expect(agentDeviceSession("thread", "mini", "same-id")).not.toBe(
77 agentDeviceSession("thread", "android", "same-id"),
78 expect(yield* agentDeviceSession("thread", "mini", "same-id")).not.toBe(
79 yield* agentDeviceSession("thread", "android", "same-id"),
7880 );
7981 for (const args of [
8082 ["snapshot"],
apps/server/src/device/AgentDeviceTarget.ts+12-6
......@@ -1,6 +1,7 @@
1import * as NodeCrypto from "node:crypto";
2import * as Schema from "effect/Schema";
1import * as Crypto from "effect/Crypto";
32import * as Effect from "effect/Effect";
3import * as Hex from "effect/encoding/Hex";
4import * as Schema from "effect/Schema";
45import * as FileSystem from "effect/FileSystem";
56import * as Path from "effect/Path";
67
......@@ -12,15 +13,20 @@ const encodeEndpoint = Schema.encodeEffect(
1213 ),
1314);
1415
15const key = (value: string) =>
16 NodeCrypto.createHash("sha256").update(value).digest("hex").slice(0, 24);
16const key = Effect.fn("AgentDeviceTarget.key")(function* (value: string) {
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
1824/** A stable file per host lets forwarded endpoints change without retargeting other commands. */
1925export 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
2228export 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
2531export const writeAgentDeviceConfig = Effect.fn("AgentDeviceTarget.writeConfig")(function* (
2632 file: string,
apps/server/src/device/DeviceMultiHost.test.ts+2-1
......@@ -2,6 +2,7 @@ import { expect, it } from "@effect/vitest";
22import { ThreadId } from "@t3tools/contracts";
33import * as Deferred from "effect/Deferred";
44import * as Fiber from "effect/Fiber";
5import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
56import * as Effect from "effect/Effect";
67import { HttpClient, HttpClientResponse } from "effect/http";
78import * as ServerSettings from "../serverSettings.ts";
......@@ -77,7 +78,7 @@ it.effect("keeps hosts independent when serials collide and another host fails",
7778 order.push("write finished");
7879 return "/host-config.json";
7980 }),
80 ).pipe(Effect.provideService(HttpClient.HttpClient, http));
81 ).pipe(Effect.provide(NodeCrypto.layer), Effect.provideService(HttpClient.HttpClient, http));
8182 expect(yield* service.agentReadinessIfSupported("b")).not.toBeNull();
8283 const listed = yield* service.list;
8384 expect(listed.devices.map((device) => device.hostId).sort()).toEqual(["a", "b"]);
apps/server/src/device/DeviceService.test.ts+4
......@@ -7,6 +7,7 @@ import {
77 ThreadId,
88 type DeviceServiceState,
99} from "@t3tools/contracts";
10import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
1011import * as Effect from "effect/Effect";
1112import * as Exit from "effect/Exit";
1213import * as Deferred from "effect/Deferred";
......@@ -135,6 +136,7 @@ const fixture = Effect.fn("fixture")(function* (
135136 undefined,
136137 installTool,
137138 ).pipe(
139 Effect.provide(NodeCrypto.layer),
138140 Effect.provideService(DeviceHost.DeviceHost, host),
139141 Effect.provideService(
140142 ServerSettings.ServerSettingsService,
......@@ -482,6 +484,7 @@ it.effect.each(["shutdown", "close"] as const)(
482484 }),
483485 );
484486 const service = yield* DeviceService.makeWithHosts(new Map([[host.id, host]])).pipe(
487 Effect.provide(NodeCrypto.layer),
485488 Effect.provideService(HttpClient.HttpClient, http),
486489 );
487490 const input = { threadId, deviceId, platform: "ios" as const };
......@@ -574,6 +577,7 @@ it.effect.each([
574577 }),
575578 );
576579 const service = yield* DeviceService.makeWithHosts(new Map([[host.id, host]])).pipe(
580 Effect.provide(NodeCrypto.layer),
577581 Effect.provideService(HttpClient.HttpClient, http),
578582 );
579583 yield* service.list;
apps/server/src/device/DeviceService.ts+20-14
......@@ -46,6 +46,7 @@ import {
4646 writeAgentDeviceConfig,
4747} from "./AgentDeviceTarget.ts";
4848import * as Context from "effect/Context";
49import * as Crypto from "effect/Crypto";
4950import * as DateTime from "effect/DateTime";
5051import * as Duration from "effect/Duration";
5152import * as Effect from "effect/Effect";
......@@ -186,6 +187,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
186187 installTool?: (tool: "hub" | "agent") => Effect.Effect<unknown, DeviceError>,
187188) {
188189 const settings = yield* ServerSettings.ServerSettingsService;
190 const crypto = yield* Crypto.Crypto;
189191 const lifecycleLock = yield* Semaphore.make(1);
190192 const readDeviceSettings = settings.getSettings.pipe(
191193 Effect.map((value) => ({
......@@ -943,12 +945,12 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
943945 return yield* configureAgent(input.hostId, ready);
944946 }),
945947 );
946 return [
947 "--config",
948 configPath,
949 "--session",
950 agentDeviceSession(input.threadId, input.hostId, input.deviceId),
951 ];
948 const session = yield* agentDeviceSession(
949 input.threadId,
950 input.hostId,
951 input.deviceId,
952 ).pipe(Effect.provideService(Crypto.Crypto, crypto));
953 return ["--config", configPath, "--session", session];
952954 }),
953955 state: SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state)),
954956 subscribe: PubSub.subscribe(statePubSub),
......@@ -998,9 +1000,17 @@ export const make = Effect.gen(function* () {
9981000 const hosts = new Map<DeviceHostId, DeviceHost.DeviceHost["Service"]>([
9991001 [localHost.id, localHost],
10001002 ]);
1001 const configureAgent = (hostId: DeviceHostId, ready: DeviceHost.DeviceHostAgentReady) => {
1002 const file = agentDeviceConfigPath(config.stateDir, hostId, path);
1003 return writeAgentDeviceConfig(file, ready.agentDevice).pipe(
1003 const crypto = yield* Crypto.Crypto;
1004 const configPath = (hostId: DeviceHostId) =>
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(
10041014 Effect.provideService(FileSystem.FileSystem, fs),
10051015 Effect.provideService(Path.Path, path),
10061016 Effect.mapError(
......@@ -1011,9 +1021,7 @@ export const make = Effect.gen(function* () {
10111021 cause,
10121022 }),
10131023 ),
1014 Effect.as(file),
10151024 );
1016 };
10171025 const probeContext =
10181026 yield* Effect.context<Effect.Services<ReturnType<typeof SshDeviceHost.probe>>>();
10191027 const localTargetContext =
......@@ -1089,9 +1097,7 @@ export const make = Effect.gen(function* () {
10891097 ({ id, scope }) =>
10901098 Effect.gen(function* () {
10911099 yield* Scope.close(scope, Exit.void);
1092 yield* fs
1093 .remove(agentDeviceConfigPath(config.stateDir, id, path), { force: true })
1094 .pipe(Effect.ignore);
1100 yield* fs.remove(yield* configPath(id), { force: true }).pipe(Effect.ignore);
10951101 }),
10961102 { concurrency: 4, discard: true },
10971103 );
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+54-32
......@@ -32,6 +32,7 @@ import { assert, describe, it } from "@effect/vitest";
3232import * as Context from "effect/Context";
3333import * as DateTime from "effect/DateTime";
3434import * as Deferred from "effect/Deferred";
35import * as Crypto from "effect/Crypto";
3536import * as Effect from "effect/Effect";
3637import * as Exit from "effect/Exit";
3738import * as FileSystem from "effect/FileSystem";
......@@ -957,6 +958,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => {
957958 attachmentsDir,
958959 fileSystem,
959960 path: yield* Path.Path,
961 crypto: yield* Crypto.Crypto,
960962 idAllocator,
961963 queryRunner: {
962964 allocateSessionId: Effect.succeed("native-thread-claude-accept-edits"),
......@@ -1199,6 +1201,7 @@ describe("ClaudeAdapterV2 resume compaction", () => {
11991201 attachmentsDir,
12001202 fileSystem,
12011203 path: yield* Path.Path,
1204 crypto: yield* Crypto.Crypto,
12021205 idAllocator,
12031206 queryRunner: {
12041207 allocateSessionId: Effect.succeed("native-thread-claude-resume"),
......@@ -1418,6 +1421,7 @@ describe("ClaudeAdapterV2 attachments", () => {
14181421 attachmentsDir,
14191422 fileSystem,
14201423 path: yield* Path.Path,
1424 crypto: yield* Crypto.Crypto,
14211425 idAllocator,
14221426 queryRunner: {
14231427 allocateSessionId: Effect.succeed("native-thread-claude-attachments"),
......@@ -1558,6 +1562,7 @@ describe("ClaudeAdapterV2 attachments", () => {
15581562 attachmentsDir,
15591563 fileSystem,
15601564 path: yield* Path.Path,
1565 crypto: yield* Crypto.Crypto,
15611566 idAllocator,
15621567 queryRunner: {
15631568 allocateSessionId: Effect.succeed("native-thread-claude-unsupported-attachment"),
......@@ -1647,6 +1652,7 @@ describe("ClaudeAdapterV2 native fork", () => {
16471652 attachmentsDir,
16481653 fileSystem,
16491654 path: yield* Path.Path,
1655 crypto: yield* Crypto.Crypto,
16501656 idAllocator,
16511657 queryRunner: {
16521658 allocateSessionId: Effect.succeed("source-native-session"),
......@@ -1819,6 +1825,7 @@ describe("ClaudeAdapterV2 native session identity", () => {
18191825 attachmentsDir,
18201826 fileSystem,
18211827 path: yield* Path.Path,
1828 crypto: yield* Crypto.Crypto,
18221829 idAllocator,
18231830 queryRunner: {
18241831 allocateSessionId: Effect.succeed("native-session-identity"),
......@@ -2108,6 +2115,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
21082115 attachmentsDir,
21092116 fileSystem,
21102117 path: yield* Path.Path,
2118 crypto: yield* Crypto.Crypto,
21112119 idAllocator,
21122120 continuationRequests: {
21132121 offer: (request) =>
......@@ -3892,6 +3900,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
38923900 attachmentsDir,
38933901 fileSystem,
38943902 path: yield* Path.Path,
3903 crypto: yield* Crypto.Crypto,
38953904 idAllocator,
38963905 continuationRequests: {
38973906 offer: (request) =>
......@@ -4137,6 +4146,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
41374146 attachmentsDir,
41384147 fileSystem,
41394148 path: yield* Path.Path,
4149 crypto: yield* Crypto.Crypto,
41404150 idAllocator,
41414151 continuationRequests: {
41424152 offer: () => Effect.void,
......@@ -4389,11 +4399,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
43894399 const continuationAttempt = RunAttemptId.make("attempt-claude-wake-callback-3");
43904400 const planToolUseId = "toolu_01WakePlanExitPlanMode";
43914401 const planMarkdown = "# Wake plan\n\n1. Report the background result.";
4392 const stamp = (frame: SDKMessage, attemptId: RunAttemptId) =>
4393 claudeSdkFrame({
4402 const stamp = Effect.fnUntraced(function* (frame: SDKMessage, attemptId: RunAttemptId) {
4403 return claudeSdkFrame({
43944404 ...frame,
4395 user_message_uuid: ClaudeAdapterV2.claudePromptUuid(attemptId),
4405 user_message_uuid: yield* ClaudeAdapterV2.claudePromptUuid(attemptId),
43964406 });
4407 });
43974408 const runOf = (attemptId: RunAttemptId) => RunId.make(`run-${attemptId}`);
43984409 const planToolUse = claudeSdkFrame({
43994410 type: "assistant",
......@@ -4446,10 +4457,10 @@ describe("ClaudeAdapterV2 background wake turns", () => {
44464457 runtimePolicy,
44474458 }),
44484459 );
4449 yield* Queue.offer(harness.sdkMessages, stamp(wakeTaskStarted, firstAttempt));
4460 yield* Queue.offer(harness.sdkMessages, yield* stamp(wakeTaskStarted, firstAttempt));
44504461 yield* Queue.offer(
44514462 harness.sdkMessages,
4452 stamp(
4463 yield* stamp(
44534464 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000740", result: "STARTED" }),
44544465 firstAttempt,
44554466 ),
......@@ -4508,7 +4519,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
45084519 // The prompt's own turn follows and echoes its uuid.
45094520 yield* Queue.offer(
45104521 harness.sdkMessages,
4511 stamp(
4522 yield* stamp(
45124523 makeAssistantTextFrame({
45134524 uuid: "00000000-0000-4000-8000-000000000743",
45144525 text: "USER_REPLY",
......@@ -4518,7 +4529,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
45184529 );
45194530 yield* Queue.offer(
45204531 harness.sdkMessages,
4521 stamp(
4532 yield* stamp(
45224533 makeResultFrame({
45234534 uuid: "00000000-0000-4000-8000-000000000744",
45244535 result: "USER_REPLY",
......@@ -4688,11 +4699,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
46884699 const firstAttempt = RunAttemptId.make("attempt-claude-wake-approval-1");
46894700 const userAttempt = RunAttemptId.make("attempt-claude-wake-approval-2");
46904701 const bashToolUseId = "toolu_01WakeApprovalBash";
4691 const stamp = (frame: SDKMessage, attemptId: RunAttemptId) =>
4692 claudeSdkFrame({
4702 const stamp = Effect.fnUntraced(function* (frame: SDKMessage, attemptId: RunAttemptId) {
4703 return claudeSdkFrame({
46934704 ...frame,
4694 user_message_uuid: ClaudeAdapterV2.claudePromptUuid(attemptId),
4705 user_message_uuid: yield* ClaudeAdapterV2.claudePromptUuid(attemptId),
46954706 });
4707 });
46964708
46974709 yield* harness.runtime.startTurn(
46984710 makeClaudeTestTurnInput({
......@@ -4707,14 +4719,14 @@ describe("ClaudeAdapterV2 background wake turns", () => {
47074719 );
47084720 yield* Queue.offer(
47094721 harness.sdkMessages,
4710 stamp(
4722 yield* stamp(
47114723 makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000780", text: "One." }),
47124724 firstAttempt,
47134725 ),
47144726 );
47154727 yield* Queue.offer(
47164728 harness.sdkMessages,
4717 stamp(
4729 yield* stamp(
47184730 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000781", result: "One." }),
47194731 firstAttempt,
47204732 ),
......@@ -4811,11 +4823,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
48114823 const now = yield* DateTime.now;
48124824 const firstAttempt = RunAttemptId.make("attempt-claude-echo-debris-1");
48134825 const secondAttempt = RunAttemptId.make("attempt-claude-echo-debris-2");
4814 const stamp = (frame: SDKMessage, attemptId: RunAttemptId) =>
4815 claudeSdkFrame({
4826 const stamp = Effect.fnUntraced(function* (frame: SDKMessage, attemptId: RunAttemptId) {
4827 return claudeSdkFrame({
48164828 ...frame,
4817 user_message_uuid: ClaudeAdapterV2.claudePromptUuid(attemptId),
4829 user_message_uuid: yield* ClaudeAdapterV2.claudePromptUuid(attemptId),
48184830 });
4831 });
48194832
48204833 yield* harness.runtime.startTurn(
48214834 makeClaudeTestTurnInput({
......@@ -4830,14 +4843,14 @@ describe("ClaudeAdapterV2 background wake turns", () => {
48304843 // The first turn echoes on its first frame: this process echoes early.
48314844 yield* Queue.offer(
48324845 harness.sdkMessages,
4833 stamp(
4846 yield* stamp(
48344847 makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000760", text: "One." }),
48354848 firstAttempt,
48364849 ),
48374850 );
48384851 yield* Queue.offer(
48394852 harness.sdkMessages,
4840 stamp(
4853 yield* stamp(
48414854 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000761", result: "One." }),
48424855 firstAttempt,
48434856 ),
......@@ -4864,14 +4877,14 @@ describe("ClaudeAdapterV2 background wake turns", () => {
48644877 yield* Queue.offer(harness.sdkMessages, staleTaskNotificationResult);
48654878 yield* Queue.offer(
48664879 harness.sdkMessages,
4867 stamp(
4880 yield* stamp(
48684881 makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000762", text: "Two." }),
48694882 secondAttempt,
48704883 ),
48714884 );
48724885 yield* Queue.offer(
48734886 harness.sdkMessages,
4874 stamp(
4887 yield* stamp(
48754888 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000763", result: "Two." }),
48764889 secondAttempt,
48774890 ),
......@@ -4909,11 +4922,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
49094922 const now = yield* DateTime.now;
49104923 const firstAttempt = RunAttemptId.make("attempt-claude-echo-1");
49114924 const secondAttempt = RunAttemptId.make("attempt-claude-echo-2");
4912 const stamp = (frame: SDKMessage, attemptId: RunAttemptId) =>
4913 claudeSdkFrame({
4925 const stamp = Effect.fnUntraced(function* (frame: SDKMessage, attemptId: RunAttemptId) {
4926 return claudeSdkFrame({
49144927 ...frame,
4915 user_message_uuid: ClaudeAdapterV2.claudePromptUuid(attemptId),
4928 user_message_uuid: yield* ClaudeAdapterV2.claudePromptUuid(attemptId),
49164929 });
4930 });
49174931 const assistantTexts = () =>
49184932 harness.events.flatMap((event) =>
49194933 event.type === "message.updated" && event.message.role === "assistant"
......@@ -4935,18 +4949,18 @@ describe("ClaudeAdapterV2 background wake turns", () => {
49354949 );
49364950 assert.equal(
49374951 harness.offeredMessages[0]?.uuid,
4938 ClaudeAdapterV2.claudePromptUuid(firstAttempt),
4952 yield* ClaudeAdapterV2.claudePromptUuid(firstAttempt),
49394953 );
49404954 yield* Queue.offer(
49414955 harness.sdkMessages,
4942 stamp(
4956 yield* stamp(
49434957 makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000701", text: "One." }),
49444958 firstAttempt,
49454959 ),
49464960 );
49474961 yield* Queue.offer(
49484962 harness.sdkMessages,
4949 stamp(
4963 yield* stamp(
49504964 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000702", result: "One." }),
49514965 firstAttempt,
49524966 ),
......@@ -5995,11 +6009,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
59956009 const userAttempt = RunAttemptId.make("attempt-claude-wake-subagent-2");
59966010 const continuationAttempt = RunAttemptId.make("attempt-claude-wake-subagent-3");
59976011 const runOf = (attemptId: RunAttemptId) => RunId.make(`run-${attemptId}`);
5998 const stamp = (frame: SDKMessage, attemptId: RunAttemptId) =>
5999 claudeSdkFrame({
6012 const stamp = Effect.fnUntraced(function* (frame: SDKMessage, attemptId: RunAttemptId) {
6013 return claudeSdkFrame({
60006014 ...frame,
6001 user_message_uuid: ClaudeAdapterV2.claudePromptUuid(attemptId),
6015 user_message_uuid: yield* ClaudeAdapterV2.claudePromptUuid(attemptId),
60026016 });
6017 });
60036018
60046019 yield* harness.runtime.startTurn(
60056020 makeClaudeTestTurnInput({
......@@ -6011,10 +6026,10 @@ describe("ClaudeAdapterV2 background wake turns", () => {
60116026 attachments: [],
60126027 }),
60136028 );
6014 yield* Queue.offer(harness.sdkMessages, stamp(wakeTaskStarted, firstAttempt));
6029 yield* Queue.offer(harness.sdkMessages, yield* stamp(wakeTaskStarted, firstAttempt));
60156030 yield* Queue.offer(
60166031 harness.sdkMessages,
6017 stamp(
6032 yield* stamp(
60186033 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000770", result: "STARTED" }),
60196034 firstAttempt,
60206035 ),
......@@ -6083,14 +6098,14 @@ describe("ClaudeAdapterV2 background wake turns", () => {
60836098 session_id: WAKE_NATIVE_SESSION,
60846099 }),
60856100 wakeResult,
6086 stamp(
6101 yield* stamp(
60876102 makeAssistantTextFrame({
60886103 uuid: "00000000-0000-4000-8000-000000000776",
60896104 text: "USER_REPLY",
60906105 }),
60916106 userAttempt,
60926107 ),
6093 stamp(
6108 yield* stamp(
60946109 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000777", result: "USER_REPLY" }),
60956110 userAttempt,
60966111 ),
......@@ -7018,6 +7033,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
70187033 attachmentsDir,
70197034 fileSystem,
70207035 path: yield* Path.Path,
7036 crypto: yield* Crypto.Crypto,
70217037 idAllocator,
70227038 continuationRequests: {
70237039 offer: (request) =>
......@@ -7201,6 +7217,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
72017217 attachmentsDir,
72027218 fileSystem,
72037219 path: yield* Path.Path,
7220 crypto: yield* Crypto.Crypto,
72047221 idAllocator,
72057222 continuationRequests: {
72067223 offer: (request) =>
......@@ -7436,6 +7453,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
74367453 attachmentsDir,
74377454 fileSystem,
74387455 path: yield* Path.Path,
7456 crypto: yield* Crypto.Crypto,
74397457 idAllocator,
74407458 continuationRequests: {
74417459 offer: (request) =>
......@@ -7628,6 +7646,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
76287646 attachmentsDir,
76297647 fileSystem,
76307648 path: yield* Path.Path,
7649 crypto: yield* Crypto.Crypto,
76317650 idAllocator,
76327651 continuationRequests: { offer: () => Effect.void },
76337652 queryRunner: {
......@@ -7794,6 +7813,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
77947813 attachmentsDir,
77957814 fileSystem,
77967815 path: yield* Path.Path,
7816 crypto: yield* Crypto.Crypto,
77977817 idAllocator,
77987818 continuationRequests: {
77997819 offer: () => Effect.void,
......@@ -7923,6 +7943,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
79237943 attachmentsDir,
79247944 fileSystem,
79257945 path: yield* Path.Path,
7946 crypto: yield* Crypto.Crypto,
79267947 idAllocator,
79277948 continuationRequests: {
79287949 offer: (request) =>
......@@ -8117,6 +8138,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
81178138 attachmentsDir,
81188139 fileSystem,
81198140 path: yield* Path.Path,
8141 crypto: yield* Crypto.Crypto,
81208142 idAllocator,
81218143 continuationRequests: {
81228144 offer: () => Effect.void,
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts+5-1
......@@ -1580,7 +1580,11 @@ async function recordClaudeStreamingQuery(input: {
15801580 // Like the adapter, give each prompt a uuid Claude echoes on its turn.
15811581 const message = ClaudeAdapterV2.makeClaudeUserMessage({
15821582 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 ),
15841588 });
15851589 input.entries.push({
15861590 type: "expect_outbound",
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+39-24
......@@ -1,5 +1,3 @@
1import * as NodeCrypto from "node:crypto";
2
31import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts";
42import {
53 dynamicToolTitle,
......@@ -73,6 +71,7 @@ import {
7371import * as Cause from "effect/Cause";
7472import * as Context from "effect/Context";
7573import * as Crypto from "effect/Crypto";
74import * as Hex from "effect/encoding/Hex";
7675import * as DateTime from "effect/DateTime";
7776import * as Deferred from "effect/Deferred";
7877import * as Effect from "effect/Effect";
......@@ -1420,11 +1419,17 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input:
14201419
14211420// Stable per run attempt, so a replayed prompt offer matches its recording.
14221421// Claude echoes it back as user_message_uuid on the turn that answers it.
1423export function claudePromptUuid(attemptId: string): NonNullable<SDKUserMessage["uuid"]> {
1424 const hex = NodeCrypto.createHash("sha256").update(`t3-claude-prompt:${attemptId}`).digest("hex");
1422export const claudePromptUuid = Effect.fn("claudePromptUuid")(function* (attemptId: string) {
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);
14251428 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)}`;
1427}
1429 const uuid: NonNullable<SDKUserMessage["uuid"]> =
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
14291434type ClaudeAssistantContentBlock = SDKAssistantMessage["message"]["content"][number];
14301435type ClaudeToolUseContentBlock = Extract<
......@@ -2998,6 +3003,7 @@ export interface ClaudeAdapterV2Options {
29983003 readonly attachmentsDir: string;
29993004 readonly fileSystem: FileSystem.FileSystem;
30003005 readonly path: Path.Path;
3006 readonly crypto: Crypto.Crypto;
30013007 readonly idAllocator: IdAllocator.IdAllocatorV2Shape;
30023008 readonly queryRunner: ClaudeAgentSdkQueryRunnerShape;
30033009 readonly scopedLimitNames?: Ref.Ref<ClaudeScopedLimitNames>;
......@@ -3013,7 +3019,7 @@ export interface ClaudeAdapterV2Options {
30133019export function makeClaudeAdapterV2(
30143020 adapterOptions: ClaudeAdapterV2Options,
30153021): ProviderAdapter.ProviderAdapterV2Shape {
3016 const { attachmentsDir, fileSystem, path, idAllocator, queryRunner } = adapterOptions;
3022 const { attachmentsDir, fileSystem, path, crypto, idAllocator, queryRunner } = adapterOptions;
30173023 const continuationRequests = adapterOptions.continuationRequests ?? {
30183024 offer: () => Effect.void,
30193025 };
......@@ -7242,6 +7248,11 @@ export function makeClaudeAdapterV2(
72427248 const startedAt = yield* DateTime.now;
72437249 const nativeThreadId = yield* getNativeThreadId(turnInput.providerThread);
72447250 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 );
72457256 const providerTurnId = idAllocator.derive.providerTurn({
72467257 driver: CLAUDE_PROVIDER,
72477258 nativeTurnId,
......@@ -7298,9 +7309,7 @@ export function makeClaudeAdapterV2(
72987309 subagentsByToolUseId: new Map(),
72997310 subagentNodesByTaskId: new Map(),
73007311 pendingSubagentLaunchesByToolUseId: new Map(),
7301 promptUuid: isClaudeProviderContinuationTurn(turnInput)
7302 ? null
7303 : claudePromptUuid(turnInput.attemptId),
7312 promptUuid,
73047313 promptEcho: isClaudeProviderContinuationTurn(turnInput) ? "confirmed" : "pending",
73057314 gatedFramesBeforeEcho: 0,
73067315 heldRootFrames: [],
......@@ -7309,20 +7318,20 @@ export function makeClaudeAdapterV2(
73097318 // produced instead of prompting it again: drain the buffered wake
73107319 // messages into this turn and let any still-streaming messages
73117320 // follow live. The continuation prompt text never reaches the CLI.
7312 const isContinuationTurn = context.promptUuid === null;
7313 const userMessage = isContinuationTurn
7314 ? null
7315 : yield* makeClaudeUserMessageWithAttachments({
7316 text: applyClaudePromptEffortPrefix(
7317 turnInput.message.text,
7318 compileClaudeModelSelection(turnInput.modelSelection).promptEffort,
7319 ),
7320 attachments: turnInput.message.attachments,
7321 attachmentsDir,
7322 fileSystem,
7323 skillNames: yield* userInvocableSkillNames(turnInput.runtimePolicy.cwd),
7324 uuid: claudePromptUuid(turnInput.attemptId),
7325 });
7321 const userMessage =
7322 promptUuid === null
7323 ? null
7324 : yield* makeClaudeUserMessageWithAttachments({
7325 text: applyClaudePromptEffortPrefix(
7326 turnInput.message.text,
7327 compileClaudeModelSelection(turnInput.modelSelection).promptEffort,
7328 ),
7329 attachments: turnInput.message.attachments,
7330 attachmentsDir,
7331 fileSystem,
7332 skillNames: yield* userInvocableSkillNames(turnInput.runtimePolicy.cwd),
7333 uuid: promptUuid,
7334 });
73267335 const querySession = yield* openQuery(turnInput, nativeThreadId);
73277336 yield* Ref.set(activeTurn, context);
73287337 yield* emitProviderEvent({
......@@ -7906,6 +7915,7 @@ export function makeClaudeAdapterV2(
79067915
79077916export type ClaudeAdapterV2DriverEnv =
79087917 | ClaudeAgentSdkQueryRunner
7918 | Crypto.Crypto
79097919 | FileSystem.FileSystem
79107920 | IdAllocator.IdAllocatorV2
79117921 | Path.Path
......@@ -7926,6 +7936,7 @@ export const createClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2Driver.create")(
79267936 const baseEnvironment = mergeProviderInstanceEnvironment(environment, hostEnvironment);
79277937 const claudeEnvironment = yield* makeClaudeEnvironment(config, baseEnvironment);
79287938 const path = yield* Path.Path;
7939 const crypto = yield* Crypto.Crypto;
79297940 const binaryPath = yield* resolveClaudeSdkExecutablePath(
79307941 expandHomePath(config.binaryPath),
79317942 claudeEnvironment,
......@@ -7937,6 +7948,7 @@ export const createClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2Driver.create")(
79377948 attachmentsDir: serverConfig.attachmentsDir,
79387949 fileSystem,
79397950 path,
7951 crypto,
79407952 idAllocator,
79417953 queryRunner,
79427954 continuationRequests,
......@@ -7970,6 +7982,7 @@ export const ClaudeAdapterV2Driver: ProviderAdapterDriver<
79707982const makeDefaultClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2.layer")(function* () {
79717983 const fileSystem = yield* FileSystem.FileSystem;
79727984 const path = yield* Path.Path;
7985 const crypto = yield* Crypto.Crypto;
79737986 const hostEnvironment = yield* HostProcessEnvironment;
79747987 const idAllocator = yield* IdAllocator.IdAllocatorV2;
79757988 const queryRunner = yield* ClaudeAgentSdkQueryRunner;
......@@ -7983,6 +7996,7 @@ const makeDefaultClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2.layer")(function*
79837996 attachmentsDir: serverConfig.attachmentsDir,
79847997 fileSystem,
79857998 path,
7999 crypto,
79868000 idAllocator,
79878001 queryRunner,
79888002 continuationRequests,
......@@ -7993,6 +8007,7 @@ const layer: Layer.Layer<
79938007 ProviderAdapter.ProviderAdapterV2,
79948008 never,
79958009 | ClaudeAgentSdkQueryRunner
8010 | Crypto.Crypto
79968011 | FileSystem.FileSystem
79978012 | IdAllocator.IdAllocatorV2
79988013 | 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";
3535import * as CodexReplay from "effect-codex-app-server/replay";
3636import * as DateTime from "effect/DateTime";
3737import * as Deferred from "effect/Deferred";
38import * as Crypto from "effect/Crypto";
3839import * as Effect from "effect/Effect";
3940import * as Predicate from "effect/Predicate";
4041import * as FileSystem from "effect/FileSystem";
......@@ -801,183 +802,195 @@ describe("CodexAdapterV2 process spawning", () => {
801802});
802803
803804describe("CodexAdapterV2 dynamic tool projection", () => {
804 it("uses the CUA call title while leaving other MCP titles as tool arguments", () => {
805 const call = {
806 type: "mcpToolCall" as const,
807 id: "inspect",
808 server: "cua_repl",
809 tool: "js",
810 status: "completed" as const,
811 arguments: {
812 code: "await game.getAXStateAndScreenshot();",
813 title: "Inspect Saga music screen",
814 },
815 result: { content: [] },
816 };
817 assert.equal(
818 CodexAdapterV2.projectCodexDynamicToolItem(call).title,
819 "Inspect Saga music screen",
820 );
821 assert.equal(
822 CodexAdapterV2.projectCodexDynamicToolItem({ ...call, arguments: { title: " " } }).title,
823 "js",
824 );
825 assert.equal(
826 CodexAdapterV2.projectCodexDynamicToolItem({ ...call, server: "github" }).title,
827 "js",
828 );
829 });
805 it.effect("uses the CUA call title while leaving other MCP titles as tool arguments", () =>
806 Effect.gen(function* () {
807 const call = {
808 type: "mcpToolCall" as const,
809 id: "inspect",
810 server: "cua_repl",
811 tool: "js",
812 status: "completed" as const,
813 arguments: {
814 code: "await game.getAXStateAndScreenshot();",
815 title: "Inspect Saga music screen",
816 },
817 result: { content: [] },
818 };
819 assert.equal(
820 (yield* CodexAdapterV2.projectCodexDynamicToolItem(call)).title,
821 "Inspect Saga music screen",
822 );
823 assert.equal(
824 (yield* CodexAdapterV2.projectCodexDynamicToolItem({ ...call, arguments: { title: " " } }))
825 .title,
826 "js",
827 );
828 assert.equal(
829 (yield* CodexAdapterV2.projectCodexDynamicToolItem({ ...call, server: "github" })).title,
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)(
832836 "presents ordinary MCP calls when %s",
833 (status) => {
834 const projection = CodexAdapterV2.projectCodexDynamicToolItem({
837 (status) =>
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({
835860 type: "mcpToolCall",
836 id: "weather-call",
837 server: "weather",
838 tool: "get_weather",
839 status,
840 arguments: { city: "Berlin" },
861 id: "connector-call",
862 server: "_apps",
863 tool: "connector_get_weather",
864 status: "completed",
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 },
841875 });
842 assert.equal(projection.title, "get weather");
843 assert.deepEqual(projection.toolSource, {
844 key: "mcp:weather",
845 name: "weather",
846 kind: "integration",
876 assert.equal(projection.title, "Check weather");
877 assert.equal(projection.toolSource?.name, "Weather");
878 assert.deepEqual(projection.toolIcon, {
879 _tag: "themed-logo",
880 logoUrl: "https://example.com/weather.png",
847881 });
848 assert.deepEqual(projection.input, { city: "Berlin" });
849 },
882 assert.deepEqual(projection.toolSource?.icon, projection.toolIcon);
883 }).pipe(Effect.provide(NodeServices.layer)),
850884 );
851885
852 it("uses Codex connector names without reading a display title from arguments", () => {
853 const projection = CodexAdapterV2.projectCodexDynamicToolItem({
854 type: "mcpToolCall",
855 id: "connector-call",
856 server: "_apps",
857 tool: "connector_get_weather",
858 status: "completed",
859 arguments: { title: "Argument, not display metadata" },
860 appContext: {
861 connectorId: "weather-app",
862 appName: "Weather",
863 actionName: "Check weather",
864 },
865 result: {
866 content: [],
867 _meta: { source: { logoUrl: "https://example.com/weather.png" } },
868 },
869 });
870 assert.equal(projection.title, "Check weather");
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",
886 it.effect("preserves native browser and app icons alongside MCP tool output", () =>
887 Effect.gen(function* () {
888 const browser = yield* CodexAdapterV2.projectCodexDynamicToolItem({
889 type: "mcpToolCall",
890 id: "browser",
891 server: "browser",
892 tool: "open",
893 status: "completed",
894 arguments: {},
895 result: {
896 content: [],
897 _meta: {
898 "codex/toolSurface": {
899 kind: "browserUse",
900 browserFamily: "Chrome",
901 screenshot: {
902 pageUrl: "https://example.com/docs",
903 faviconUrl: "https://example.com/icon.png",
904 },
896905 },
897906 },
898907 },
899 },
900 });
901 assert.equal(browser.toolSurface, "browser");
902 assert.deepEqual(browser.toolIcon, {
903 _tag: "website",
904 pageUrl: "https://example.com/docs",
905 faviconUrl: "https://example.com/icon.png",
906 });
907 assert.equal(browser.toolSource?.name, "Chrome");
908 const app = CodexAdapterV2.projectCodexDynamicToolItem({
909 type: "mcpToolCall",
910 id: "app",
911 server: "computer",
912 tool: "click",
913 status: "completed",
914 arguments: {},
915 result: {
916 content: [],
917 _meta: {
918 "codex/toolSurface": {
919 kind: "computerUse",
920 app: { kind: "appId", appId: "com.apple.finder" },
908 });
909 assert.equal(browser.toolSurface, "browser");
910 assert.deepEqual(browser.toolIcon, {
911 _tag: "website",
912 pageUrl: "https://example.com/docs",
913 faviconUrl: "https://example.com/icon.png",
914 });
915 assert.equal(browser.toolSource?.name, "Chrome");
916 const app = yield* CodexAdapterV2.projectCodexDynamicToolItem({
917 type: "mcpToolCall",
918 id: "app",
919 server: "computer",
920 tool: "click",
921 status: "completed",
922 arguments: {},
923 result: {
924 content: [],
925 _meta: {
926 "codex/toolSurface": {
927 kind: "computerUse",
928 app: { kind: "appId", appId: "com.apple.finder" },
929 },
921930 },
922931 },
923 },
924 });
925 assert.deepEqual(app.toolIcon, {
926 _tag: "native-app",
927 app: { _tag: "app-id", appId: "com.apple.finder" },
928 });
929 assert.equal(app.toolSource?.name, "Finder");
930 });
932 });
933 assert.deepEqual(app.toolIcon, {
934 _tag: "native-app",
935 app: { _tag: "app-id", appId: "com.apple.finder" },
936 });
937 assert.equal(app.toolSource?.name, "Finder");
938 }).pipe(Effect.provide(NodeServices.layer)),
939 );
931940
932 it("preserves MCP arguments and prefers structured output", () => {
933 const projection = CodexAdapterV2.projectCodexDynamicToolItem({
934 type: "mcpToolCall",
935 id: "call-create-threads",
936 server: "t3-code",
937 tool: "create_threads",
938 status: "completed",
939 arguments: {
940 threads: [{ title: "Fixture child", prompt: "fixture child prompt" }],
941 },
942 result: {
943 content: [{ type: "text", text: '{"threads":[{"threadId":"thread:mcp:fixture:0"}]}' }],
944 structuredContent: {
945 threads: [{ threadId: "thread:mcp:fixture:0" }],
941 it.effect("preserves MCP arguments and prefers structured output", () =>
942 Effect.gen(function* () {
943 const projection = yield* CodexAdapterV2.projectCodexDynamicToolItem({
944 type: "mcpToolCall",
945 id: "call-create-threads",
946 server: "t3-code",
947 tool: "create_threads",
948 status: "completed",
949 arguments: {
950 threads: [{ title: "Fixture child", prompt: "fixture child prompt" }],
946951 },
947 },
948 });
952 result: {
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, {
951 toolName: "t3-code.create_threads",
952 input: {
953 threads: [{ title: "Fixture child", prompt: "fixture child prompt" }],
954 },
955 output: {
956 threads: [{ threadId: "thread:mcp:fixture:0" }],
957 },
958 status: "completed",
959 });
960 });
960 assert.deepEqual(projection, {
961 toolName: "t3-code.create_threads",
962 input: {
963 threads: [{ title: "Fixture child", prompt: "fixture child prompt" }],
964 },
965 output: {
966 threads: [{ threadId: "thread:mcp:fixture:0" }],
967 },
968 status: "completed",
969 });
970 }).pipe(Effect.provide(NodeServices.layer)),
971 );
961972
962 it("preserves namespaced dynamic tool output", () => {
963 const projection = CodexAdapterV2.projectCodexDynamicToolItem({
964 type: "dynamicToolCall",
965 id: "call-dynamic",
966 namespace: "workspace",
967 tool: "inspect",
968 status: "failed",
969 arguments: { path: "package.json" },
970 contentItems: [{ type: "inputText", text: "inspection failed" }],
971 success: false,
972 });
973 it.effect("preserves namespaced dynamic tool output", () =>
974 Effect.gen(function* () {
975 const projection = yield* CodexAdapterV2.projectCodexDynamicToolItem({
976 type: "dynamicToolCall",
977 id: "call-dynamic",
978 namespace: "workspace",
979 tool: "inspect",
980 status: "failed",
981 arguments: { path: "package.json" },
982 contentItems: [{ type: "inputText", text: "inspection failed" }],
983 success: false,
984 });
973985
974 assert.deepEqual(projection, {
975 toolName: "workspace.inspect",
976 input: { path: "package.json" },
977 output: [{ type: "inputText", text: "inspection failed" }],
978 status: "failed",
979 });
980 });
986 assert.deepEqual(projection, {
987 toolName: "workspace.inspect",
988 input: { path: "package.json" },
989 output: [{ type: "inputText", text: "inspection failed" }],
990 status: "failed",
991 });
992 }).pipe(Effect.provide(NodeServices.layer)),
993 );
981994});
982995
983996describe("CodexAdapterV2 native protocol logging", () => {
......@@ -1703,6 +1716,7 @@ describe("CodexAdapterV2 session initialize", () => {
17031716 ),
17041717 ),
17051718 },
1719 crypto: yield* Crypto.Crypto,
17061720 fileSystem: yield* FileSystem.FileSystem,
17071721 idAllocator: yield* IdAllocator.IdAllocatorV2,
17081722 serverConfig: yield* makeReplayServerConfig(transcript.scenario).pipe(Effect.orDie),
......@@ -1899,6 +1913,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
18991913 settings: DEFAULT_CODEX_SETTINGS,
19001914 environment: {},
19011915 clientFactory,
1916 crypto: yield* Crypto.Crypto,
19021917 fileSystem,
19031918 idAllocator,
19041919 serverConfig,
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+35-23
......@@ -66,6 +66,7 @@ import * as CodexClient from "effect-codex-app-server/client";
6666import * as CodexErrors from "effect-codex-app-server/errors";
6767import * as CodexSchema from "effect-codex-app-server/schema";
6868import * as Context from "effect/Context";
69import * as Crypto from "effect/Crypto";
6970import * as DateTime from "effect/DateTime";
7071import * as Deferred from "effect/Deferred";
7172import * as Effect from "effect/Effect";
......@@ -479,26 +480,29 @@ function codexDynamicToolOutput(
479480 return item.success === false ? { success: false } : undefined;
480481}
481482
482export function projectCodexDynamicToolItem(
483 item: CodexDynamicToolItem,
484): CodexDynamicToolProjection {
485 const output =
486 item.type === "mcpToolCall" ? codexMcpToolOutput(item) : codexDynamicToolOutput(item);
487 const toolName =
488 item.type === "mcpToolCall"
489 ? `${item.server}.${item.tool}`
490 : [trimText(item.namespace), item.tool].filter(Boolean).join(".");
491 const presentation = item.type === "mcpToolCall" ? mcpToolPresentation(item) : {};
492 const title = dynamicToolTitle(toolName, item.arguments) ?? presentation.title;
493 const projection: CodexDynamicToolProjection = {
494 ...presentation,
495 toolName,
496 ...(title ? { title } : {}),
497 input: item.arguments,
498 status: codexItemStatus(item.status).turnItem,
499 };
500 return output === undefined ? projection : { ...projection, output };
501}
483export const projectCodexDynamicToolItem = Effect.fn("CodexAdapterV2.projectDynamicToolItem")(
484 function* (
485 item: CodexDynamicToolItem,
486 ): Effect.fn.Return<CodexDynamicToolProjection, never, Crypto.Crypto> {
487 const output =
488 item.type === "mcpToolCall" ? codexMcpToolOutput(item) : codexDynamicToolOutput(item);
489 const toolName =
490 item.type === "mcpToolCall"
491 ? `${item.server}.${item.tool}`
492 : [trimText(item.namespace), item.tool].filter(Boolean).join(".");
493 const presentation: McpToolPresentation =
494 item.type === "mcpToolCall" ? yield* mcpToolPresentation(item) : {};
495 const title = dynamicToolTitle(toolName, item.arguments) ?? presentation.title;
496 const projection: CodexDynamicToolProjection = {
497 ...presentation,
498 toolName,
499 ...(title ? { title } : {}),
500 input: item.arguments,
501 status: codexItemStatus(item.status).turnItem,
502 };
503 return output === undefined ? projection : { ...projection, output };
504 },
505);
502506
503507function codexNativeItemRef(nativeItemId: string) {
504508 return {
......@@ -1513,6 +1517,7 @@ export const layerAppServerClientFactory: Layer.Layer<
15131517
15141518export type CodexAdapterV2DriverEnv =
15151519 | CodexAppServerClientFactory
1520 | Crypto.Crypto
15161521 | FileSystem.FileSystem
15171522 | IdAllocatorV2
15181523 | Path.Path
......@@ -1525,6 +1530,7 @@ export const createCodexAdapterV2 = (
15251530 Effect.gen(function* () {
15261531 const clientFactory = yield* CodexAppServerClientFactory;
15271532 const continuationRequests = yield* ProviderContinuationRequests;
1533 const crypto = yield* Crypto.Crypto;
15281534 const fileSystem = yield* FileSystem.FileSystem;
15291535 const hostEnvironment = yield* HostProcessEnvironment;
15301536 const idAllocator = yield* IdAllocatorV2;
......@@ -1555,6 +1561,7 @@ export const createCodexAdapterV2 = (
15551561 settings,
15561562 environment: mergeProviderInstanceEnvironment(environment, hostEnvironment),
15571563 clientFactory,
1564 crypto,
15581565 fileSystem,
15591566 idAllocator,
15601567 serverConfig,
......@@ -1573,12 +1580,13 @@ export const CodexAdapterV2Driver: ProviderAdapterDriver<CodexSettings, CodexAda
15731580const layer: Layer.Layer<
15741581 ProviderAdapterV2,
15751582 never,
1576 CodexAppServerClientFactory | FileSystem.FileSystem | IdAllocatorV2 | ServerConfig
1583 CodexAppServerClientFactory | Crypto.Crypto | FileSystem.FileSystem | IdAllocatorV2 | ServerConfig
15771584> = Layer.effect(
15781585 ProviderAdapterV2,
15791586 Effect.gen(function* () {
15801587 const clientFactory = yield* CodexAppServerClientFactory;
15811588 const continuationRequests = yield* ProviderContinuationRequests;
1589 const crypto = yield* Crypto.Crypto;
15821590 const fileSystem = yield* FileSystem.FileSystem;
15831591 const hostEnvironment = yield* HostProcessEnvironment;
15841592 const idAllocator = yield* IdAllocatorV2;
......@@ -1589,6 +1597,7 @@ const layer: Layer.Layer<
15891597 settings: DEFAULT_CODEX_SETTINGS,
15901598 environment: hostEnvironment,
15911599 clientFactory,
1600 crypto,
15921601 fileSystem,
15931602 idAllocator,
15941603 serverConfig,
......@@ -1609,6 +1618,7 @@ export interface CodexAdapterV2Options {
16091618 * Codex with a current access token.
16101619 */
16111620 readonly resolveRuntime?: Effect.Effect<CodexEffectiveRuntime, ProviderSetupError, Scope.Scope>;
1621 readonly crypto: Crypto.Crypto;
16121622 readonly fileSystem: FileSystem.FileSystem;
16131623 readonly idAllocator: IdAllocatorV2Shape;
16141624 readonly serverConfig: ServerConfig["Service"];
......@@ -1623,7 +1633,7 @@ export interface CodexAdapterV2Options {
16231633}
16241634
16251635export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): ProviderAdapterV2Shape {
1626 const { clientFactory, fileSystem, idAllocator, serverConfig } = adapterOptions;
1636 const { clientFactory, crypto, fileSystem, idAllocator, serverConfig } = adapterOptions;
16271637 const continuationRequests = adapterOptions.continuationRequests;
16281638
16291639 return ProviderAdapterV2.of({
......@@ -3480,7 +3490,9 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
34803490 nativeItemId: item.id,
34813491 });
34823492 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 );
34843496 const node: OrchestrationV2ExecutionNode = {
34853497 id: nodeId,
34863498 threadId: context.projectionThreadId,
apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts+20-15
......@@ -3,6 +3,7 @@
33 * driven through the real adapter and `@opencode/client` against a replayed
44 * HTTP server. Frames reuse the shapes recorded against 2.0.18.
55 */
6import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
67import { assert, it } from "@effect/vitest";
78import {
89 CheckpointId,
......@@ -2766,21 +2767,25 @@ describe("OpenCode2 adapter", () => {
27662767 }).pipe(Effect.scoped),
27672768 );
27682769
2769 it("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 const ids = [
2772 `${project}:d3b2d715-c4a1-4b63-bb65-1634c3a3a8c4`,
2773 `${project}:d3b2d715-c4a1-4b63-bb65-1634c3a3a8c5`,
2774 "thread:delegated-task:command%3Amcp%3A48bef2bf-6d0e-4f7a-9c3b-2e5d8a1f7c40%3Adelegate-task%3Around1",
2775 "thread:delegated-task:command%3Amcp%3A48bef2bf-6d0e-4f7a-9c3b-2e5d8a1f7c40%3Adelegate-task%3Around2",
2776 ];
2777 const names = ids.map(t3McpServerName);
2778 for (const name of names) assert.match(name, /^t3-code-[A-Za-z0-9_-]{1,56}$/);
2779 assert.equal(new Set(names).size, ids.length);
2780 assert.deepEqual(ids.map(t3McpServerName), names);
2781 // A name that already fits stays readable.
2782 assert.equal(t3McpServerName(threadId), "t3-code-thread_opencode2-adapter");
2783 });
2770 it.effect("names each thread's MCP server within OpenCode's limits, one name per thread", () =>
2771 Effect.gen(function* () {
2772 const project = "thread:project:ce04e4e2-6c29-4ff0-a1d7-b089dd63e258";
2773 const ids = [
2774 `${project}:d3b2d715-c4a1-4b63-bb65-1634c3a3a8c4`,
2775 `${project}:d3b2d715-c4a1-4b63-bb65-1634c3a3a8c5`,
2776 "thread:delegated-task:command%3Amcp%3A48bef2bf-6d0e-4f7a-9c3b-2e5d8a1f7c40%3Adelegate-task%3Around1",
2777 "thread:delegated-task:command%3Amcp%3A48bef2bf-6d0e-4f7a-9c3b-2e5d8a1f7c40%3Adelegate-task%3Around2",
2778 ];
2779 const names = yield* Effect.forEach(ids, t3McpServerName);
2780 for (const name of names) assert.match(name, /^t3-code-[A-Za-z0-9_-]{1,56}$/);
2781 assert.equal(new Set(names).size, ids.length);
2782 assert.deepEqual(yield* Effect.forEach(ids, t3McpServerName), names);
2783 // A digested name is the one the synchronous node:crypto version produced.
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
27852790 it.effect("reads user and assistant text from the session's message list", () =>
27862791 Effect.gen(function* () {
apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts+14-2
......@@ -284,7 +284,13 @@ function layerRegistry(
284284 Effect.map((adapter) => ProviderAdapterRegistry.layerFromAdapters([adapter])),
285285 ),
286286 ).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 ),
288294 );
289295}
290296
......@@ -322,7 +328,13 @@ export const openCode2ReplayRuntime = (
322328 },
323329 });
324330 }).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 ),
326338 );
327339
328340export const OpenCode2OrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness<
apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts+19-12
......@@ -21,7 +21,6 @@
2121 *
2222 * @module orchestration-v2/Adapters/OpenCode2AdapterV2
2323 */
24import * as NodeCrypto from "node:crypto";
2524
2625import {
2726 AbsolutePath,
......@@ -59,7 +58,9 @@ import {
5958import type * as Cause from "effect/Cause";
6059import * as DateTime from "effect/DateTime";
6160import * as Deferred from "effect/Deferred";
61import * as Crypto from "effect/Crypto";
6262import * as Effect from "effect/Effect";
63import * as Hex from "effect/encoding/Hex";
6364import * as Exit from "effect/Exit";
6465import * as Option from "effect/Option";
6566import * as Queue from "effect/Queue";
......@@ -453,12 +454,15 @@ const rule = (action: string, effect: Rule["effect"]): Rule => ({ action, resour
453454 * tool namespace limit), and its router rejects adding one over 100, so a
454455 * thread id that does not fit is replaced by a digest of it.
455456 */
456export const t3McpServerName = (threadId: string) => {
457export const t3McpServerName = Effect.fn("t3McpServerName")(function* (threadId: string) {
457458 const name = `t3-code-${threadId.replaceAll(/[^a-zA-Z0-9_-]/g, "_")}`;
458459 if (name.length <= 64) return name;
459 const digest = NodeCrypto.createHash("sha256").update(threadId).digest("hex");
460 return `t3-code-${digest.slice(0, 16)}`;
461};
460 const crypto = yield* Crypto.Crypto;
461 const digest = yield* crypto
462 .digest("SHA-256", new TextEncoder().encode(threadId))
463 .pipe(Effect.orDie);
464 return `t3-code-${Hex.encode(digest).slice(0, 16)}`;
465});
462466
463467/**
464468 * 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) => {
466470 * this thread's own is allowed again, in every mode. A subagent's session
467471 * inherits the thread's.
468472 */
469const mcpRules = (threadId: string | null): ReadonlyArray<Rule> =>
470 threadId === null
473const mcpRules = (mcpServerName: string | null): ReadonlyArray<Rule> =>
474 mcpServerName === null
471475 ? []
472476 : [
473477 { action: "t3-code-*", resource: "*", effect: "deny" },
474 { action: `${t3McpServerName(threadId)}_*`, resource: "*", effect: "allow" },
478 { action: `${mcpServerName}_*`, resource: "*", effect: "allow" },
475479 ];
476480
477481const sessionRules = (
478482 policy: RulesPolicy,
479483 paths: ReadonlyArray<Rule>,
480484 grants: ReadonlyArray<Rule>,
481 threadId: string | null,
485 mcpServerName: string | null,
482486): ReadonlyArray<Rule> => [
483487 ...(policy.runtimeMode === "full-access"
484488 ? [rule("*", "allow")]
......@@ -492,7 +496,7 @@ const sessionRules = (
492496 // are never denied: the free tier refuses sessions whose rules deny them.
493497 ...(policy.interactionMode === "plan" ? [rule("edit", "deny")] : []),
494498 ...paths,
495 ...mcpRules(threadId),
499 ...mcpRules(mcpServerName),
496500];
497501
498502const sameRules = (left: ReadonlyArray<Rule> | undefined, right: ReadonlyArray<Rule>) =>
......@@ -828,7 +832,10 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
828832 const idAllocator = yield* IdAllocator.IdAllocatorV2;
829833 const serverConfig = yield* ServerConfig.ServerConfig;
830834 const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
835 const crypto = yield* Crypto.Crypto;
831836 const driver = OPENCODE_PROVIDER;
837 const mcpServerNameFor = (threadId: string) =>
838 t3McpServerName(threadId).pipe(Effect.provideService(Crypto.Crypto, crypto));
832839
833840 /**
834841 * 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
29772984 policy,
29782985 paths,
29792986 policy.runtimeMode === "full-access" ? [] : thread.grants,
2980 appThreadId,
2987 appThreadId === null ? null : yield* mcpServerNameFor(appThreadId),
29812988 );
29822989 });
29832990
......@@ -3234,7 +3241,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
32343241 ) {
32353242 const mcpSession = McpProviderSession.readMcpProviderSession(turnInput.threadId);
32363243 const directory = turnInput.runtimePolicy.cwd ?? serverConfig.cwd;
3237 const name = t3McpServerName(turnInput.threadId);
3244 const name = yield* mcpServerNameFor(turnInput.threadId);
32383245 // An external server may not reach T3's MCP endpoint, as with 1.x.
32393246 const wanted =
32403247 mcpSession === undefined || connection.external
apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts+2
......@@ -1,3 +1,4 @@
1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
12import { assert, it } from "@effect/vitest";
23import {
34 CheckpointId,
......@@ -274,6 +275,7 @@ it.layer(layerProjectionStoreTest)("CheckpointCaptureServiceV2", (it) => {
274275 Layer.provide(
275276 Layer.mergeAll(
276277 IdAllocator.layer,
278 NodeCrypto.layer,
277279 Layer.mock(CheckpointStore.CheckpointStore)({
278280 isGitRepository: () => Effect.succeed(true),
279281 captureCheckpoint: () => Effect.void,
apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts+10-9
......@@ -1,3 +1,4 @@
1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
12import { assert, it } from "@effect/vitest";
23import * as DateTime from "effect/DateTime";
34import * as Effect from "effect/Effect";
......@@ -24,6 +25,7 @@ const layerProjection = Layer.mergeAll(
2425 ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)),
2526 SqlitePersistence.layerMemory,
2627 IdAllocator.layer,
28 NodeCrypto.layer,
2729);
2830it.effect("resolves the thread baseline after a second root run replaces scope ownership", () =>
2931 Effect.gen(function* () {
......@@ -38,6 +40,11 @@ it.effect("resolves the thread baseline after a second root run replaces scope o
3840 const providerThreadId = ProviderThreadId.make("provider-thread:audit-root-scope");
3941 const providerInstanceId = ProviderInstanceId.make("codex");
4042 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 ];
4148 const baselineId = CheckpointId.make("checkpoint:audit-root-scope:0");
4249 const firstCheckpointId = CheckpointId.make("checkpoint:audit-root-scope:1");
4350 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
154161 input.ordinal === 0 ? null : input.ordinal === 1 ? baselineId : firstCheckpointId,
155162 ordinalWithinScope: input.ordinal,
156163 appRunOrdinal: input.appRunOrdinal,
157 ref: checkpointRefForScopeOrdinal({ scopeId, ordinalWithinScope: input.ordinal }),
164 ref: checkpointRefs[input.ordinal]!,
158165 status: "ready" as const,
159166 files: [],
160167 capturedAt: now,
......@@ -214,14 +221,8 @@ it.effect("resolves the thread baseline after a second root run replaces scope o
214221 Layer.mock(CheckpointStore.CheckpointStore)({
215222 diffCheckpoints: (input) => {
216223 assert.equal(input.cwd, "/prepared-repo");
217 assert.equal(
218 input.fromCheckpointRef,
219 checkpointRefForScopeOrdinal({ scopeId, ordinalWithinScope: 0 }),
220 );
221 assert.equal(
222 input.toCheckpointRef,
223 checkpointRefForScopeOrdinal({ scopeId, ordinalWithinScope: 2 }),
224 );
224 assert.equal(input.fromCheckpointRef, checkpointRefs[0]);
225 assert.equal(input.toCheckpointRef, checkpointRefs[2]);
225226 return Effect.succeed("two-run diff");
226227 },
227228 }),
apps/server/src/orchestration-v2/CheckpointService.test.ts+3-1
......@@ -1,3 +1,4 @@
1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
12import { assert, it, vi } from "@effect/vitest";
23import {
34 CheckpointScopeId,
......@@ -58,6 +59,7 @@ it.effect.each([false, true, "interrupt"] as const)(
5859 }),
5960 ),
6061 ),
62 Layer.provideMerge(NodeCrypto.layer),
6163 );
6264
6365 return Effect.gen(function* () {
......@@ -88,7 +90,7 @@ it.effect.each([false, true, "interrupt"] as const)(
8890 assert.equal(baseline.ordinalWithinScope, 2);
8991 assert.equal(
9092 baseline.ref,
91 CheckpointService.checkpointRefForScopeOrdinal({
93 yield* CheckpointService.checkpointRefForScopeOrdinal({
9294 scopeId: scope.id,
9395 ordinalWithinScope: 2,
9496 }),
apps/server/src/orchestration-v2/CheckpointService.ts+22-15
......@@ -9,11 +9,12 @@ import {
99 RunId,
1010 ThreadId,
1111} from "@t3tools/contracts";
12import * as NodeCrypto from "node:crypto";
1312import * as Context from "effect/Context";
13import * as Crypto from "effect/Crypto";
1414import * as DateTime from "effect/DateTime";
1515import * as Effect from "effect/Effect";
1616import * as Base64Url from "effect/encoding/Base64Url";
17import * as Hex from "effect/encoding/Hex";
1718import * as KeyedLock from "@t3tools/shared/KeyedLock";
1819import * as Layer from "effect/Layer";
1920import * as Schema from "effect/Schema";
......@@ -157,15 +158,18 @@ export class CheckpointServiceV2 extends Context.Service<
157158 CheckpointServiceV2Shape
158159>()("t3/orchestration-v2/CheckpointService/CheckpointServiceV2") {}
159160
160export function checkpointRefForScopeOrdinal(input: {
161 readonly scopeId: CheckpointScopeId;
162 readonly ordinalWithinScope: number;
163}): CheckpointRef {
164 const scopeKey = NodeCrypto.createHash("sha256").update(input.scopeId).digest("hex").slice(0, 32);
165 return CheckpointRef.make(
166 `${CHECKPOINT_REFS_PREFIX}/${Base64Url.encode(scopeKey)}/ordinal/${input.ordinalWithinScope}`,
167 );
168}
161export const checkpointRefForScopeOrdinal = Effect.fn("checkpointRefForScopeOrdinal")(
162 function* (input: { readonly scopeId: CheckpointScopeId; readonly ordinalWithinScope: number }) {
163 const crypto = yield* Crypto.Crypto;
164 const digest = yield* crypto
165 .digest("SHA-256", new TextEncoder().encode(input.scopeId))
166 .pipe(Effect.orDie);
167 const scopeKey = Hex.encode(digest).slice(0, 32);
168 return CheckpointRef.make(
169 `${CHECKPOINT_REFS_PREFIX}/${Base64Url.encode(scopeKey)}/ordinal/${input.ordinalWithinScope}`,
170 );
171 },
172);
169173
170174function checkpointIdForScopeOrdinal(
171175 idAllocator: IdAllocator.IdAllocatorV2Shape,
......@@ -242,11 +246,14 @@ function makeCheckpoint(input: {
242246export const layer: Layer.Layer<
243247 CheckpointServiceV2,
244248 never,
245 CheckpointStore.CheckpointStore | IdAllocator.IdAllocatorV2
249 CheckpointStore.CheckpointStore | Crypto.Crypto | IdAllocator.IdAllocatorV2
246250> = Layer.effect(
247251 CheckpointServiceV2,
248252 Effect.gen(function* () {
249253 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));
250257 const idAllocator = yield* IdAllocator.IdAllocatorV2;
251258 const workspaceLocks = yield* KeyedLock.make<string>();
252259 const withWorkspaceLock = <A, E, R>(cwd: string, effect: Effect.Effect<A, E, R>) =>
......@@ -265,7 +272,7 @@ export const layer: Layer.Layer<
265272 return;
266273 }
267274
268 const checkpointRef = checkpointRefForScopeOrdinal({
275 const checkpointRef = yield* checkpointRefFor({
269276 scopeId: input.scope.id,
270277 ordinalWithinScope: input.ordinalWithinScope,
271278 });
......@@ -298,7 +305,7 @@ export const layer: Layer.Layer<
298305 withWorkspaceLock(
299306 input.scope.cwd,
300307 Effect.gen(function* () {
301 const checkpointRef = checkpointRefForScopeOrdinal({
308 const checkpointRef = yield* checkpointRefFor({
302309 scopeId: input.scope.id,
303310 ordinalWithinScope: input.ordinalWithinScope,
304311 });
......@@ -362,11 +369,11 @@ export const layer: Layer.Layer<
362369 ordinalWithinScope: input.ordinalWithinScope - 1,
363370 })
364371 : null;
365 const checkpointRef = checkpointRefForScopeOrdinal({
372 const checkpointRef = yield* checkpointRefFor({
366373 scopeId: input.scope.id,
367374 ordinalWithinScope: input.ordinalWithinScope,
368375 });
369 const previousCheckpointRef = checkpointRefForScopeOrdinal({
376 const previousCheckpointRef = yield* checkpointRefFor({
370377 scopeId: input.scope.id,
371378 ordinalWithinScope: Math.max(0, input.ordinalWithinScope - 1),
372379 });
apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts+2
......@@ -15,6 +15,7 @@ import {
1515 type OrchestrationV2DomainEvent,
1616} from "@t3tools/contracts";
1717import * as DateTime from "effect/DateTime";
18import * as Crypto from "effect/Crypto";
1819import * as Effect from "effect/Effect";
1920import * as FileSystem from "effect/FileSystem";
2021import * as Fiber from "effect/Fiber";
......@@ -137,6 +138,7 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con
137138 attachmentsDir: cwd,
138139 fileSystem: yield* FileSystem.FileSystem,
139140 path: yield* Path.Path,
141 crypto: yield* Crypto.Crypto,
140142 idAllocator: yield* IdAllocator.IdAllocatorV2,
141143 queryRunner: {
142144 allocateSessionId: Effect.succeed(sessionId),
apps/server/src/orchestration-v2/ProviderTurnControlService.ts+17-10
......@@ -7,12 +7,12 @@ import {
77 RunAttemptId,
88 ThreadId,
99} from "@t3tools/contracts";
10import * as Clock from "effect/Clock";
1011import * as Context from "effect/Context";
1112import * as Effect from "effect/Effect";
1213import * as Layer from "effect/Layer";
1314import * as Option from "effect/Option";
1415import * as Schema from "effect/Schema";
15import * as NodeTimersPromises from "node:timers/promises";
1616
1717import * as ProjectionStore from "./ProjectionStore.ts";
1818import * as ProviderSessionManager from "./ProviderSessionManager.ts";
......@@ -188,17 +188,24 @@ export const layer: Layer.Layer<
188188 });
189189 // Give native terminal ingestion time to finish before the Stop
190190 // follow-up repairs a run whose provider no longer reports on it.
191 const deadline = performance.now() + 2_000;
192 while (loaded.providerTurn.status === "running" && performance.now() < deadline) {
193 const current = yield* projections.getProviderControlContext(input.threadId, input);
194 if (
195 current.providerTurn?.status !== "running" &&
196 current.attempt?.status !== "running"
191 // The wait is real time: ingestion runs on other fibers and never
192 // advances a test clock, so a test clock would hold Stop forever.
193 yield* Effect.gen(function* () {
194 const deadline = (yield* Clock.currentTimeMillis) + 2_000;
195 while (
196 loaded.providerTurn.status === "running" &&
197 (yield* Clock.currentTimeMillis) < deadline
197198 ) {
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");
199207 }
200 yield* Effect.promise(() => NodeTimersPromises.setTimeout(10));
201 }
208 }).pipe(Effect.provideService(Clock.Clock, Clock.Clock.defaultValue()));
202209 }).pipe(
203210 Effect.mapError((cause) =>
204211 isProviderTurnControlError(cause)
apps/server/src/preview/PreviewBrowser.test.ts+6-2
......@@ -2,9 +2,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
22import { expect, it } from "@effect/vitest";
33import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
44import * as Deferred from "effect/Deferred";
5import * as Crypto from "effect/Crypto";
56import type * as Duration from "effect/Duration";
67import * as DateTime from "effect/DateTime";
78import * as Effect from "effect/Effect";
9import * as Hex from "effect/encoding/Hex";
810import * as Fiber from "effect/Fiber";
911import * as FileSystem from "effect/FileSystem";
1012import * as Option from "effect/Option";
......@@ -12,7 +14,6 @@ import * as Path from "effect/Path";
1214import * as Stream from "effect/Stream";
1315import * as TestClock from "effect/testing/TestClock";
1416import { HttpClient, HttpClientResponse } from "effect/http";
15import * as NodeCrypto from "node:crypto";
1617import * as NodeZlib from "node:zlib";
1718
1819import * as PreviewBrowser from "./PreviewBrowser.ts";
......@@ -87,6 +88,9 @@ const makeHarness = Effect.fn("test.makePreviewBrowser")(function* (
8788 const path = yield* Path.Path;
8889 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-preview-browser-test-" });
8990 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));
9094 const requests: Array<string> = [];
9195 const browser = yield* PreviewBrowser.makePreviewBrowser({
9296 baseDir,
......@@ -97,7 +101,7 @@ const makeHarness = Effect.fn("test.makePreviewBrowser")(function* (
97101 platform: "fixture",
98102 url: "https://storage.googleapis.com/chrome-headless-shell-fixture.zip",
99103 bytes: archive.byteLength,
100 sha256: options.sha256 ?? NodeCrypto.createHash("sha256").update(archive).digest("hex"),
104 sha256,
101105 },
102106 ...(options.wait === undefined ? {} : { wait: options.wait }),
103107 }).pipe(
apps/server/src/provider/AntigravityInstallation.test.ts+20-13
......@@ -6,8 +6,10 @@ import {
66 HostProcessIsExecutable,
77 HostProcessPlatform,
88} from "@t3tools/shared/hostProcess";
9import * as Crypto from "effect/Crypto";
910import * as Deferred from "effect/Deferred";
1011import * as Effect from "effect/Effect";
12import * as Hex from "effect/encoding/Hex";
1113import * as Exit from "effect/Exit";
1214import * as Fiber from "effect/Fiber";
1315import * as FileSystem from "effect/FileSystem";
......@@ -21,7 +23,6 @@ import * as Sink from "effect/Sink";
2123import * as Stream from "effect/Stream";
2224import { HttpClient, HttpClientResponse } from "effect/http";
2325import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
24import * as NodeCrypto from "node:crypto";
2526
2627import * as AntigravityInstallation from "./AntigravityInstallation.ts";
2728import { ANTIGRAVITY_AUTH_BROWSER_MARKER } from "./antigravityAuthSupport.ts";
......@@ -70,14 +71,15 @@ const executableName = hostPlatform === "win32" ? "agy_acp_server.exe" : "agy_ac
7071const harnessName =
7172 hostPlatform === "win32" ? "localharness_external.exe" : "localharness_external";
7273
73function releaseAsset(
74const releaseAsset = Effect.fn("test.antigravityReleaseAsset")(function* (
7475 archive: Uint8Array = completeArchive,
7576 platform: NodeJS.Platform = hostPlatform,
7677) {
78 const crypto = yield* Crypto.Crypto;
7779 return {
7880 version: "fixture-new",
7981 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)),
8183 archiveBytes: archive.byteLength,
8284 executable: {
8385 name: platform === "win32" ? "agy_acp_server.exe" : "agy_acp_server.par",
......@@ -88,7 +90,7 @@ function releaseAsset(
8890 bytes: Buffer.byteLength(harnessContents),
8991 },
9092 } satisfies AntigravityReleaseAsset;
91}
93});
9294
9395const writeRelease = Effect.fn("test.writeAntigravityRelease")(function* (
9496 managedDirectory: string,
......@@ -146,11 +148,12 @@ const makeHarness = Effect.fn("test.makeAntigravityInstallation")(function* (
146148 options.baseDir ?? (yield* fs.makeTempDirectoryScoped({ prefix: "t3-agy-test-" }));
147149 const platform = options.platform ?? hostPlatform;
148150 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;
150153 const managedDirectory = path.join(baseDir, "tools", "antigravity-acp", `${platform}-x64`);
151154 if (options.previous) {
152155 yield* writeRelease(managedDirectory, {
153 ...releaseAsset(archive, platform),
156 ...(yield* releaseAsset(archive, platform)),
154157 sha256: previousReleaseId,
155158 version: previousVersion,
156159 });
......@@ -301,9 +304,9 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => {
301304 expect(yield* fs.readFileString(selected.executablePath)).toBe(serverContents);
302305 expect(yield* fs.readFileString(selected.harnessPath)).toBe(harnessContents);
303306 expect(yield* fs.readDirectory(path.join(installation.managedDirectory, "versions"))).toEqual(
304 expect.arrayContaining([previousReleaseId, releaseAsset().sha256]),
307 expect.arrayContaining([previousReleaseId, (yield* releaseAsset()).sha256]),
305308 );
306 expect(requests).toEqual([releaseAsset().url]);
309 expect(requests).toEqual([(yield* releaseAsset()).url]);
307310 }),
308311 );
309312
......@@ -469,12 +472,12 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => {
469472 );
470473
471474 it.effect.each([
472 { name: "checksum mismatch", asset: { ...releaseAsset(), sha256: "2".repeat(64) } },
473 { name: "short download", archive: completeArchive.subarray(0, -1), asset: releaseAsset() },
475 { name: "checksum mismatch", completeAsset: { sha256: "2".repeat(64) } },
476 { name: "short download", archive: completeArchive.subarray(0, -1), completeAsset: {} },
474477 {
475478 name: "oversized download",
476479 archive: Buffer.concat([completeArchive, Buffer.from("extra")]),
477 asset: releaseAsset(),
480 completeAsset: {},
478481 },
479482 { name: "wrong Content-Length", contentLength: completeArchive.byteLength + 1 },
480483 { name: "missing harness", archive: Buffer.from(zipFixtures.missingHarness, "base64") },
......@@ -482,10 +485,14 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => {
482485 { name: "path traversal", archive: Buffer.from(zipFixtures.traversal, "base64") },
483486 { name: "symbolic link", archive: Buffer.from(zipFixtures.symlink, "base64") },
484487 { name: "oversized member", archive: Buffer.from(zipFixtures.oversizedMember, "base64") },
485 ])("rejects $name before runtime validation", (options) =>
488 ])("rejects $name before runtime validation", ({ completeAsset, ...options }) =>
486489 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 };
487493 const { installation, validations, stagingReleased, fs, path } = yield* makeHarness({
488494 ...options,
495 ...(asset === undefined ? {} : { asset }),
489496 previous: true,
490497 });
491498 yield* installation.start;
......@@ -893,7 +900,7 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => {
893900 const fs = yield* FileSystem.FileSystem;
894901 const path = yield* Path.Path;
895902 const archive = Buffer.from(zipFixtures.windows, "base64");
896 const asset = releaseAsset(archive, "win32");
903 const asset = yield* releaseAsset(archive, "win32");
897904 let denyPointerRename = true;
898905 const renameTargets: string[] = [];
899906 const { installation, requests, validations } = yield* makeHarness({
apps/server/src/provider/CodexInstallation.test.ts+5-3
......@@ -6,15 +6,16 @@ import {
66 HostProcessEnvironment,
77 HostProcessPlatform,
88} from "@t3tools/shared/hostProcess";
9import * as Crypto from "effect/Crypto";
910import * as Deferred from "effect/Deferred";
1011import * as Effect from "effect/Effect";
12import * as Hex from "effect/encoding/Hex";
1113import * as FileSystem from "effect/FileSystem";
1214import * as Option from "effect/Option";
1315import * as Exit from "effect/Exit";
1416import * as Scope from "effect/Scope";
1517import * as Stream from "effect/Stream";
1618import { HttpClient, HttpClientResponse } from "effect/http";
17import * as NodeCrypto from "node:crypto";
1819import * as CodexInstallation from "./CodexInstallation.ts";
1920
2021const archive = Buffer.from(
......@@ -25,7 +26,6 @@ const asset = {
2526 version: "0.156.1",
2627 target: "aarch64-apple-darwin",
2728 url: "https://github.com/openai/codex/releases/download/test/package.tar.gz",
28 sha256: NodeCrypto.createHash("sha256").update(archive).digest("hex"),
2929 archiveBytes: archive.length,
3030};
3131const makeHarness = Effect.fn("test.makeCodexInstallation")(function* (
......@@ -52,9 +52,11 @@ const makeHarness = Effect.fn("test.makeCodexInstallation")(function* (
5252 );
5353 }
5454 let downloads = 0;
55 const crypto = yield* Crypto.Crypto;
56 const sha256 = Hex.encode(yield* crypto.digest("SHA-256", archive).pipe(Effect.orDie));
5557 const installation = yield* CodexInstallation.makeCodexInstallation({
5658 baseDir,
57 releaseAsset: asset,
59 releaseAsset: { ...asset, sha256 },
5860 validate: () => Effect.void,
5961 ...input.options,
6062 }).pipe(
apps/server/src/provider/CodexToolPresentation.ts+114-105
......@@ -1,9 +1,11 @@
1import * as NodeCrypto from "node:crypto";
21import type {
32 ToolActivityIcon,
43 ToolActivityNativeAppReference,
54 ToolActivitySource,
65} from "@t3tools/contracts";
6import * as Crypto from "effect/Crypto";
7import * as Effect from "effect/Effect";
8import * as Hex from "effect/encoding/Hex";
79import type * as EffectCodexSchema from "effect-codex-app-server/schema";
810
911import {
......@@ -42,12 +44,17 @@ function normalizedSourceKeyPart(value: string): string {
4244 return value.trim().toLowerCase();
4345}
4446
45function nativeAppSourceKey(appId: string): string {
47const nativeAppSourceKey = Effect.fn("CodexToolPresentation.nativeAppSourceKey")(function* (
48 appId: string,
49) {
4650 const key = `native-app:${appId.toLowerCase()}`;
4751 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 );
4956 return `${key.slice(0, 512 - digest.length - 1)}:${digest}`;
50}
57});
5158
5259function browserDisplayName(value: unknown): string | undefined {
5360 const normalized = normalizedDisplayName(value)?.toLowerCase();
......@@ -123,105 +130,107 @@ export interface McpToolPresentation {
123130 readonly toolSource?: ToolActivitySource;
124131}
125132
126export function mcpToolPresentation(
127 item: Extract<CodexLifecycleItem, { readonly type: "mcpToolCall" }>,
128): McpToolPresentation {
129 const result = asUnknownRecord(item.result);
130 const metadata = asUnknownRecord(result?._meta);
131 const surface = asUnknownRecord(metadata?.["codex/toolSurface"]);
132 const sourceMetadata = asUnknownRecord(metadata?.source);
133 const appContext = asUnknownRecord(item.appContext);
134 const sourceLogo = themedLogoIcon(surface, sourceMetadata, appContext);
135 if (surface?.kind === "browserUse") {
136 const screenshot = asUnknownRecord(surface.screenshot);
137 const browserUse = asUnknownRecord(metadata?.browser_use);
138 const openTabs = Array.isArray(surface.openTabs) ? surface.openTabs : [];
139 const latestOpenTab = openTabs
140 .toReversed()
141 .map(asUnknownRecord)
142 .find((tab) => normalizedHttpUrl(tab?.url) !== undefined);
143 const selectedPage = [
144 { record: screenshot, url: screenshot?.pageUrl },
145 { record: browserUse, url: browserUse?.url },
146 { record: latestOpenTab, url: latestOpenTab?.url },
147 ]
148 .map((candidate) => ({ ...candidate, pageUrl: normalizedHttpUrl(candidate.url) }))
149 .find((candidate) => candidate.pageUrl !== undefined);
150 const pageUrl = selectedPage?.pageUrl;
151 const faviconUrl = normalizedImageUrl(
152 selectedPage?.record?.faviconUrl ?? selectedPage?.record?.favIconUrl,
153 );
154 const faviconUrlDark = normalizedImageUrl(
155 selectedPage?.record?.faviconUrlDark ?? selectedPage?.record?.favIconUrlDark,
156 );
157 const name =
158 browserDisplayName(appContext?.appName) ??
159 browserDisplayName(surface.browserFamily) ??
160 browserDisplayName(surface.backend) ??
161 "Browser";
162 const nativeBrowserIcon = browserNativeAppReference(name);
163 const sourceIcon =
164 sourceLogo ??
165 (nativeBrowserIcon ? ({ _tag: "native-app", app: nativeBrowserIcon } as const) : undefined);
166 const sourceKeyPart = normalizedSourceKeyPart(name) || "browser";
167 return {
168 toolSurface: "browser",
169 ...(pageUrl
170 ? {
171 toolIcon: {
172 _tag: "website",
173 pageUrl,
174 ...(faviconUrl ? { faviconUrl } : {}),
175 ...(faviconUrlDark ? { faviconUrlDark } : {}),
176 } as const,
177 }
178 : {}),
179 toolSource: {
180 key: `browser-use:${sourceKeyPart}`,
181 name,
182 kind: name === "Browser" ? "browser" : "integration",
183 ...(sourceIcon ? { icon: sourceIcon } : {}),
184 },
185 };
186 }
187 if (surface?.kind === "computerUse") {
188 const app = nativeAppReference(surface.app);
189 const args = asUnknownRecord(item.arguments);
190 const argumentAppName =
191 normalizedDisplayName(args?.appName) ??
192 normalizedDisplayName(args?.application) ??
193 normalizedDisplayName(typeof args?.app === "string" ? args.app : undefined);
194 const name =
195 normalizedDisplayName(appContext?.appName) ??
196 argumentAppName ??
197 (app?._tag === "display-name" ? app.displayName : undefined) ??
198 (app?._tag === "app-id" ? appDisplayNameFromId(app.appId) : undefined) ??
199 "Computer Use";
200 const sourceIcon = sourceLogo ?? (app ? ({ _tag: "native-app", app } as const) : undefined);
201 const sourceKey = app
202 ? app._tag === "app-id"
203 ? nativeAppSourceKey(app.appId)
204 : `native-app-name:${normalizedSourceKeyPart(app.displayName)}`
205 : "computer-use";
206 return {
207 toolSurface: "computer",
208 ...(app ? { toolIcon: { _tag: "native-app", app } as const } : {}),
209 toolSource: {
210 key: sourceKey,
211 name,
212 kind: "computer",
213 ...(sourceIcon ? { icon: sourceIcon } : {}),
214 },
215 };
216 }
133export const mcpToolPresentation = Effect.fn("CodexToolPresentation.mcpToolPresentation")(
134 function* (
135 item: Extract<CodexLifecycleItem, { readonly type: "mcpToolCall" }>,
136 ): Effect.fn.Return<McpToolPresentation, never, Crypto.Crypto> {
137 const result = asUnknownRecord(item.result);
138 const metadata = asUnknownRecord(result?._meta);
139 const surface = asUnknownRecord(metadata?.["codex/toolSurface"]);
140 const sourceMetadata = asUnknownRecord(metadata?.source);
141 const appContext = asUnknownRecord(item.appContext);
142 const sourceLogo = themedLogoIcon(surface, sourceMetadata, appContext);
143 if (surface?.kind === "browserUse") {
144 const screenshot = asUnknownRecord(surface.screenshot);
145 const browserUse = asUnknownRecord(metadata?.browser_use);
146 const openTabs = Array.isArray(surface.openTabs) ? surface.openTabs : [];
147 const latestOpenTab = openTabs
148 .toReversed()
149 .map(asUnknownRecord)
150 .find((tab) => normalizedHttpUrl(tab?.url) !== undefined);
151 const selectedPage = [
152 { record: screenshot, url: screenshot?.pageUrl },
153 { record: browserUse, url: browserUse?.url },
154 { record: latestOpenTab, url: latestOpenTab?.url },
155 ]
156 .map((candidate) => ({ ...candidate, pageUrl: normalizedHttpUrl(candidate.url) }))
157 .find((candidate) => candidate.pageUrl !== undefined);
158 const pageUrl = selectedPage?.pageUrl;
159 const faviconUrl = normalizedImageUrl(
160 selectedPage?.record?.faviconUrl ?? selectedPage?.record?.favIconUrl,
161 );
162 const faviconUrlDark = normalizedImageUrl(
163 selectedPage?.record?.faviconUrlDark ?? selectedPage?.record?.favIconUrlDark,
164 );
165 const name =
166 browserDisplayName(appContext?.appName) ??
167 browserDisplayName(surface.browserFamily) ??
168 browserDisplayName(surface.backend) ??
169 "Browser";
170 const nativeBrowserIcon = browserNativeAppReference(name);
171 const sourceIcon =
172 sourceLogo ??
173 (nativeBrowserIcon ? ({ _tag: "native-app", app: nativeBrowserIcon } as const) : undefined);
174 const sourceKeyPart = normalizedSourceKeyPart(name) || "browser";
175 return {
176 toolSurface: "browser",
177 ...(pageUrl
178 ? {
179 toolIcon: {
180 _tag: "website",
181 pageUrl,
182 ...(faviconUrl ? { faviconUrl } : {}),
183 ...(faviconUrlDark ? { faviconUrlDark } : {}),
184 } as const,
185 }
186 : {}),
187 toolSource: {
188 key: `browser-use:${sourceKeyPart}`,
189 name,
190 kind: name === "Browser" ? "browser" : "integration",
191 ...(sourceIcon ? { icon: sourceIcon } : {}),
192 },
193 };
194 }
195 if (surface?.kind === "computerUse") {
196 const app = nativeAppReference(surface.app);
197 const args = asUnknownRecord(item.arguments);
198 const argumentAppName =
199 normalizedDisplayName(args?.appName) ??
200 normalizedDisplayName(args?.application) ??
201 normalizedDisplayName(typeof args?.app === "string" ? args.app : undefined);
202 const name =
203 normalizedDisplayName(appContext?.appName) ??
204 argumentAppName ??
205 (app?._tag === "display-name" ? app.displayName : undefined) ??
206 (app?._tag === "app-id" ? appDisplayNameFromId(app.appId) : undefined) ??
207 "Computer Use";
208 const sourceIcon = sourceLogo ?? (app ? ({ _tag: "native-app", app } as const) : undefined);
209 const sourceKey = app
210 ? app._tag === "app-id"
211 ? yield* nativeAppSourceKey(app.appId)
212 : `native-app-name:${normalizedSourceKeyPart(app.displayName)}`
213 : "computer-use";
214 return {
215 toolSurface: "computer",
216 ...(app ? { toolIcon: { _tag: "native-app", app } as const } : {}),
217 toolSource: {
218 key: sourceKey,
219 name,
220 kind: "computer",
221 ...(sourceIcon ? { icon: sourceIcon } : {}),
222 },
223 };
224 }
217225
218 return integrationToolPresentation({
219 serverName: appContext?.connectorId ?? item.server,
220 toolName: item.tool,
221 title: appContext?.actionName,
222 serverDisplayName: appContext?.appName,
223 source: sourceMetadata,
224 iconUrl: sourceLogo?._tag === "themed-logo" ? sourceLogo.logoUrl : undefined,
225 iconUrlDark: sourceLogo?._tag === "themed-logo" ? sourceLogo.logoUrlDark : undefined,
226 });
227}
226 return integrationToolPresentation({
227 serverName: appContext?.connectorId ?? item.server,
228 toolName: item.tool,
229 title: appContext?.actionName,
230 serverDisplayName: appContext?.appName,
231 source: sourceMetadata,
232 iconUrl: sourceLogo?._tag === "themed-logo" ? sourceLogo.logoUrl : undefined,
233 iconUrlDark: sourceLogo?._tag === "themed-logo" ? sourceLogo.logoUrlDark : undefined,
234 });
235 },
236);
apps/server/src/provider/OpenCodeProvider.test.ts+2-2
......@@ -1,5 +1,4 @@
11import * as NodeAssert from "node:assert/strict";
2import * as NodeCrypto from "node:crypto";
32
43import * as NodeServices from "@effect/platform-node/NodeServices";
54import { it } from "@effect/vitest";
......@@ -81,7 +80,8 @@ it.effect("reads Go limits with the instance's XDG credentials and preserves res
8180 NodeAssert.equal(limits.unavailable, undefined);
8281 NodeAssert.equal(
8382 limits.credentialFingerprint,
84 NodeCrypto.createHash("sha256").update("opencode-go\0instance-key").digest("hex"),
83 // SHA-256 of "opencode-go\0instance-key".
84 "aba48e85c981a8edc1c9fb4575121accc7235fa55d0f8689f66f153de5566a37",
8585 );
8686 NodeAssert.deepEqual(
8787 limits.windows.map(({ kind, usedPercent, resetsAt: reset }) => ({
apps/server/src/provider/acp/AcpRegistryAuthenticationState.ts+11-5
......@@ -1,10 +1,11 @@
1import * as NodeCrypto from "node:crypto";
21import type {
32 AcpRegistrySettings,
43 ProviderInstanceEnvironment,
54 ProviderInstanceId,
65} from "@t3tools/contracts";
6import * as Crypto from "effect/Crypto";
77import * as Effect from "effect/Effect";
8import * as Hex from "effect/encoding/Hex";
89import * as FileSystem from "effect/FileSystem";
910import * as Path from "effect/Path";
1011import * as Ref from "effect/Ref";
......@@ -16,8 +17,13 @@ import { writeFileStringAtomically } from "../../atomicWrite.ts";
1617const decodeState = Schema.decodeUnknownEffect(
1718 Schema.fromJsonString(Schema.Struct({ binding: Schema.String, authenticated: Schema.Boolean })),
1819);
19const hash = (value: unknown) =>
20 NodeCrypto.createHash("sha256").update(JSON.stringify(value)).digest("hex");
20const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
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
2228/** Remember explicit sign-in success, never discovery success or the agent's credentials. */
2329export const makeAcpRegistryAuthenticationState = Effect.fn("makeAcpRegistryAuthenticationState")(
......@@ -30,10 +36,10 @@ export const makeAcpRegistryAuthenticationState = Effect.fn("makeAcpRegistryAuth
3036 }) {
3137 const fs = yield* FileSystem.FileSystem;
3238 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`);
3440 // Cosmetic settings and model discovery can rebuild the driver without
3541 // changing the account. Credential overrides and profile paths cannot.
36 const binding = hash({
42 const binding = yield* hash({
3743 ...(input.settings.source === "local"
3844 ? { source: "local", commandArgs: input.settings.commandArgs }
3945 : {}),
apps/server/src/provider/acp/AcpRegistrySupport.test.ts+4-3
......@@ -16,7 +16,6 @@ import * as Layer from "effect/Layer";
1616import * as Schema from "effect/Schema";
1717import { HttpClient, HttpClientResponse } from "effect/http";
1818import * as TestClock from "effect/testing/TestClock";
19import * as NodeCrypto from "node:crypto";
2019import { ChildProcess, ChildProcessSpawner } from "effect/process";
2120
2221import * as AcpRegistrySupport from "./AcpRegistrySupport.ts";
......@@ -587,7 +586,8 @@ describe("AcpRegistrySupport", () => {
587586 archive: archiveUrl,
588587 cmd: "./bin/example-agent",
589588 args: ["acp"],
590 sha256: NodeCrypto.createHash("sha256").update(binaryBytes).digest("hex"),
589 // SHA-256 of binaryBytes.
590 sha256: "97b29a636d7ddf7bf3567ae4d48c0f2a9b03943fd808b6efbf74f1dd3db131b7",
591591 },
592592 },
593593 });
......@@ -646,7 +646,8 @@ describe("AcpRegistrySupport", () => {
646646 archive: archiveUrl,
647647 cmd: "./bin/example-agent",
648648 args: ["acp"],
649 sha256: NodeCrypto.createHash("sha256").update(binaryBytes).digest("hex"),
649 // SHA-256 of binaryBytes.
650 sha256: "e1eb7c905b91c400af2ec858628aec22866bfda1383b93ceb73ae01d9769fb31",
650651 },
651652 },
652653 });
apps/server/src/provider/acp/AcpRegistrySupport.ts+33-27
......@@ -25,7 +25,9 @@ import {
2525import * as Clock from "effect/Clock";
2626import * as Context from "effect/Context";
2727import * as Duration from "effect/Duration";
28import * as Crypto from "effect/Crypto";
2829import * as Effect from "effect/Effect";
30import * as Hex from "effect/encoding/Hex";
2931import * as FileSystem from "effect/FileSystem";
3032import * as Layer from "effect/Layer";
3133import * as Option from "effect/Option";
......@@ -35,9 +37,9 @@ import * as Ref from "effect/Ref";
3537import * as Schema from "effect/Schema";
3638import * as Semaphore from "effect/Semaphore";
3739import * as Stream from "effect/Stream";
40import { sha256 } from "@noble/hashes/sha2";
3841import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
3942import { ChildProcess, ChildProcessSpawner } from "effect/process";
40import * as NodeCrypto from "node:crypto";
4143
4244import { collectUint8StreamText } from "../../stream/collectUint8StreamText.ts";
4345import * as ServerSettings from "../../serverSettings.ts";
......@@ -597,11 +599,15 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct
597599 AcpRegistryCatalog["Service"],
598600 never,
599601 | ChildProcessSpawner.ChildProcessSpawner
602 | Crypto.Crypto
600603 | FileSystem.FileSystem
601604 | HttpClient.HttpClient
602605 | Path.Path
603606 | ServerSettings.ServerSettingsService
604607> {
608 const crypto = yield* Crypto.Crypto;
609 const sha256Hex = (data: Uint8Array) =>
610 crypto.digest("SHA-256", data).pipe(Effect.map(Hex.encode), Effect.orDie);
605611 const fileSystem = yield* FileSystem.FileSystem;
606612 const path = yield* Path.Path;
607613 const httpClient = yield* HttpClient.HttpClient;
......@@ -731,17 +737,20 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct
731737 .readFileString(registryCachePath)
732738 .pipe(Effect.flatMap(decodeRegistryText), Effect.option);
733739
734 const writeRegistryCache = (text: string) => {
735 const temporaryPath = `${registryCachePath}.${process.pid}-${NodeCrypto.randomUUID()}.tmp`;
736 return fileSystem
737 .makeDirectory(registryDirectory, { recursive: true })
738 .pipe(
739 Effect.andThen(fileSystem.writeFileString(temporaryPath, text)),
740 Effect.andThen(fileSystem.rename(temporaryPath, registryCachePath)),
741 Effect.ensuring(fileSystem.remove(temporaryPath, { force: true }).pipe(Effect.ignore)),
742 Effect.ignore,
743 );
744 };
740 const writeRegistryCache = (text: string) =>
741 crypto.randomUUIDv4.pipe(
742 Effect.flatMap((id) => {
743 const temporaryPath = `${registryCachePath}.${process.pid}-${id}.tmp`;
744 return fileSystem
745 .makeDirectory(registryDirectory, { recursive: true })
746 .pipe(
747 Effect.andThen(fileSystem.writeFileString(temporaryPath, text)),
748 Effect.andThen(fileSystem.rename(temporaryPath, registryCachePath)),
749 Effect.ensuring(fileSystem.remove(temporaryPath, { force: true }).pipe(Effect.ignore)),
750 );
751 }),
752 Effect.ignore,
753 );
745754
746755 const fetchRegistry = Effect.fn("AcpRegistryCatalog.fetchRegistry")(function* () {
747756 yield* assertHttpsUrl(registryUrl, "ACP Registry index URL must use HTTPS.");
......@@ -928,17 +937,12 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct
928937 );
929938 });
930939
931 const packageReceiptPath = (
932 agentId: string,
933 distribution: "npx" | "uvx",
934 managerPath: string,
935 ) => {
936 const managerId = NodeCrypto.createHash("sha256")
937 .update(`${distribution}\0${managerPath}`)
938 .digest("hex")
939 .slice(0, 16);
940 return path.join(packageReceiptsDirectory, `${agentId}-${managerId}.json`);
941 };
940 const packageReceiptPath = (agentId: string, distribution: "npx" | "uvx", managerPath: string) =>
941 sha256Hex(new TextEncoder().encode(`${distribution}\0${managerPath}`)).pipe(
942 Effect.map((digest) =>
943 path.join(packageReceiptsDirectory, `${agentId}-${digest.slice(0, 16)}.json`),
944 ),
945 );
942946
943947 const readPackageReceipt = Effect.fn("AcpRegistryCatalog.readPackageReceipt")(function* (
944948 agent: AcpRegistryAgent,
......@@ -947,7 +951,7 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct
947951 managerPath: string,
948952 ) {
949953 const receipt = yield* fileSystem
950 .readFileString(packageReceiptPath(agent.id, distribution, managerPath))
954 .readFileString(yield* packageReceiptPath(agent.id, distribution, managerPath))
951955 .pipe(
952956 Effect.map(decodePackageInstallReceipt),
953957 Effect.orElseSucceed(() => Option.none()),
......@@ -991,7 +995,7 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct
991995
992996 const writePackageReceipt = Effect.fn("AcpRegistryCatalog.writePackageReceipt")(
993997 function* (receipt: AcpRegistryPackageInstallReceipt) {
994 const receiptPath = packageReceiptPath(
998 const receiptPath = yield* packageReceiptPath(
995999 receipt.agentId,
9961000 receipt.distribution,
9971001 receipt.managerPath,
......@@ -1409,7 +1413,9 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct
14091413 ),
14101414 }),
14111415 );
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();
14131419 let downloadedBytes = 0;
14141420 yield* response.stream.pipe(
14151421 Stream.tap((chunk) => {
......@@ -1447,7 +1453,7 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct
14471453 }),
14481454 );
14491455 if (target.sha256 !== undefined) {
1450 const actual = hash.digest("hex");
1456 const actual = Hex.encode(hash.digest());
14511457 if (actual !== target.sha256.toLowerCase()) {
14521458 return yield* new AcpRegistryError({
14531459 reason: "checksum_mismatch",
apps/server/src/provider/opencode2/OpenCode2Client.live.test.ts+3-2
......@@ -10,6 +10,7 @@
1010import * as NodeServices from "@effect/platform-node/NodeServices";
1111import { AbsolutePath, Location, Model, Provider } from "@opencode/client/effect";
1212import { assert, it } from "@effect/vitest";
13import * as Crypto from "effect/Crypto";
1314import * as Effect from "effect/Effect";
1415import * as Base64Url from "effect/encoding/Base64Url";
1516import * as Fiber from "effect/Fiber";
......@@ -21,7 +22,6 @@ import * as Path from "effect/Path";
2122import * as Stream from "effect/Stream";
2223import { FetchHttpClient } from "effect/http";
2324import { ChildProcess, ChildProcessSpawner } from "effect/process";
24import * as NodeCrypto from "node:crypto";
2525import { describe } from "vite-plus/test";
2626
2727import * as OpenCode2Client from "./OpenCode2Client.ts";
......@@ -36,7 +36,8 @@ const startServer = Effect.fn("OpenCode2ClientLive.startServer")(function* (bina
3636 const directory = path.join(root, "work");
3737 yield* fs.makeDirectory(directory);
3838 // 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€`;
4041 const child = yield* spawner.spawn(
4142 ChildProcess.make(binary, ["serve", "--hostname=127.0.0.1", "--port=0"], {
4243 cwd: directory,
apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts+1
......@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createHmac.
12import * as NodeCrypto from "node:crypto";
23
34import * as NodePlatformCrypto from "@effect/platform-node/NodeCrypto";
apps/server/src/scheduledTasks/webhookRoute.test.ts+1
......@@ -1,4 +1,5 @@
11import { describe, expect, it } from "@effect/vitest";
2// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync.
23import * as NodeCrypto from "node:crypto";
34import * as Clock from "effect/Clock";
45import * as Effect from "effect/Effect";
apps/server/src/scheduledTasks/webhookVerification.test.ts+1
......@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createHmac.
12import * as NodeCrypto from "node:crypto";
23
34import { assert, describe, it } from "@effect/vitest";
apps/server/src/scheduledTasks/webhookVerification.ts+1
......@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createHmac or timingSafeEqual.
12import * as NodeCrypto from "node:crypto";
23
34import type { ScheduledTaskWebhookSignature } from "@t3tools/contracts";
apps/server/src/secrets/SecretRequests.ts+1
......@@ -16,6 +16,7 @@ import {
1616 type SecretRequestAnswerInput,
1717 type ThreadId,
1818} from "@t3tools/contracts";
19// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createHmac.
1920import * as NodeCrypto from "node:crypto";
2021
2122import * as Clock from "effect/Clock";
apps/server/src/telemetry/Identify.test.ts+11-6
......@@ -1,7 +1,8 @@
1import * as NodeCrypto from "node:crypto";
21import * as NodeServices from "@effect/platform-node/NodeServices";
32import { assert, it } from "@effect/vitest";
3import * as Crypto from "effect/Crypto";
44import * as Effect from "effect/Effect";
5import * as Hex from "effect/encoding/Hex";
56import * as FileSystem from "effect/FileSystem";
67import * as Layer from "effect/Layer";
78import * as Logger from "effect/Logger";
......@@ -17,8 +18,12 @@ interface CapturedLog {
1718 readonly annotations: Readonly<Record<string, unknown>>;
1819}
1920
20const sha256 = (value: string) =>
21 NodeCrypto.createHash("sha256").update(value, "utf8").digest("hex");
21const sha256 = Effect.fn("test.sha256")(function* (value: string) {
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
2328const makeCaptureLogger = (logs: CapturedLog[]) =>
2429 Logger.make(({ fiber, message }) => {
......@@ -48,7 +53,7 @@ it.layer(NodeServices.layer)("telemetry identity", (it) => {
4853 path.join(config.baseDir, "home"),
4954 );
5055
51 assert.equal(identifier, sha256(anonymousId));
56 assert.equal(identifier, yield* sha256(anonymousId));
5257 }).pipe(
5358 Effect.provide(
5459 ServerConfig.layerTest(process.cwd(), {
......@@ -132,7 +137,7 @@ it.layer(NodeServices.layer)("telemetry identity", (it) => {
132137
133138 const identifier = yield* Identify.getTelemetryIdentifierForHome(homeDirectory);
134139
135 assert.equal(identifier, sha256(anonymousId));
140 assert.equal(identifier, yield* sha256(anonymousId));
136141 assert.isUndefined(findIdentityLog(logs, "codex", "TelemetryIdentityDecodeError"));
137142 assert.isUndefined(findIdentityLog(logs, "codex", "TelemetryIdentityReadError"));
138143 const allLogs = logs
......@@ -175,7 +180,7 @@ it.layer(NodeServices.layer)("telemetry identity", (it) => {
175180
176181 const identifier = yield* Identify.getTelemetryIdentifierForHome(homeDirectory);
177182
178 assert.equal(identifier, sha256(anonymousId));
183 assert.equal(identifier, yield* sha256(anonymousId));
179184 const decodeLog = findIdentityLog(logs, "codex", "TelemetryIdentityDecodeError");
180185 assert.isDefined(decodeLog);
181186 assert.equal(
apps/server/src/usage/cliproxyApi.test.ts+8-3
......@@ -1,10 +1,11 @@
1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
12import { describe, expect, it } from "@effect/vitest";
23import * as Effect from "effect/Effect";
34import * as Schema from "effect/Schema";
45import * as TestClock from "effect/testing/TestClock";
56import { HttpClient, HttpClientResponse } from "effect/http";
67
7import { creditRedeemRequestId, makeCliproxyApi } from "./cliproxyApi.ts";
8import { makeCliproxyApi } from "./cliproxyApi.ts";
89
910const config = {
1011 kind: "cliproxy",
......@@ -106,7 +107,10 @@ function fixture(
106107 );
107108 return {
108109 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 ),
110114 };
111115}
112116
......@@ -222,7 +226,8 @@ describe("CLIProxyAPI built-in management API", () => {
222226 expect(redemptions[0]?.body?.data).toBe(redemptions[1]?.body?.data);
223227 expect(redemptions[0]?.body?.data).toBe(
224228 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",
226231 credit_id: "credit-b",
227232 }),
228233 );
apps/server/src/usage/cliproxyApi.ts+22-12
......@@ -1,5 +1,3 @@
1import * as NodeCrypto from "node:crypto";
2
31import {
42 ProviderDriverKind,
53 UsageLimitSourceError,
......@@ -7,8 +5,10 @@ import {
75 type UsageLimitSourceAccount,
86 type UsageLimitSourceConfig,
97} from "@t3tools/contracts";
8import * as Crypto from "effect/Crypto";
109import * as DateTime from "effect/DateTime";
1110import * as Effect from "effect/Effect";
11import * as Hex from "effect/encoding/Hex";
1212import * as Schema from "effect/Schema";
1313import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
1414
......@@ -99,21 +99,31 @@ const decodeConsumeResponse = Schema.decodeUnknownEffect(
9999const CODEX_BASE = "https://chatgpt.com/backend-api/wham";
100100const 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
102107// UUIDv5 per account and credit also deduplicates retries across T3 environments.
103export function creditRedeemRequestId(accountId: string, creditId: string): string {
104 const bytes = NodeCrypto.createHash("sha1")
105 .update(Buffer.from("6f1c2a9e2d4b4c1e9a7f3b8d5e0c1a42", "hex"))
106 .update(`${accountId}:${creditId}`)
107 .digest()
108 .subarray(0, 16);
108const creditRedeemRequestId = Effect.fn("CliproxyApi.creditRedeemRequestId")(function* (
109 accountId: string,
110 creditId: string,
111) {
112 const crypto = yield* Crypto.Crypto;
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);
109118 bytes[6] = (bytes[6]! & 0x0f) | 0x50;
110119 bytes[8] = (bytes[8]! & 0x3f) | 0x80;
111 const hex = bytes.toString("hex");
120 const hex = Hex.encode(bytes);
112121 return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
113}
122});
114123
115124export const makeCliproxyApi = Effect.gen(function* () {
116125 const client = yield* HttpClient.HttpClient;
126 const crypto = yield* Crypto.Crypto;
117127
118128 const management = Effect.fn("CliproxyApi.management")(function* (
119129 config: UsageLimitSourceConfig,
......@@ -323,10 +333,10 @@ export const makeCliproxyApi = Effect.gen(function* () {
323333 });
324334 }
325335 const body = yield* apiCall(config, account, `${CREDIT_URL}/consume`, {
326 redeem_request_id: creditRedeemRequestId(
336 redeem_request_id: yield* creditRedeemRequestId(
327337 account.id_token?.chatgpt_account_id ?? account.id,
328338 creditId,
329 ),
339 ).pipe(Effect.provideService(Crypto.Crypto, crypto)),
330340 credit_id: creditId,
331341 });
332342 const response = yield* decodeConsumeResponse(body);
apps/server/src/zipArchive.ts+1
......@@ -1,4 +1,5 @@
11import * as Effect from "effect/Effect";
2// @effect-diagnostics-next-line nodeBuiltinImport:off -- type-only, to adapt a Node Readable into Effect.
23import type * as NodeStream from "node:stream";
34import * as Yauzl from "yauzl";
45
infra/relay/src/agentActivity/ApnsClient.test.ts+1
......@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync.
12import * as NodeCrypto from "node:crypto";
23
34import { EnvironmentId, ThreadId } from "@t3tools/contracts";
infra/relay/src/agentActivity/ApnsDeliveries.test.ts+1
......@@ -4,6 +4,7 @@ import type {
44} from "@t3tools/contracts/relay";
55import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto";
66import { describe, expect, it } from "@effect/vitest";
7// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync.
78import * as NodeCrypto from "node:crypto";
89import * as Deferred from "effect/Deferred";
910import * as Effect from "effect/Effect";
infra/relay/src/agentActivity/ApnsProviderTokens.test.ts+1
......@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync.
12import * as NodeCrypto from "node:crypto";
23
34import { describe, expect, it } from "@effect/vitest";
infra/relay/src/agentActivity/FcmClient.test.ts+1
......@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync or verify.
12import * as NodeCrypto from "node:crypto";
23import { describe, expect, it } from "@effect/vitest";
34import * as Deferred from "effect/Deferred";
infra/relay/src/agentActivity/apnsDeliveryJobs.ts+1
......@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createHmac or timingSafeEqual.
12import * as NodeCrypto from "node:crypto";
23
34import {
infra/relay/src/agentActivity/apnsJwt.ts+1
......@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createPrivateKey.
12import * as NodeCrypto from "node:crypto";
23
34import { p256 } from "@noble/curves/nist";
infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts+1
......@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync or sign.
12import * as NodeCrypto from "node:crypto";
23
34import { describe, expect, it } from "@effect/vitest";
infra/relay/src/auth/RelayTokens.test.ts+1
......@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync.
12import * as NodeCrypto from "node:crypto";
23
34import { describe, expect, it } from "@effect/vitest";
infra/relay/src/environments/EnvironmentConnector.test.ts+1
......@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync or sign.
12import * as NodeCrypto from "node:crypto";
23import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto";
34
infra/relay/src/environments/EnvironmentLinker.test.ts+1
......@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync or sign.
12import * as NodeCrypto from "node:crypto";
23import type {
34 RelayEnvironmentLinkProofPayload,
infra/relay/src/environments/EnvironmentPublishSignatures.test.ts+1
......@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync or sign.
12import * as NodeCrypto from "node:crypto";
23import * as NodeServices from "@effect/platform-node/NodeServices";
34import type {
infra/relay/src/environments/ManagedEndpointProvider.test.ts+16-11
......@@ -1,4 +1,3 @@
1import * as NodeCrypto from "node:crypto";
21import * as NodeServices from "@effect/platform-node/NodeServices";
32
43import { describe, expect, it } from "@effect/vitest";
......@@ -365,20 +364,26 @@ function layerProvider(
365364 );
366365}
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
368381function expectedManagedHostname(environmentId: string, userId = "user_ABC"): string {
369 const hash = NodeCrypto.createHash("sha256")
370 .update(`dev_julius:${userId}:${environmentId}`)
371 .digest("hex")
372 .slice(0, 16);
373 return `dev-julius-${hash}.t3code.test`;
382 return `dev-julius-${expectedManagedEndpointHash(environmentId, userId)}.t3code.test`;
374383}
375384
376385function expectedManagedTunnelName(environmentId: string, userId = "user_ABC"): string {
377 const hash = NodeCrypto.createHash("sha256")
378 .update(`dev_julius:${userId}:${environmentId}`)
379 .digest("hex")
380 .slice(0, 16);
381 return `t3coderelay-managedendpoint-dev-julius-${hash}`;
386 return `t3coderelay-managedendpoint-dev-julius-${expectedManagedEndpointHash(environmentId, userId)}`;
382387}
383388
384389describe("ManagedEndpointProvider", () => {
infra/relay/src/hooks/HookForwarder.test.ts+1
......@@ -1,5 +1,6 @@
11import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
22import * as NodeServices from "@effect/platform-node/NodeServices";
3// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync.
34import * as NodeCrypto from "node:crypto";
45import * as EffectNodeCrypto from "@effect/platform-node/NodeCrypto";
56import { describe, expect, it } from "@effect/vitest";
infra/relay/src/http/Api.test.ts+1
......@@ -7,6 +7,7 @@ import {
77import * as EnvironmentLinker from "../environments/EnvironmentLinker.ts";
88import * as RelayTokens from "../auth/RelayTokens.ts";
99import * as Devices from "../agentActivity/Devices.ts";
10// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync.
1011import * as NodeCrypto from "node:crypto";
1112import { createClerkClient, verifyToken } from "@clerk/backend";
1213import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
packages/shared/src/desktopBootstrapToken.ts+1
......@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createHmac or timingSafeEqual.
12import * as NodeCrypto from "node:crypto";
23
34/**
packages/shared/src/dpop.test.ts+1
......@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no KeyObject, generateKeyPairSync, or sign.
12import * as NodeCrypto from "node:crypto";
23
34import { assert, describe, it } from "@effect/vitest";
packages/shared/src/relayJwt.test.ts+1
......@@ -1,3 +1,4 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync.
12import * as NodeCrypto from "node:crypto";
23
34import { describe, expect, it } from "@effect/vitest";
packages/ssh/src/command.test.ts+25
......@@ -14,6 +14,7 @@ import {
1414 baseSshArgs,
1515 getLastNonEmptyOutputLine,
1616 parseSshResolveOutput,
17 remoteStateKey,
1718 runSshCommand,
1819} from "./command.ts";
1920import { SshCommandError } from "./errors.ts";
......@@ -98,6 +99,30 @@ describe("ssh command", () => {
9899 }),
99100 );
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
101126 it.effect("reads the last non-empty ssh output line", () =>
102127 Effect.sync(() => {
103128 assert.equal(
packages/ssh/src/command.ts+12-8
......@@ -1,9 +1,9 @@
1import * as NodeCrypto from "node:crypto";
2
31import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts";
42import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3import * as Crypto from "effect/Crypto";
54import * as Duration from "effect/Duration";
65import * as Effect from "effect/Effect";
6import * as Hex from "effect/encoding/Hex";
77import * as FileSystem from "effect/FileSystem";
88import * as Option from "effect/Option";
99import * as Path from "effect/Path";
......@@ -72,12 +72,16 @@ export function targetConnectionKey(target: DesktopSshEnvironmentTarget): string
7272 return `${target.alias}\u0000${target.hostname}\u0000${target.username ?? ""}\u0000${target.port ?? ""}`;
7373}
7474
75export function remoteStateKey(target: DesktopSshEnvironmentTarget): string {
76 return NodeCrypto.createHash("sha256")
77 .update(targetConnectionKey(target))
78 .digest("hex")
79 .slice(0, 16);
80}
75/** Names the remote state directory for a target: the first 16 hex chars of its SHA-256 key. */
76export const remoteStateKey = Effect.fn("ssh/command.remoteStateKey")(function* (
77 target: DesktopSshEnvironmentTarget,
78): Effect.fn.Return<string, never, Crypto.Crypto> {
79 const crypto = yield* Crypto.Crypto;
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
8286function buildSshHostSpec(target: DesktopSshEnvironmentTarget): string {
8387 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";
1111import { ChildProcess, ChildProcessSpawner } from "effect/process";
1212import * as NodeNet from "node:net";
1313
14import { remoteStateKey } from "./command.ts";
1415import { buildRemoteStopScript, buildRemoteT3RunnerScript } from "./tunnel.ts";
1516
1617const Started = Schema.Struct({
......@@ -186,12 +187,14 @@ server.listen(0, "127.0.0.1", () => {
186187 for (const [name, contents] of Object.entries(savedState)) {
187188 yield* fs.writeFileString(path.join(fixture, name), contents);
188189 }
189 const script = buildRemoteStopScript({
190 alias: "fixture",
191 hostname: "fixture",
192 username: null,
193 port: null,
194 });
190 const script = buildRemoteStopScript(
191 yield* remoteStateKey({
192 alias: "fixture",
193 hostname: "fixture",
194 username: null,
195 port: null,
196 }),
197 );
195198 // Redirect only the state directory. Never use the developer's SSH state.
196199 const isolatedScript = script.replace(
197200 /^STATE_DIR=.*$/mu,
packages/ssh/src/tunnel.test.ts+8-13
......@@ -233,12 +233,7 @@ describe("ssh tunnel scripts", () => {
233233 });
234234
235235 it("uses the remote t3 runner for launch and pairing scripts", () => {
236 const target = {
237 alias: "devbox",
238 hostname: "devbox.example.com",
239 username: "julius",
240 port: 2222,
241 } as const;
236 const stateKey = "711bc738002d72fd";
242237 const launch = SshTunnel.buildRemoteLaunchScript(ARCHIVE);
243238 const devLaunch = SshTunnel.buildRemoteLaunchScript({
244239 ...NODE_SCRIPT,
......@@ -265,28 +260,28 @@ describe("ssh tunnel scripts", () => {
265260 assert.include(launch, "It wrote nothing to %s");
266261 assert.include(launch, "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'");
267262 assert.include(
268 SshTunnel.buildRemotePairingScript(target, ARCHIVE),
263 SshTunnel.buildRemotePairingScript(stateKey, ARCHIVE),
269264 '"$RUNNER_FILE" auth pairing create --base-dir "$PAIRING_BASE_DIR" --json',
270265 );
271266 assert.include(
272 SshTunnel.buildRemotePairingScript(target, ARCHIVE),
267 SshTunnel.buildRemotePairingScript(stateKey, ARCHIVE),
273268 'PAIRING_BASE_DIR="$DEFAULT_SERVER_HOME"',
274269 );
275 assert.notInclude(SshTunnel.buildRemotePairingScript(target, ARCHIVE), "server-home");
270 assert.notInclude(SshTunnel.buildRemotePairingScript(stateKey, ARCHIVE), "server-home");
276271 assert.include(
277 SshTunnel.buildRemotePairingScript(target, ARCHIVE),
272 SshTunnel.buildRemotePairingScript(stateKey, ARCHIVE),
278273 "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'",
279274 );
280275 assert.include(
281 SshTunnel.buildRemoteStopScript(target),
276 SshTunnel.buildRemoteStopScript(stateKey),
282277 'if [ "$REMOTE_MANAGED" != "external" ] && [ -n "$REMOTE_PID" ]',
283278 );
284279 assert.include(
285 SshTunnel.buildRemoteStopScript(target),
280 SshTunnel.buildRemoteStopScript(stateKey),
286281 'kill "$REMOTE_PID" 2>/dev/null || true',
287282 );
288283 assert.include(
289 SshTunnel.buildRemoteStopScript(target),
284 SshTunnel.buildRemoteStopScript(stateKey),
290285 'rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE"',
291286 );
292287 assert.include(
packages/ssh/src/tunnel.ts+29-24
......@@ -12,6 +12,7 @@ import * as NetService from "@t3tools/shared/Net";
1212import { extractJsonObject, fromLenientJson } from "@t3tools/shared/schemaJson";
1313import { satisfiesSemverRange } from "@t3tools/shared/semver";
1414import * as Context from "effect/Context";
15import * as Crypto from "effect/Crypto";
1516import * as Effect from "effect/Effect";
1617import * as Exit from "effect/Exit";
1718import * as FileSystem from "effect/FileSystem";
......@@ -98,6 +99,7 @@ interface SshTunnelEntry {
9899
99100type SshEnvironmentEffectContext =
100101 | ChildProcessSpawner.ChildProcessSpawner
102 | Crypto.Crypto
101103 | FileSystem.FileSystem
102104 | Path.Path
103105 | HttpClient.HttpClient
......@@ -837,25 +839,22 @@ export function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string {
837839 });
838840}
839841
840export function buildRemotePairingScript(
841 target: DesktopSshEnvironmentTarget,
842 input?: RemoteT3RunnerOptions,
843): string {
842export function buildRemotePairingScript(stateKey: string, input?: RemoteT3RunnerOptions): string {
844843 return applyScriptPlaceholders(REMOTE_PAIRING_SCRIPT, {
845 T3_STATE_KEY: remoteStateKey(target),
844 T3_STATE_KEY: stateKey,
846845 T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)),
847846 });
848847}
849848
850export function buildRemoteStopScript(target: DesktopSshEnvironmentTarget): string {
849export function buildRemoteStopScript(stateKey: string): string {
851850 return applyScriptPlaceholders(REMOTE_STOP_SCRIPT, {
852 T3_STATE_KEY: remoteStateKey(target),
851 T3_STATE_KEY: stateKey,
853852 });
854853}
855854
856function buildRemoteLogTailScript(target: DesktopSshEnvironmentTarget): string {
855function buildRemoteLogTailScript(stateKey: string): string {
857856 return applyScriptPlaceholders(REMOTE_LOG_TAIL_SCRIPT, {
858 T3_STATE_KEY: remoteStateKey(target),
857 T3_STATE_KEY: stateKey,
859858 });
860859}
861860
......@@ -867,15 +866,16 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo
867866 ): Effect.fn.Return<
868867 { readonly remotePort: number; readonly remoteServerKind: "external" | "managed" | null },
869868 SshCommandError | SshInvalidTargetError | SshLaunchError,
870 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
869 ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path
871870 > {
871 const stateKey = yield* remoteStateKey(target);
872872 yield* Effect.logInfo("ssh.remoteServer.launch.start", {
873873 ...sshTargetLogFields(target),
874874 ...sshRunnerLogFields(runner),
875 stateKey: remoteStateKey(target),
875 stateKey,
876876 });
877877 const result = yield* runSshCommand(target, {
878 remoteCommandArgs: ["sh", "-l", "-s", "--", remoteStateKey(target)],
878 remoteCommandArgs: ["sh", "-l", "-s", "--", stateKey],
879879 stdin: buildRemoteLaunchScript(runner),
880880 timeoutMs: isNodeScriptRunner(runner)
881881 ? REMOTE_LAUNCH_TIMEOUT_MS
......@@ -910,7 +910,7 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo
910910 ...sshTargetLogFields(target),
911911 remotePort: parsed.remotePort,
912912 remoteServerKind: parsed.serverKind ?? null,
913 stateKey: remoteStateKey(target),
913 stateKey,
914914 });
915915 return {
916916 remotePort: parsed.remotePort,
......@@ -928,15 +928,16 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT
928928 readonly credential: string;
929929 },
930930 SshCommandError | SshInvalidTargetError | SshPairingError,
931 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
931 ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path
932932> {
933 const stateKey = yield* remoteStateKey(target);
933934 yield* Effect.logDebug("ssh.remoteServer.pairingToken.start", {
934935 ...sshTargetLogFields(target),
935 stateKey: remoteStateKey(target),
936 stateKey,
936937 });
937938 const result = yield* runSshCommand(target, {
938939 remoteCommandArgs: ["sh", "-s"],
939 stdin: buildRemotePairingScript(target, runner),
940 stdin: buildRemotePairingScript(stateKey, runner),
940941 // Pairing may be the first command on a cold remote, so it can install
941942 // the archive on the way.
942943 ...(isNodeScriptRunner(runner) ? {} : { timeoutMs: REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS }),
......@@ -968,7 +969,7 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT
968969 }
969970 yield* Effect.logDebug("ssh.remoteServer.pairingToken.created", {
970971 ...sshTargetLogFields(target),
971 stateKey: remoteStateKey(target),
972 stateKey,
972973 });
973974 return {
974975 credential: parsed.credential,
......@@ -981,22 +982,23 @@ const stopRemoteServer = Effect.fn("ssh/tunnel.stopRemoteServer")(function* (
981982): Effect.fn.Return<
982983 void,
983984 SshCommandError | SshInvalidTargetError,
984 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
985 ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path
985986> {
987 const stateKey = yield* remoteStateKey(target);
986988 yield* Effect.logInfo("ssh.remoteServer.stop.start", {
987989 ...sshTargetLogFields(target),
988 stateKey: remoteStateKey(target),
990 stateKey,
989991 });
990992 yield* runSshCommand(target, {
991993 remoteCommandArgs: ["sh", "-s"],
992 stdin: buildRemoteStopScript(target),
994 stdin: buildRemoteStopScript(stateKey),
993995 ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }),
994996 ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }),
995997 ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }),
996998 });
997999 yield* Effect.logInfo("ssh.remoteServer.stop.succeeded", {
9981000 ...sshTargetLogFields(target),
999 stateKey: remoteStateKey(target),
1001 stateKey,
10001002 });
10011003});
10021004
......@@ -1006,11 +1008,11 @@ const readRemoteServerLogTail = Effect.fn("ssh/tunnel.readRemoteServerLogTail")(
10061008): Effect.fn.Return<
10071009 string,
10081010 SshCommandError | SshInvalidTargetError,
1009 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
1011 ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path
10101012> {
10111013 const result = yield* runSshCommand(target, {
10121014 remoteCommandArgs: ["sh", "-s"],
1013 stdin: buildRemoteLogTailScript(target),
1015 stdin: buildRemoteLogTailScript(yield* remoteStateKey(target)),
10141016 timeoutMs: 10_000,
10151017 ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }),
10161018 ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }),
......@@ -1109,6 +1111,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input:
11091111 SshTunnelEntry,
11101112 SshCommandError | SshInvalidTargetError | SshReadinessError,
11111113 | ChildProcessSpawner.ChildProcessSpawner
1114 | Crypto.Crypto
11121115 | FileSystem.FileSystem
11131116 | Path.Path
11141117 | HttpClient.HttpClient
......@@ -1538,6 +1541,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma
15381541 );
15391542 tunnels.set(input.key, tunnelEntry);
15401543 const spawnerService = yield* ChildProcessSpawner.ChildProcessSpawner;
1544 const cryptoService = yield* Crypto.Crypto;
15411545 const fileSystemService = yield* FileSystem.FileSystem;
15421546 const pathService = yield* Path.Path;
15431547 yield* Scope.addFinalizer(
......@@ -1577,6 +1581,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma
15771581 },
15781582 ).pipe(
15791583 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawnerService),
1584 Effect.provideService(Crypto.Crypto, cryptoService),
15801585 Effect.provideService(FileSystem.FileSystem, fileSystemService),
15811586 Effect.provideService(Path.Path, pathService),
15821587 );
......@@ -1757,7 +1762,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma
17571762});
17581763
17591764/**
1760 * @effect-expect-leaking ChildProcessSpawner | FileSystem | HttpClient | NetService | Path | SshPasswordPrompt
1765 * @effect-expect-leaking ChildProcessSpawner | Crypto | FileSystem | HttpClient | NetService | Path | SshPasswordPrompt
17611766 */
17621767export class SshEnvironmentManager extends Context.Service<
17631768 SshEnvironmentManager,
pnpm-lock.yaml+37-34
......@@ -13,8 +13,8 @@ catalogs:
1313 specifier: 4.0.1
1414 version: 4.0.1
1515 '@effect/tsgo':
16 specifier: 0.41.0
17 version: 0.41.0
16 specifier: 0.46.1
17 version: 0.46.1
1818 '@legendapp/list':
1919 specifier: 3.3.5
2020 version: 3.3.5
......@@ -131,7 +131,7 @@ importers:
131131 version: 1.1.0
132132 '@effect/tsgo':
133133 specifier: 'catalog:'
134 version: 0.41.0
134 version: 0.46.1
135135 '@shadcn/lint':
136136 specifier: 0.1.5
137137 version: 0.1.5
......@@ -540,6 +540,9 @@ importers:
540540 '@napi-rs/keyring':
541541 specifier: ^1.3.0
542542 version: 1.3.0
543 '@noble/hashes':
544 specifier: 'catalog:'
545 version: 1.8.0
543546 '@opencode-ai/sdk':
544547 specifier: ^1.3.15
545548 version: 1.15.13
......@@ -2384,43 +2387,43 @@ packages:
23842387 peerDependencies:
23852388 effect: 4.0.1
23862389
2387 '@effect/tsgo-darwin-arm64@0.41.0':
2388 resolution: {integrity: sha512-3iEPtcHF72yDjv7T5YpnX+Io1LDLywJb1oGG3Fp/Fth4xmqiT1Vacyz5wnPCfEWGQ6CyrlyCZSSjUxPyJ70+DQ==}
2390 '@effect/tsgo-darwin-arm64@0.46.1':
2391 resolution: {integrity: sha512-ErehNqI9p8me4XKO8N4bzdDWgBbSNlJ3CIZGfXoAW6Fo4Oo7x4ZbZV7KNsMSn9aJSiUUOQUoXjGBdslU5OYMdw==}
23892392 cpu: [arm64]
23902393 os: [darwin]
23912394
2392 '@effect/tsgo-darwin-x64@0.41.0':
2393 resolution: {integrity: sha512-rrVVNacJ/Qh8DfdfgNJDuzcVmR3xPnqes3z+F1kio99umJwPIRB8iPWRAY0SrO+Y84A/y2SSb0AjMGqA5V3Aiw==}
2395 '@effect/tsgo-darwin-x64@0.46.1':
2396 resolution: {integrity: sha512-zsQI1gjGMYF/C2FWDUwbStBAf0tJDVgXeWwTAZ+VUiNY06fR1SrZHK0L+22PE49Nim3biWK2h+XXRFVzql+ojg==}
23942397 cpu: [x64]
23952398 os: [darwin]
23962399
2397 '@effect/tsgo-linux-arm64@0.41.0':
2398 resolution: {integrity: sha512-RVI+7pG3tP56LfH06a2aq4KLp2RQ0HohW8mmoGmrBPyNEL9DnzFEGB98ZoMJ+ERYUJoIp74Jy3nKRVbnzVhxFw==}
2400 '@effect/tsgo-linux-arm64@0.46.1':
2401 resolution: {integrity: sha512-e283BZEQC3mJpk1N7vdUhoF/aYHc4YwCE/fwz/80d11OSvG35U+c49grxS7V7+dAl2ld6QiT8D1rcL8PYu8DLQ==}
23992402 cpu: [arm64]
24002403 os: [linux]
24012404
2402 '@effect/tsgo-linux-arm@0.41.0':
2403 resolution: {integrity: sha512-JddS91IaupLa22oc4YQiYppe7iAZAEyx/2iR9sAAAhpVanWACsIAcyUy4RJrs8sPLLoLsZQaKQa+9rbQO1FLsg==}
2405 '@effect/tsgo-linux-arm@0.46.1':
2406 resolution: {integrity: sha512-Y12wOcJ6HgwiCgg/oDQH4tdRQKRsWnul/KjCOwMxzVT4kO+ndczu1sKUZT+qIxV7qNk4Sq+uSWKEnFBA3ETI8w==}
24042407 cpu: [arm]
24052408 os: [linux]
24062409
2407 '@effect/tsgo-linux-x64@0.41.0':
2408 resolution: {integrity: sha512-U3+kMDVe2Opa5mxbN4B6PgOizWx5B4LXLN98CZDUl2vYtRGfwrRRhKQiB+de3ELaZ0MuDjPlCvU7S2mXQ8UiuQ==}
2410 '@effect/tsgo-linux-x64@0.46.1':
2411 resolution: {integrity: sha512-NYGBC3Scfo9yZJfs7cg0KMcHgtukJ4+xff18nqIrON9Zzc/qD6w1AwEwYKYCsbai5hTQBb3VLIWmHM8BJ54WJg==}
24092412 cpu: [x64]
24102413 os: [linux]
24112414
2412 '@effect/tsgo-win32-arm64@0.41.0':
2413 resolution: {integrity: sha512-H6/xkn+B4B63OPKc0UAzQQFvLs0MXKigiD9dtvQeCU7czWGztOQILuLkyHnM1Dlc+uy4cJ5eXHiMXd+lwl+E8g==}
2415 '@effect/tsgo-win32-arm64@0.46.1':
2416 resolution: {integrity: sha512-MK93SN9qjowrCjch+2wGGncd6Y7KgDyJvLKVVFCJV8cPpHoL3xDCpxFCGqQWe120mVpLmnzbM2evKdh0M5l5PA==}
24142417 cpu: [arm64]
24152418 os: [win32]
24162419
2417 '@effect/tsgo-win32-x64@0.41.0':
2418 resolution: {integrity: sha512-vRN/Mhi381xEGrvV68IN/VO4Lc0pnlVQzHSVmNCJXLwGLmJUvuvPoyZ7Lv4wwHRX9iZe8Rch21xUGu5jSQtUSw==}
2420 '@effect/tsgo-win32-x64@0.46.1':
2421 resolution: {integrity: sha512-hlRbeNlp0N/dfZZFl+xVzT3XfHYqPmkXzCadR6e5yoe/vyA3O9RnO5UrOBHsXvL2RTE6hrh8bRfx2Uu6g3LL5w==}
24192422 cpu: [x64]
24202423 os: [win32]
24212424
2422 '@effect/tsgo@0.41.0':
2423 resolution: {integrity: sha512-C/U7lFM0AXsfpqRilDOgwu+llBhAo8bcdIlzgbRWf1LWlU4KZKB2UsUvBPL3qPnm+wmbCvQLeTQc4BjV9oJrcg==}
2425 '@effect/tsgo@0.46.1':
2426 resolution: {integrity: sha512-DMcBCg5nWLufejRlyqFEkXmbtaLUJ56NKny6L1LTtimvz0U3VNj9H6ZUXV4WeeWvJzVyL0y7G2wsCBAFUgy1jw==}
24242427 hasBin: true
24252428
24262429 '@effect/vitest@4.0.1':
......@@ -13055,36 +13058,36 @@ snapshots:
1305513058 dependencies:
1305613059 effect: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f)
1305713060
13058 '@effect/tsgo-darwin-arm64@0.41.0':
13061 '@effect/tsgo-darwin-arm64@0.46.1':
1305913062 optional: true
1306013063
13061 '@effect/tsgo-darwin-x64@0.41.0':
13064 '@effect/tsgo-darwin-x64@0.46.1':
1306213065 optional: true
1306313066
13064 '@effect/tsgo-linux-arm64@0.41.0':
13067 '@effect/tsgo-linux-arm64@0.46.1':
1306513068 optional: true
1306613069
13067 '@effect/tsgo-linux-arm@0.41.0':
13070 '@effect/tsgo-linux-arm@0.46.1':
1306813071 optional: true
1306913072
13070 '@effect/tsgo-linux-x64@0.41.0':
13073 '@effect/tsgo-linux-x64@0.46.1':
1307113074 optional: true
1307213075
13073 '@effect/tsgo-win32-arm64@0.41.0':
13076 '@effect/tsgo-win32-arm64@0.46.1':
1307413077 optional: true
1307513078
13076 '@effect/tsgo-win32-x64@0.41.0':
13079 '@effect/tsgo-win32-x64@0.46.1':
1307713080 optional: true
1307813081
13079 '@effect/tsgo@0.41.0':
13082 '@effect/tsgo@0.46.1':
1308013083 optionalDependencies:
13081 '@effect/tsgo-darwin-arm64': 0.41.0
13082 '@effect/tsgo-darwin-x64': 0.41.0
13083 '@effect/tsgo-linux-arm': 0.41.0
13084 '@effect/tsgo-linux-arm64': 0.41.0
13085 '@effect/tsgo-linux-x64': 0.41.0
13086 '@effect/tsgo-win32-arm64': 0.41.0
13087 '@effect/tsgo-win32-x64': 0.41.0
13084 '@effect/tsgo-darwin-arm64': 0.46.1
13085 '@effect/tsgo-darwin-x64': 0.46.1
13086 '@effect/tsgo-linux-arm': 0.46.1
13087 '@effect/tsgo-linux-arm64': 0.46.1
13088 '@effect/tsgo-linux-x64': 0.46.1
13089 '@effect/tsgo-win32-arm64': 0.46.1
13090 '@effect/tsgo-win32-x64': 0.46.1
1308813091
1308913092 '@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)':
1309013093 dependencies:
pnpm-workspace.yaml+1-1
......@@ -36,7 +36,7 @@ catalog:
3636 "@effect/platform-node-shared": 4.0.1
3737 "@effect/sql-pg": 4.0.1
3838 "@effect/sql-sqlite-do": 4.0.1
39 "@effect/tsgo": 0.41.0
39 "@effect/tsgo": 0.46.1
4040 "@effect/vitest": 4.0.1
4141 "@legendapp/list": 3.3.5
4242 "@noble/curves": 1.9.1
tsconfig.base.json-1
......@@ -23,7 +23,6 @@
2323 "name": "@effect/language-service",
2424 "namespaceImportPackages": ["@effect/platform-node", "effect"],
2525 "diagnosticSeverity": {
26 "importFromBarrel": "error",
2726 "anyUnknownInErrorContext": "error",
2827 "unsafeEffectTypeAssertion": "error",
2928 "instanceOfSchema": "error",