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