From d705d0335be00b41c8d89ffd37891ee4eaf78e04 Mon Sep 17 00:00:00 2001 From: Laurin-Notemann <47634664+Laurin-Notemann@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:37:16 +0000 Subject: [PATCH 1/2] fix(otterware): final pass after the shell rebuild --- apps/desktop/src/app/DesktopCliShim.ts | 2 +- .../desktop/src/backend/DesktopBackendPool.ts | 4 +- .../desktop/src/wsl/DesktopWslBackend.test.ts | 2 +- apps/desktop/src/wsl/DesktopWslBackend.ts | 2 +- .../settings/ConnectionsSettings.tsx | 2 +- .../settings/scopedSettings.test.ts | 2 +- .../src/components/settings/scopedSettings.ts | 2 +- .../sidebar/updateEverythingRun.tsx | 2 +- .../otterware/account/AccountSettingsPage.tsx | 10 ++ apps/web/src/otterware/settingsRegistry.ts | 12 +- infra/cloud/CUTOVER.md | 12 +- .../cloud/drive/src/server/device-approval.ts | 123 ++++++++++++++++++ infra/cloud/drive/src/server/identity.test.ts | 27 +++- infra/cloud/src/legacy.ts | 2 + infra/cloud/src/worker.test.ts | 41 ++++++ infra/cloud/src/worker.ts | 4 + modules/mail/contracts/src/rpc.test.ts | 14 +- .../src/connection/errors.test.ts | 4 +- .../src/relay/errorPresentation.ts | 6 +- packages/composition/src/contracts.test.ts | 35 +---- .../src/agent-instructions/index.test.ts | 23 ---- packages/module-kit/src/web/registry.test.ts | 16 +-- scripts/otterware/upstream-seams.txt | 11 ++ 23 files changed, 259 insertions(+), 99 deletions(-) create mode 100644 infra/cloud/drive/src/server/device-approval.ts delete mode 100644 packages/module-kit/src/agent-instructions/index.test.ts diff --git a/apps/desktop/src/app/DesktopCliShim.ts b/apps/desktop/src/app/DesktopCliShim.ts index 0a575a1556..6d4f3cce36 100644 --- a/apps/desktop/src/app/DesktopCliShim.ts +++ b/apps/desktop/src/app/DesktopCliShim.ts @@ -27,7 +27,7 @@ const shellWord = (value: string) => `'${value.replaceAll("'", `'"'"'`)}'`; const cmdText = (value: string) => value.replaceAll("%", "%%"); const cmdWord = (value: string) => `"${cmdText(value)}"`; -const MOVED = "T3 Code has moved or been removed. Open the app once to update this command."; +const MOVED = "Otter Code has moved or been removed. Open the app once to update this command."; export type CliShimTarget = | { readonly kind: "appimage"; readonly appImage: string; readonly executableName: string } diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts index ad978ada2b..97bf06b4fc 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.ts @@ -308,8 +308,8 @@ export const layer = Layer.effect( onStateDirOwned: () => electronDialog .showErrorBox( - "This T3 home is unavailable", - "Another T3 Code server, such as the background service, is using this data directory. Stop that server and reopen T3 Code, or set a separate T3CODE_HOME and pair with the running server.", + "This Otter Code home is unavailable", + "Another Otter Code server, such as the background service, is using this data directory. Stop that server and reopen Otter Code, or set a separate T3CODE_HOME and pair with the running server.", ) .pipe(Effect.andThen(electronApp.quit)), }); diff --git a/apps/desktop/src/wsl/DesktopWslBackend.test.ts b/apps/desktop/src/wsl/DesktopWslBackend.test.ts index e6dcb7bd71..13291ba5d5 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.test.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.test.ts @@ -172,7 +172,7 @@ describe("DesktopWslBackend", () => { yield* recordOwnershipFailure(); const refusal = yield* backend.lastPreflightError; - assert.include(Option.getOrThrow(refusal), "Another T3 Code server"); + assert.include(Option.getOrThrow(refusal), "Another Otter Code server"); yield* backend.reconcile; assert.equal(startCount, 1); assert.deepEqual(yield* backend.lastPreflightError, refusal); diff --git a/apps/desktop/src/wsl/DesktopWslBackend.ts b/apps/desktop/src/wsl/DesktopWslBackend.ts index fe131ca4f9..c39938486b 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.ts @@ -177,7 +177,7 @@ export const layer = Layer.effect( Ref.set( preflightErrorRef, Option.some( - "Another T3 Code server is using this distro's T3 home. Stop it, or pair with it instead.", + "Another Otter Code server is using this distro's Otter Code home. Stop it, or pair with it instead.", ), ), ), diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index a45dfcdbf7..8c916ef28e 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1947,7 +1947,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b ) : linkRevoked ? ( ) : null} { const unsupported = [ { label: server.label, - message: "This environment does not support this setting. Update T3 Code on it.", + message: "This environment does not support this setting. Update Otter Code on it.", }, ]; diff --git a/apps/web/src/components/settings/scopedSettings.ts b/apps/web/src/components/settings/scopedSettings.ts index dcedf419ca..796648c7db 100644 --- a/apps/web/src/components/settings/scopedSettings.ts +++ b/apps/web/src/components/settings/scopedSettings.ts @@ -475,7 +475,7 @@ export async function persistScopedSettingsPatch( return [ { ...environment, - message: "This environment does not support this setting. Update T3 Code on it.", + message: "This environment does not support this setting. Update Otter Code on it.", }, ]; } diff --git a/apps/web/src/components/sidebar/updateEverythingRun.tsx b/apps/web/src/components/sidebar/updateEverythingRun.tsx index 8fe73b9da1..0c4c3c3082 100644 --- a/apps/web/src/components/sidebar/updateEverythingRun.tsx +++ b/apps/web/src/components/sidebar/updateEverythingRun.tsx @@ -133,7 +133,7 @@ export function DesktopUpdateLaunchNotice() { stackedThreadToast({ type: "error", title: "Update did not apply", - description: `T3 Code is still on ${APP_VERSION}. Install ${notAppliedVersion} again from the update button.`, + description: `Otter Code is still on ${APP_VERSION}. Install ${notAppliedVersion} again from the update button.`, }), ); }, [notAppliedVersion]); diff --git a/apps/web/src/otterware/account/AccountSettingsPage.tsx b/apps/web/src/otterware/account/AccountSettingsPage.tsx index 97a63633cb..662c77bd30 100644 --- a/apps/web/src/otterware/account/AccountSettingsPage.tsx +++ b/apps/web/src/otterware/account/AccountSettingsPage.tsx @@ -59,6 +59,16 @@ export function AccountSettingsPage() { } /> + + Run otterdrive auth login in your terminal, then approve the sign-in in + your browser with your Otter Account. + + } + /> {environmentId !== null ? ( diff --git a/apps/web/src/otterware/settingsRegistry.ts b/apps/web/src/otterware/settingsRegistry.ts index 29823f8237..01ca32557c 100644 --- a/apps/web/src/otterware/settingsRegistry.ts +++ b/apps/web/src/otterware/settingsRegistry.ts @@ -170,7 +170,17 @@ const ACCOUNT_SEARCH_ITEM: OtterwareSettingsSearchItem = { id: "account", title: "Otter Account", to: ACCOUNT_SETTINGS_PATH, - searchTerms: ["account", "sign in", "sign out", "otter", "devices", "tokens"], + searchTerms: [ + "account", + "sign in", + "sign out", + "otter", + "devices", + "tokens", + "command line", + "CLI", + "otterdrive auth login", + ], }; const MODULES_SEARCH_ITEM: OtterwareSettingsSearchItem = { diff --git a/infra/cloud/CUTOVER.md b/infra/cloud/CUTOVER.md index a8031d5b2b..ddf02bd81d 100644 --- a/infra/cloud/CUTOVER.md +++ b/infra/cloud/CUTOVER.md @@ -119,11 +119,15 @@ newer local edits. `sync.changed` is an invalidation hint, never the copy of rec no account continue using the same local store and outbox. The imported Drive API is at `/api/v1/`; it accepts API keys/Drive sessions and Cloud tokens. -Cloud users must already have an explicitly linked Drive identity (no matching by email). +Cloud users get a Drive identity and personal drive on their first authenticated request, linked +by the verified Otter Account subject (never by matching email). Preview and thumbnail grants continue targeting **only** `usercontent.otterware.app`, using the -existing key. The Cloud host rejects `/raw/*` before dispatch. The Drive profile's W5 static -build replaces `public/` only at cutover; this API deployment does not ship an incomplete Drive UI. -The imported CLI source is in `drive-cli/`, built by `build:cli`; no CLI release is published. +existing key. The Cloud host rejects `/raw/*` before dispatch. The Cloud build ships the +standalone Drive profile under `public/drive/`; moving the canonical Drive hostname still waits +for cutover. CLI device approval at `/device` is served by the Worker +on that canonical hostname, independently of the SPA, using Drive's Otter Account OAuth session. +The CLI source is in `apps/drive-cli/`, built by `build:cli`. Its separate +`publish-drive-cli.yml` workflow publishes to npm only on manual dispatch. OpenRouter uses `/v1/cloud/agent/*` and a new `CloudAgentHub` namespace, preserving the imported `/connection`, `/sessions`, `/turn`, `/tool-result`, and `/cancel` protocol. Keys are sealed; diff --git a/infra/cloud/drive/src/server/device-approval.ts b/infra/cloud/drive/src/server/device-approval.ts new file mode 100644 index 0000000000..b8bfa8ac63 --- /dev/null +++ b/infra/cloud/drive/src/server/device-approval.ts @@ -0,0 +1,123 @@ +import { html, raw } from "hono/html"; +import { createAuth } from "./auth"; +import type { Env } from "./types"; + +/** The CLI's browser approval survives independently of the Drive profile's SPA. */ +export async function deviceApproval(request: Request, env: Env): Promise { + if (request.method !== "GET" && request.method !== "HEAD") + return new Response(null, { status: 405, headers: { allow: "GET, HEAD" } }); + const userCode = new URL(request.url).searchParams.get("user_code") ?? ""; + let content; + let script = ""; + let status = 200; + if (!/^[A-Za-z0-9-]{4,32}$/.test(userCode)) { + content = html`

Enter the code shown by otterdrive auth login.

+
+ + + +
`; + } else { + const auth = createAuth(env); + const session = await auth.api.getSession({ headers: request.headers }); + if (!session) { + content = html`

+ Sign in with your Otter Account to review code ${userCode}. +

+ `; + const callbackURL = `${env.APP_URL}/device?${new URLSearchParams({ user_code: userCode })}`; + script = `document.querySelector('#sign-in').onclick = async () => { + try { + const response = await fetch('/api/auth/sign-in/social', { method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify({provider: 'otter', callbackURL: ${JSON.stringify(callbackURL)}, disableRedirect: true}) }); + const result = await response.json(); + if (!response.ok || !result.url) throw new Error(); + location.assign(result.url); + } catch { document.querySelector('#result').textContent = 'Could not sign in. Please try again.'; } + };`; + } else { + const device = await auth.api + .deviceVerify({ + headers: request.headers, + query: { user_code: userCode }, + }) + .catch(() => null); + if (!device || !["otterdrive-cli", "otterware-cli"].includes(device.client_id ?? "")) { + status = 400; + content = html`

+ This code is invalid or expired. Run otterdrive auth login again. +

`; + } else if (device.status !== "pending") { + content = html`

This connection has already been reviewed. Return to your terminal.

`; + } else { + content = html`

+ Connect the CLI displaying ${userCode} to + ${session.user.email}. +

+

Approve only if you started this sign-in on your own machine.

+
+ +
`; + script = `document.querySelector('#review').onsubmit = async event => { + event.preventDefault(); + try { + const action = event.submitter.value; + const response = await fetch('/api/auth/device/' + action, { method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify({userCode: ${JSON.stringify(userCode)}}) }); + if (!response.ok) throw new Error(); + document.querySelector('#result').textContent = action === 'approve' ? 'Approved. Return to your terminal.' : 'Connection denied.'; + document.querySelector('#review').remove(); + } catch { document.querySelector('#result').textContent = 'Could not review this connection. Please try again.'; } + };`; + } + } + } + const nonce = crypto.randomUUID(); + const body = await html` + + + + + Connect Otter Drive + + + +
+

Connect Otter Drive

+ ${content} +

+
+ + + `; + return new Response(request.method === "HEAD" ? null : body.toString(), { + status, + headers: { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + "referrer-policy": "no-referrer", + "x-content-type-options": "nosniff", + "content-security-policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`, + }, + }); +} diff --git a/infra/cloud/drive/src/server/identity.test.ts b/infra/cloud/drive/src/server/identity.test.ts index 427bac1659..aedcadae64 100644 --- a/infra/cloud/drive/src/server/identity.test.ts +++ b/infra/cloud/drive/src/server/identity.test.ts @@ -8,6 +8,7 @@ import { createAuth } from "./auth"; import { authenticate } from "./actor"; import { deleteIdentity, identityRequest } from "./identity"; import { browserAuth } from "./browser-auth"; +import { deviceApproval } from "./device-approval"; import type { Env } from "./types"; let server: ReturnType; @@ -358,12 +359,24 @@ describe("shared Otter identity", () => { user_code: string; device_code: string; }; - const claim = await auth.handler( - new Request(`${env.APP_URL}/api/auth/device?user_code=${code.user_code}`, { headers }), - ); + const pageUrl = `${env.APP_URL}/device?user_code=${code.user_code}`; + const signedOut = await deviceApproval(new Request(pageUrl), env); + expect(await signedOut.text()).toContain("Sign in with Otter Account"); + const claim = await deviceApproval(new Request(pageUrl, { headers }), env); expect(claim.status).toBe(200); + const page = await claim.text(); + expect(page).toContain(code.user_code); + expect(page).toContain("owner@example.com"); + expect(page).toContain('value="approve"'); + expect(page).toContain('value="deny"'); + expect( + (await auth.api.deviceVerify({ headers, query: { user_code: code.user_code } })).status, + ).toBe("pending"); const approved = await call("/device/approve", { userCode: code.user_code }, true); expect(approved.status).toBe(200); + expect(await (await deviceApproval(new Request(pageUrl, { headers }), env)).text()).toContain( + "already been reviewed", + ); const granted = await call("/device/token", { grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: code.device_code, @@ -381,6 +394,14 @@ describe("shared Otter identity", () => { expect(actor.userId).toBe("stable-otter-id"); expect(actor.folderId).toBe("folder"); }); + it("rejects invalid or expired approval codes and never accepts a page mutation", async () => { + const { response } = await login(); + const request = new Request(`${env.APP_URL}/device?user_code=INVALID`, { + headers: { cookie: cookies(response) }, + }); + expect((await deviceApproval(request, env)).status).toBe(400); + expect((await deviceApproval(new Request(request, { method: "POST" }), env)).status).toBe(405); + }); it("does not link a new identity to an existing user just because the emails match", async () => { const { response } = await login("different-otter-id"); expect(response.headers.get("location")).toContain("error="); diff --git a/infra/cloud/src/legacy.ts b/infra/cloud/src/legacy.ts index d01f3c9512..c5cdc6af7b 100644 --- a/infra/cloud/src/legacy.ts +++ b/infra/cloud/src/legacy.ts @@ -27,6 +27,7 @@ import { authenticate as cloudAuthenticate } from "./auth.ts"; import { createAuth } from "../drive/src/server/auth.ts"; import { authenticate } from "../drive/src/server/actor.ts"; import { ensureDriveIdentity } from "./drive-identity.ts"; +import { deviceApproval } from "../drive/src/server/device-approval.ts"; export function accountsEnv(env: Env): AccountsEnv { return { @@ -91,6 +92,7 @@ export async function drive(request: Request, env: Env, parallel: boolean) { if (!isAllowedHostPath(request, hostBindings)) return hostNotFound(); const url = new URL(request.url); try { + if (url.pathname === "/device") return await deviceApproval(request, bindings); if (url.pathname.startsWith("/api/v1/")) { // Provision from the authenticated Accounts subject, never an email match. if (request.headers.has("x-otter-audience")) { diff --git a/infra/cloud/src/worker.test.ts b/infra/cloud/src/worker.test.ts index 34087e2bb9..04f0999a30 100644 --- a/infra/cloud/src/worker.test.ts +++ b/infra/cloud/src/worker.test.ts @@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; import { bundle } from "../scripts/bundle.ts"; let runtime: Miniflare; +let workerScript: string; const origin = "https://cloud.otterware.app"; const audience = "environment:test"; const identityScript = ` @@ -29,6 +30,7 @@ export default { async fetch(request, env) { `; beforeAll(async () => { const result = await bundle("src/worker.ts", false); + workerScript = result.outputFiles![0]!.text; runtime = new Miniflare( convertV4MiniflareOptions({ workers: [ @@ -134,6 +136,45 @@ const mutation = ( ) => ({ collection: "settings", id, modifiedAt, value, mutationId }); describe("parallel Cloud Worker", () => { + it("serves CLI approval on the Drive host without falling through to SPA assets", async () => { + const cutover = new Miniflare( + convertV4MiniflareOptions({ + modules: true, + script: workerScript, + compatibilityDate: "2026-09-01", + compatibilityFlags: ["nodejs_compat"], + bindings: { + CLOUD_ORIGIN: origin, + LEGACY_HOSTS: "enabled", + DRIVE_SECRET: "test-drive-secret-is-at-least-32-characters", + CONTENT_SIGNING_KEY: "test-content-secret-is-at-least-32-characters", + }, + d1Databases: { DRIVE_DB: "device-test" }, + }), + ); + try { + const page = await cutover.dispatchFetch("https://drive.otterware.app/device"); + expect(page.status).toBe(200); + expect(page.headers.get("cache-control")).toBe("no-store"); + expect(await page.text()).toContain("otterdrive auth login"); + expect((await cutover.dispatchFetch("https://usercontent.otterware.app/device")).status).toBe( + 404, + ); + expect( + (await cutover.dispatchFetch("https://drive.otterware.app/device", { method: "POST" })) + .status, + ).toBe(405); + } finally { + await cutover.dispose(); + } + }); + it("sends device approval to Drive's OAuth origin, preserving the CLI code", async () => { + const response = await request("/device?user_code=ABCD-EFGH", { redirect: "manual" }); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe( + "https://drive.otterware.app/device?user_code=ABCD-EFGH", + ); + }); it("serves health only on the new hostname and rejects uploaded-content paths", async () => { expect((await request("/health")).status).toBe(200); expect((await request("/raw/a/test/version/index.html")).status).toBe(404); diff --git a/infra/cloud/src/worker.ts b/infra/cloud/src/worker.ts index 175c07c363..57f8be68e8 100644 --- a/infra/cloud/src/worker.ts +++ b/infra/cloud/src/worker.ts @@ -251,6 +251,10 @@ function forwardToMail(c: Context, request: Request, sessionToken: string) } app.all("/v1/cloud/mail/*", (c) => forwardToMail(c, c.req.raw, c.var.identity.sessionToken)); app.all("/api/*", (c) => drive(c.req.raw, c.env, true)); +// CLI auth remains on Drive's canonical origin, including its OAuth callback cookie. +app.get("/device", (c) => + c.redirect(`https://drive.otterware.app/device${new URL(c.req.url).search}`, 302), +); app.notFound((c) => { const redirect = driveStaticRedirect(c.req.raw); if (redirect) return redirect; diff --git a/modules/mail/contracts/src/rpc.test.ts b/modules/mail/contracts/src/rpc.test.ts index 4cfe8e1df7..cbd38c156c 100644 --- a/modules/mail/contracts/src/rpc.test.ts +++ b/modules/mail/contracts/src/rpc.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from "vite-plus/test"; import * as Schema from "effect/Schema"; -import { MailContract, MailEvent, MailOperations } from "./rpc.ts"; +import { MailEvent, MailOperations } from "./rpc.ts"; + +const send = Schema.decodeUnknownSync(MailOperations["gmail:sendMessage"].payloadSchema); +const signInImap = Schema.decodeUnknownSync(MailOperations["gmail:signInImap"].payloadSchema); +const event = Schema.decodeUnknownSync(MailEvent); describe("Mail RPC boundary", () => { it("rejects malformed send and mailbox parameters before dispatch", () => { - const send = Schema.decodeUnknownSync(MailOperations["gmail:sendMessage"].payloadSchema); expect(() => send({ accountId: "a", to: 123, subject: "test", body: "text" })).toThrow(); expect(() => send({ @@ -17,15 +20,12 @@ describe("Mail RPC boundary", () => { ).toThrow(); expect(send({ accountId: "a", to: "b", subject: "test", body: "text" }).to).toBe("b"); expect(() => - Schema.decodeUnknownSync(MailOperations["gmail:signInImap"].payloadSchema)({ + signInImap({ accountId: "a", }), ).toThrow(); }); - it("authorizes each named method and refuses arbitrary event channels", () => { - expect(Object.keys(MailContract.scopes)).toHaveLength(Object.keys(MailOperations).length + 1); - expect(Object.keys(MailContract.scopes).some((name) => /invoke/.test(name))).toBe(false); - const event = Schema.decodeUnknownSync(MailEvent); + it("refuses arbitrary event channels and malformed event payloads", () => { expect(() => event({ channel: "agent:send", params: {} })).toThrow(); expect(() => event({ channel: "gmail:send-failed", params: { subject: "Hi", savedToDrafts: "yes" } }), diff --git a/packages/client-runtime/src/connection/errors.test.ts b/packages/client-runtime/src/connection/errors.test.ts index 4f36c82580..e3951756d6 100644 --- a/packages/client-runtime/src/connection/errors.test.ts +++ b/packages/client-runtime/src/connection/errors.test.ts @@ -55,13 +55,13 @@ describe("mapManagedRelayError", () => { expect(mapConnectRefusal("managed_endpoint_allocation_not_ready")).toMatchObject({ _tag: "ConnectionTransientError", reason: "endpoint-unavailable", - detail: "The environment's T3 Connect tunnel is still being set up.", + detail: "The environment's Otter Connect tunnel is still being set up.", traceId: "trace-connect-refused", }); expect(mapConnectRefusal("endpoint_provider_not_managed")).toMatchObject({ _tag: "ConnectionBlockedError", reason: "permission", - detail: expect.stringContaining("turn T3 Connect on"), + detail: expect.stringContaining("turn Otter Connect on"), }); expect(mapConnectRefusal(undefined)).toMatchObject({ _tag: "ConnectionBlockedError", diff --git a/packages/client-runtime/src/relay/errorPresentation.ts b/packages/client-runtime/src/relay/errorPresentation.ts index 575636b5be..e236a5a60a 100644 --- a/packages/client-runtime/src/relay/errorPresentation.ts +++ b/packages/client-runtime/src/relay/errorPresentation.ts @@ -41,13 +41,13 @@ export function relayProtectedErrorMessage(error: RelayProtectedError): string { // "Not authorized" covers non-auth causes too; surface the reason so a // missing link does not read as a credential problem. if (error.reason === "environment_link_not_found") { - return "Relay has no active link for this environment. If it was deregistered from T3 Connect, link it again on that machine."; + return "Relay has no active link for this environment. If it was deregistered from Otter Connect, link it again on that machine."; } if (error.reason === "managed_endpoint_allocation_not_ready") { - return "The environment's T3 Connect tunnel is still being set up."; + return "The environment's Otter Connect tunnel is still being set up."; } if (error.reason === "endpoint_provider_not_managed") { - return "This environment is linked only to publish agent activity, so it cannot accept connections. On that computer, open Settings > Connections and turn T3 Connect on."; + return "This environment is linked only to publish agent activity, so it cannot accept connections. On that computer, open Settings > Connections and turn Otter Connect on."; } return error.reason ? `Relay rejected the environment connection request (${error.reason}).` diff --git a/packages/composition/src/contracts.test.ts b/packages/composition/src/contracts.test.ts index c259705000..9e8601cbb5 100644 --- a/packages/composition/src/contracts.test.ts +++ b/packages/composition/src/contracts.test.ts @@ -1,27 +1,14 @@ import { PROFILES } from "@otterware/profiles"; import { describe, expect, it } from "vite-plus/test"; -import { - composeContracts, - OTTERWARE_RPC_AGGREGATES, - OTTERWARE_RPC_SUPERSET_SCOPES, -} from "./contracts.ts"; +import { composeContracts } from "./contracts.ts"; describe("composeContracts", () => { it("serves only the active profile's modules while the host RPCs stay", () => { const code = composeContracts(PROFILES.code); expect(code.moduleIds).toEqual([]); - expect([...code.group.requests.keys()]).toEqual([ - "modules.list", - "modules.setEnabled", - "assistant.ensureProject", - "account.session.attach", - "account.session.detach", - "account.session.state", - "account.connections.list", - "account.connections.connect", - "account.connections.disconnect", - ]); + expect([...code.group.requests.keys()]).toContain("modules.list"); + expect([...code.group.requests.keys()].some((tag) => tag.startsWith("mail."))).toBe(false); expect(code.scopes["account.session.attach"]).toBe("settings:write"); expect(code.scopes["account.connections.list"]).toBe("orchestration:read"); @@ -47,20 +34,4 @@ describe("composeContracts", () => { expect([...otterware.group.requests.keys()]).toContain("home.overview"); expect(otterware.scopes["home.overview"]).toBe("orchestration:read"); }); - - it("labels every known method for instrumentation and authorization", () => { - for (const tag of Object.keys(OTTERWARE_RPC_SUPERSET_SCOPES)) { - expect(OTTERWARE_RPC_AGGREGATES[tag as keyof typeof OTTERWARE_RPC_AGGREGATES]).toBe( - tag.startsWith("home.") - ? "module.home" - : tag.startsWith("calendar.") - ? "module.calendar" - : tag.startsWith("mail.") - ? "module.mail" - : tag.startsWith("drive.") - ? "module.drive" - : "host", - ); - } - }); }); diff --git a/packages/module-kit/src/agent-instructions/index.test.ts b/packages/module-kit/src/agent-instructions/index.test.ts deleted file mode 100644 index 58a4881138..0000000000 --- a/packages/module-kit/src/agent-instructions/index.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import { AgentInstructions, moduleAgentInstructions } from "./index.ts"; - -describe("module agent instructions", () => { - it.effect("isolates environment snapshots and reads live state", () => - Effect.gen(function* () { - let first = "mail"; - const readFirst = Effect.sync(moduleAgentInstructions).pipe( - Effect.provideService(AgentInstructions, { read: () => first, update: () => {} }), - ); - const readSecond = Effect.sync(moduleAgentInstructions).pipe( - Effect.provideService(AgentInstructions, { read: () => "calendar", update: () => {} }), - ); - expect(yield* readFirst).toBe("mail"); - expect(yield* readSecond).toBe("calendar"); - first = ""; - expect(yield* readFirst).toBe(""); - expect(yield* readSecond).toBe("calendar"); - expect(yield* Effect.sync(moduleAgentInstructions)).toBe(""); - }), - ); -}); diff --git a/packages/module-kit/src/web/registry.test.ts b/packages/module-kit/src/web/registry.test.ts index 8c26c8df82..023e703c53 100644 --- a/packages/module-kit/src/web/registry.test.ts +++ b/packages/module-kit/src/web/registry.test.ts @@ -2,12 +2,7 @@ import { HouseIcon, MailIcon } from "lucide-react"; import { lazy } from "react"; import { describe, expect, it } from "vite-plus/test"; -import { - createModuleRegistry, - defineWebModule, - moduleSettingsPath, - projectScopedModulePath, -} from "./registry.ts"; +import { createModuleRegistry, defineWebModule, projectScopedModulePath } from "./registry.ts"; const page = lazy(() => Promise.resolve({ default: () => null })); @@ -60,14 +55,5 @@ describe("createModuleRegistry", () => { expect(registry.forPath("/home/projects/p1/drive")?.module).toBe(home); expect(registry.forPath("/settings/mail/reading")).toBeNull(); expect(registry.isModulePath("/usage")).toBe(false); - expect(registry.settings.map((module) => module.id)).toEqual(["mail"]); - expect(moduleSettingsPath("mail", "reading")).toBe("/settings/mail/reading"); - expect(registry.threadAccessories).toEqual([]); - }); - - it("lists the modules that show something on Code threads", () => { - const drive = defineWebModule({ ...mail, id: "drive", threadAccessory: page }); - const registry = createModuleRegistry([home, mail, drive]); - expect(registry.threadAccessories.map((module) => module.id)).toEqual(["drive"]); }); }); diff --git a/scripts/otterware/upstream-seams.txt b/scripts/otterware/upstream-seams.txt index bd707ed927..6743c672ab 100644 --- a/scripts/otterware/upstream-seams.txt +++ b/scripts/otterware/upstream-seams.txt @@ -82,3 +82,14 @@ apps/web/src/components/ui/scroll-area.tsx # Mail editor keys reuse the host predicate evaluator. apps/web/src/keybindings.ts + +# Mechanical UI rebrand after the shell rebuild; no behavior changes or protocol text. +apps/desktop/src/backend/DesktopBackendPool.ts +apps/desktop/src/wsl/DesktopWslBackend.ts +apps/desktop/src/wsl/DesktopWslBackend.test.ts +apps/web/src/components/settings/ConnectionsSettings.tsx +apps/web/src/components/settings/scopedSettings.ts +apps/web/src/components/settings/scopedSettings.test.ts +apps/web/src/components/sidebar/updateEverythingRun.tsx +packages/client-runtime/src/connection/errors.test.ts +packages/client-runtime/src/relay/errorPresentation.ts From fa6fe5573292ce425e303210b4c73c0d9d323a65 Mon Sep 17 00:00:00 2001 From: Laurin-Notemann <47634664+Laurin-Notemann@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:43:14 +0000 Subject: [PATCH 2/2] fix(otterware): leave the UI rebrand to the sync Rebranding new upstream strings in a PR adds one-off seam entries and broke the seam budget (46 literal files, cap 40). The rebrand belongs in the sync, where it folds into the single Otterware commit; the sync doc now says so. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/app/DesktopCliShim.ts | 2 +- apps/desktop/src/backend/DesktopBackendPool.ts | 4 ++-- apps/desktop/src/wsl/DesktopWslBackend.test.ts | 2 +- apps/desktop/src/wsl/DesktopWslBackend.ts | 2 +- .../src/components/settings/ConnectionsSettings.tsx | 2 +- .../src/components/settings/scopedSettings.test.ts | 2 +- apps/web/src/components/settings/scopedSettings.ts | 2 +- .../src/components/sidebar/updateEverythingRun.tsx | 2 +- docs/operations/otter-fork.md | 8 +++++--- packages/client-runtime/src/connection/errors.test.ts | 4 ++-- .../client-runtime/src/relay/errorPresentation.ts | 6 +++--- scripts/otterware/upstream-seams.txt | 11 ----------- 12 files changed, 19 insertions(+), 28 deletions(-) diff --git a/apps/desktop/src/app/DesktopCliShim.ts b/apps/desktop/src/app/DesktopCliShim.ts index 6d4f3cce36..0a575a1556 100644 --- a/apps/desktop/src/app/DesktopCliShim.ts +++ b/apps/desktop/src/app/DesktopCliShim.ts @@ -27,7 +27,7 @@ const shellWord = (value: string) => `'${value.replaceAll("'", `'"'"'`)}'`; const cmdText = (value: string) => value.replaceAll("%", "%%"); const cmdWord = (value: string) => `"${cmdText(value)}"`; -const MOVED = "Otter Code has moved or been removed. Open the app once to update this command."; +const MOVED = "T3 Code has moved or been removed. Open the app once to update this command."; export type CliShimTarget = | { readonly kind: "appimage"; readonly appImage: string; readonly executableName: string } diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts index 97bf06b4fc..ad978ada2b 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.ts @@ -308,8 +308,8 @@ export const layer = Layer.effect( onStateDirOwned: () => electronDialog .showErrorBox( - "This Otter Code home is unavailable", - "Another Otter Code server, such as the background service, is using this data directory. Stop that server and reopen Otter Code, or set a separate T3CODE_HOME and pair with the running server.", + "This T3 home is unavailable", + "Another T3 Code server, such as the background service, is using this data directory. Stop that server and reopen T3 Code, or set a separate T3CODE_HOME and pair with the running server.", ) .pipe(Effect.andThen(electronApp.quit)), }); diff --git a/apps/desktop/src/wsl/DesktopWslBackend.test.ts b/apps/desktop/src/wsl/DesktopWslBackend.test.ts index 13291ba5d5..e6dcb7bd71 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.test.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.test.ts @@ -172,7 +172,7 @@ describe("DesktopWslBackend", () => { yield* recordOwnershipFailure(); const refusal = yield* backend.lastPreflightError; - assert.include(Option.getOrThrow(refusal), "Another Otter Code server"); + assert.include(Option.getOrThrow(refusal), "Another T3 Code server"); yield* backend.reconcile; assert.equal(startCount, 1); assert.deepEqual(yield* backend.lastPreflightError, refusal); diff --git a/apps/desktop/src/wsl/DesktopWslBackend.ts b/apps/desktop/src/wsl/DesktopWslBackend.ts index c39938486b..fe131ca4f9 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.ts @@ -177,7 +177,7 @@ export const layer = Layer.effect( Ref.set( preflightErrorRef, Option.some( - "Another Otter Code server is using this distro's Otter Code home. Stop it, or pair with it instead.", + "Another T3 Code server is using this distro's T3 home. Stop it, or pair with it instead.", ), ), ), diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 8c916ef28e..a45dfcdbf7 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1947,7 +1947,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b ) : linkRevoked ? ( ) : null} { const unsupported = [ { label: server.label, - message: "This environment does not support this setting. Update Otter Code on it.", + message: "This environment does not support this setting. Update T3 Code on it.", }, ]; diff --git a/apps/web/src/components/settings/scopedSettings.ts b/apps/web/src/components/settings/scopedSettings.ts index 796648c7db..dcedf419ca 100644 --- a/apps/web/src/components/settings/scopedSettings.ts +++ b/apps/web/src/components/settings/scopedSettings.ts @@ -475,7 +475,7 @@ export async function persistScopedSettingsPatch( return [ { ...environment, - message: "This environment does not support this setting. Update Otter Code on it.", + message: "This environment does not support this setting. Update T3 Code on it.", }, ]; } diff --git a/apps/web/src/components/sidebar/updateEverythingRun.tsx b/apps/web/src/components/sidebar/updateEverythingRun.tsx index 0c4c3c3082..8fe73b9da1 100644 --- a/apps/web/src/components/sidebar/updateEverythingRun.tsx +++ b/apps/web/src/components/sidebar/updateEverythingRun.tsx @@ -133,7 +133,7 @@ export function DesktopUpdateLaunchNotice() { stackedThreadToast({ type: "error", title: "Update did not apply", - description: `Otter Code is still on ${APP_VERSION}. Install ${notAppliedVersion} again from the update button.`, + description: `T3 Code is still on ${APP_VERSION}. Install ${notAppliedVersion} again from the update button.`, }), ); }, [notAppliedVersion]); diff --git a/docs/operations/otter-fork.md b/docs/operations/otter-fork.md index e492a7915e..35883f04e0 100644 --- a/docs/operations/otter-fork.md +++ b/docs/operations/otter-fork.md @@ -43,7 +43,9 @@ git merge --no-ff --no-commit upstream/main Resolve every conflict with upstream's current behavior, keeping only the additional Otterware functionality. Do not hand-resolve generated files: run `vp i` for `pnpm-lock.yaml`, regenerate `apps/web/src/routeTree.gen.ts` when routes changed, and rerun -`.github/scripts/otter-adapt-upstream.sh` so its output matches the new upstream. If upstream added +`.github/scripts/otter-adapt-upstream.sh` so its output matches the new upstream. Run +`scripts/otter/rebrand-ui-text.sh` for user-facing text upstream added; a sync is where one-off +rebrands of upstream files belong, so they never enter the seam list. If upstream added relay migrations that branch from the fork's chain, add a merge migration that records both heads as parents without changing the schema: @@ -85,8 +87,8 @@ onto the new `origin/main`, using their previous base explicitly (`git rebase -- **Keep the diff small:** prefer repository variables and secrets over code, and new files over edits to upstream files. Leave internal names (`@t3tools/*`, `T3CODE_*`, code identifiers) alone. -`scripts/otter/rebrand-ui-text.sh` renames client UI text; it runs only by hand, and its exclusions -list text that must keep matching what the server or agents emit. +`scripts/otter/rebrand-ui-text.sh` renames client UI text; it runs by hand during a sync, and its +exclusions list text that must keep matching what the server or agents emit. ## Identities and infrastructure diff --git a/packages/client-runtime/src/connection/errors.test.ts b/packages/client-runtime/src/connection/errors.test.ts index e3951756d6..4f36c82580 100644 --- a/packages/client-runtime/src/connection/errors.test.ts +++ b/packages/client-runtime/src/connection/errors.test.ts @@ -55,13 +55,13 @@ describe("mapManagedRelayError", () => { expect(mapConnectRefusal("managed_endpoint_allocation_not_ready")).toMatchObject({ _tag: "ConnectionTransientError", reason: "endpoint-unavailable", - detail: "The environment's Otter Connect tunnel is still being set up.", + detail: "The environment's T3 Connect tunnel is still being set up.", traceId: "trace-connect-refused", }); expect(mapConnectRefusal("endpoint_provider_not_managed")).toMatchObject({ _tag: "ConnectionBlockedError", reason: "permission", - detail: expect.stringContaining("turn Otter Connect on"), + detail: expect.stringContaining("turn T3 Connect on"), }); expect(mapConnectRefusal(undefined)).toMatchObject({ _tag: "ConnectionBlockedError", diff --git a/packages/client-runtime/src/relay/errorPresentation.ts b/packages/client-runtime/src/relay/errorPresentation.ts index e236a5a60a..575636b5be 100644 --- a/packages/client-runtime/src/relay/errorPresentation.ts +++ b/packages/client-runtime/src/relay/errorPresentation.ts @@ -41,13 +41,13 @@ export function relayProtectedErrorMessage(error: RelayProtectedError): string { // "Not authorized" covers non-auth causes too; surface the reason so a // missing link does not read as a credential problem. if (error.reason === "environment_link_not_found") { - return "Relay has no active link for this environment. If it was deregistered from Otter Connect, link it again on that machine."; + return "Relay has no active link for this environment. If it was deregistered from T3 Connect, link it again on that machine."; } if (error.reason === "managed_endpoint_allocation_not_ready") { - return "The environment's Otter Connect tunnel is still being set up."; + return "The environment's T3 Connect tunnel is still being set up."; } if (error.reason === "endpoint_provider_not_managed") { - return "This environment is linked only to publish agent activity, so it cannot accept connections. On that computer, open Settings > Connections and turn Otter Connect on."; + return "This environment is linked only to publish agent activity, so it cannot accept connections. On that computer, open Settings > Connections and turn T3 Connect on."; } return error.reason ? `Relay rejected the environment connection request (${error.reason}).` diff --git a/scripts/otterware/upstream-seams.txt b/scripts/otterware/upstream-seams.txt index 6743c672ab..bd707ed927 100644 --- a/scripts/otterware/upstream-seams.txt +++ b/scripts/otterware/upstream-seams.txt @@ -82,14 +82,3 @@ apps/web/src/components/ui/scroll-area.tsx # Mail editor keys reuse the host predicate evaluator. apps/web/src/keybindings.ts - -# Mechanical UI rebrand after the shell rebuild; no behavior changes or protocol text. -apps/desktop/src/backend/DesktopBackendPool.ts -apps/desktop/src/wsl/DesktopWslBackend.ts -apps/desktop/src/wsl/DesktopWslBackend.test.ts -apps/web/src/components/settings/ConnectionsSettings.tsx -apps/web/src/components/settings/scopedSettings.ts -apps/web/src/components/settings/scopedSettings.test.ts -apps/web/src/components/sidebar/updateEverythingRun.tsx -packages/client-runtime/src/connection/errors.test.ts -packages/client-runtime/src/relay/errorPresentation.ts