Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions apps/desktop/src/preview/BrowserImport/ChromiumKeys.module.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import { ChildProcessSpawner } from "effect/unstable/process";
import { vi } from "vite-plus/test";

vi.mock("@napi-rs/keyring", () => {
throw new Error("Cannot find native binding");
});

it("loads browser import code without a keyring native binding", async () => {
await expect(import("./ChromiumKeys.ts")).resolves.toBeDefined();
});

it.effect("reports an unavailable keychain when the macOS binding cannot load", () =>
Effect.gen(function* () {
const { ChromiumKeyError, resolveChromiumKeys } = yield* Effect.promise(
() => import("./ChromiumKeys.ts"),
);
const error = yield* resolveChromiumKeys({
platform: "darwin",
keychainService: "Chrome Safe Storage",
keychainAccount: "Chrome",
linuxSecretApplication: undefined,
}).pipe(Effect.flip);

expect(error).toBeInstanceOf(ChromiumKeyError);
expect(error.reason).toBe("keychainUnavailable");
expect(error.cause).toBeInstanceOf(Error);
}).pipe(
Effect.provideService(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() => Effect.die("must not spawn")),
),
),
);
54 changes: 54 additions & 0 deletions apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import * as PlatformError from "effect/PlatformError";
import { ChildProcessSpawner } from "effect/unstable/process";
import { beforeEach, vi } from "vite-plus/test";

import {
ChromiumKeyError,
Expand All @@ -17,6 +18,18 @@ import {
} from "./ChromiumKeys.ts";
import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts";

const { getPassword } = vi.hoisted(() => ({ getPassword: vi.fn<() => string | null>() }));

vi.mock("@napi-rs/keyring", () => ({
Entry: class {
getPassword = getPassword;
},
}));

beforeEach(() => {
getPassword.mockReset();
});

type CapturedCommand = {
readonly command: string;
readonly args: ReadonlyArray<string>;
Expand Down Expand Up @@ -63,6 +76,47 @@ const helperLayer = (input: {
),
);

describe("macOS Chromium secrets", () => {
const request = {
platform: "darwin",
keychainService: "Chrome Safe Storage",
keychainAccount: "Chrome",
linuxSecretApplication: undefined,
} as const;
const noProcesses = Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() => Effect.die("must not spawn")),
);

it.effect("derives the cookie key from the keychain secret", () =>
Effect.gen(function* () {
getPassword.mockReturnValue("macos-secret");
const keys = yield* resolveChromiumKeys(request);
expect(keys.cbcV10?.toString("hex")).toBe("3df7306fb1eac353289565a2f6b64f74");
}).pipe(Effect.provide(noProcesses)),
);

it.effect("reports a missing keychain entry", () =>
Effect.gen(function* () {
getPassword.mockReturnValue(null);
const error = yield* resolveChromiumKeys(request).pipe(Effect.flip);
expect(error.reason).toBe("keychainItemMissing");
}).pipe(Effect.provide(noProcesses)),
);

it.effect("preserves a denied keychain approval", () =>
Effect.gen(function* () {
const denied = new Error("User denied access");
getPassword.mockImplementation(() => {
throw denied;
});
const error = yield* resolveChromiumKeys(request).pipe(Effect.flip);
expect(error.reason).toBe("needsKeychainApproval");
expect(error.cause).toBe(denied);
}).pipe(Effect.provide(noProcesses)),
);
});

describe("Linux Chromium secrets", () => {
it.effect("retains a missing helper failure alongside the keyring-free fallback", () =>
Effect.gen(function* () {
Expand Down
7 changes: 6 additions & 1 deletion apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
*
* @module ChromiumKeys
*/
import * as Keyring from "@napi-rs/keyring";
import * as NodeCrypto from "node:crypto";

import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
Expand Down Expand Up @@ -102,6 +101,12 @@ const readKeychainSecret = Effect.fn("ChromiumKeys.readKeychainSecret")(function
service: string,
account: string,
) {
// Only macOS cookie imports need this binding; loading it at startup can
// prevent the desktop from opening on platforms that never use it.
const Keyring = yield* Effect.tryPromise({
try: () => import("@napi-rs/keyring"),
catch: (cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause }),
});
const secret = yield* Effect.try({
try: () => new Keyring.Entry(service, account).getPassword(),
catch: (cause) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -924,4 +924,47 @@ describe("makeRelayDeviceRegistrationRequest", () => {
await new Promise((resolve) => setTimeout(resolve, 0));
expect(widgetMocks.start).toHaveBeenCalledTimes(1);
});
it.effect(
"does not enable notifications when a token rotates after permission is revoked",
() => {
const registrations: unknown[] = [];
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init);
if (request.url.endsWith("/v1/client/dpop-token")) {
return Response.json({
access_token: "dpop",
issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
token_type: "DPoP",
expires_in: 300,
scope: "mobile:registration",
});
}
registrations.push(await request.json());
return Response.json({ ok: true });
});
Constants.expoConfig!.extra = { relay: { url: "https://permission-relay.example.test" } };
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk"), "user-a");
return Effect.gen(function* () {
yield* runBackgroundOperations();
expect(registrations.at(-1)).toMatchObject({ preferences: { notificationsEnabled: true } });
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce({
granted: false,
} as Awaited<ReturnType<typeof Notifications.getPermissionsAsync>>);
const listener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)![0];
listener({ type: "ios", data: "rotated" });
yield* runBackgroundOperations();
expect(registrations.at(-1)).toMatchObject({
preferences: { notificationsEnabled: false },
});
expect(registrations.at(-1)).not.toHaveProperty("pushToken");
}).pipe(
Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch),
Effect.provide(
managedRelayClientLayer("https://permission-relay.example.test").pipe(
Layer.provide(Layer.mergeAll(FetchHttpClient.layer, cryptoLayer)),
),
),
);
},
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -245,9 +245,6 @@ function nativePushTokenRegistration(observedPushToken?: string) {
if (!canRegisterRemoteLiveActivities() || !supportsAgentAwarenessPush()) {
return { notificationsEnabled: false, pushToken: null };
}
if (observedPushToken) {
return { notificationsEnabled: true, pushToken: observedPushToken };
}
const permissions = yield* Effect.tryPromise({
try: () => Notifications.getPermissionsAsync(),
catch: (cause) =>
Expand All @@ -259,6 +256,9 @@ function nativePushTokenRegistration(observedPushToken?: string) {
if (!permissions.granted) {
return { notificationsEnabled: false, pushToken: null };
}
if (observedPushToken) {
return { notificationsEnabled: true, pushToken: observedPushToken };
}
const token = yield* Effect.tryPromise({
try: () => Notifications.getDevicePushTokenAsync(),
catch: (cause) =>
Expand Down
23 changes: 23 additions & 0 deletions apps/mobile/src/lib/http-response.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vite-plus/test";
import { Cookies, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";

describe("React Native HTTP responses", () => {
it("can inspect a rejected response when native Headers has no getSetCookie", () => {
const response = new Response("Registration rejected", { status: 400 });
Object.defineProperty(response.headers, "getSetCookie", { value: undefined });
const result = HttpClientResponse.fromWeb(
HttpClientRequest.post("https://relay.example.test/v1/mobile/devices"),
response,
);
expect(result.cookies).toEqual(Cookies.empty);
expect(result.status).toBe(400);
});

it("preserves cookies on platforms that expose Set-Cookie headers", () => {
const result = HttpClientResponse.fromWeb(
HttpClientRequest.get("https://relay.example.test"),
new Response(null, { headers: { "Set-Cookie": "session=abc; HttpOnly" } }),
);
expect(result.cookies).toEqual(Cookies.fromSetCookie(["session=abc; HttpOnly"]));
});
});
1 change: 1 addition & 0 deletions apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabili
const CLAUDE_PRESENTATION = {
displayName: "Claude",
showInteractionModeToggle: true,
reportsContextWindow: true,
} as const;
function toTitleCaseWords(value: string): string {
const parts: Array<string> = [];
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/provider/Layers/CodexProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const;
const CODEX_PRESENTATION = {
displayName: "Codex",
showInteractionModeToggle: true,
reportsContextWindow: true,
} as const;

export interface CodexAppServerProviderSnapshot {
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/provider/providerSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export interface ServerProviderPresentation {
readonly displayName: string;
readonly badgeLabel?: string;
readonly showInteractionModeToggle?: boolean;
readonly reportsContextWindow?: boolean;
readonly requiresNewThreadForModelChange?: boolean;
}

Expand Down Expand Up @@ -212,6 +213,9 @@ export function buildServerProvider(input: {
...(typeof input.presentation.showInteractionModeToggle === "boolean"
? { showInteractionModeToggle: input.presentation.showInteractionModeToggle }
: {}),
...(typeof input.presentation.reportsContextWindow === "boolean"
? { reportsContextWindow: input.presentation.reportsContextWindow }
: {}),
...(typeof input.presentation.requiresNewThreadForModelChange === "boolean"
? { requiresNewThreadForModelChange: input.presentation.requiresNewThreadForModelChange }
: {}),
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/BranchToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat

import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities";
import { useProject, useThreadShell, useThreadShellsForProjectRefs } from "../state/entities";
import {
type EnvMode,
type EnvironmentOption,
Expand Down Expand Up @@ -465,7 +465,7 @@ export const BranchToolbar = memo(function BranchToolbar({
const draftThread = useComposerDraftStore((store) =>
draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef),
);
const serverThread = useThread(threadRef, { waitForShell: draftThread !== null });
const serverThread = useThreadShell(threadRef);
const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
const activeProjectRef = serverThread
? scopeProjectRef(serverThread.environmentId, serverThread.projectId)
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/BranchToolbarBranchSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { readLocalApi } from "../localApi";
import { useOpenPrLink } from "../lib/openPullRequestLink";
import { shouldLoadNextBranchPageAfterScroll } from "../state/paginatedBranches";
import { usePaginatedBranches } from "../state/queries";
import { useProject, useThread } from "../state/entities";
import { useProject, useThreadShell } from "../state/entities";
import { useEnvironmentQuery } from "../state/query";
import { threadEnvironment } from "../state/threads";
import { useAtomCommand } from "../state/use-atom-command";
Expand Down Expand Up @@ -119,7 +119,7 @@ export function BranchToolbarBranchSelector({
const draftThread = useComposerDraftStore((store) =>
draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef),
);
const serverThread = useThread(threadRef, { waitForShell: draftThread !== null });
const serverThread = useThreadShell(threadRef);
const serverSession = serverThread?.session ?? null;
const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);

Expand Down
62 changes: 62 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import {
isBranchMismatchDismissedForSession,
reconcileMountedTerminalThreadIds,
reconcileRetainedMountedThreadIds,
recallCheckoutIsRepo,
rememberCheckoutIsRepo,
resolveBackgroundDraftWorkspaceOptions,
resolveComposerInteractionMode,
resolveComposerProviderSelection,
Expand All @@ -55,6 +57,7 @@ import {
resolveProactiveTurnDiffAction,
resolveThreadMetadataUpdateForNextTurn,
resolveSendEnvMode,
threadShellHasStarted,
resolveDraftHeroState,
scheduleEnvironmentReconnectWarning,
startNewThreadForProject,
Expand Down Expand Up @@ -1958,3 +1961,62 @@ describe("shouldRefocusComposerOnWindowFocus", () => {
expect(shouldRefocusComposerOnWindowFocus(element("BUTTON", { within: "-popup" }))).toBe(false);
});
});

describe("checkout Git memory", () => {
it("answers from the last status seen for the same checkout", () => {
rememberCheckoutIsRepo(environmentId, "/repo/plain-folder", false);
expect(recallCheckoutIsRepo(environmentId, "/repo/plain-folder")).toBe(false);
rememberCheckoutIsRepo(environmentId, "/repo/plain-folder", true);
expect(recallCheckoutIsRepo(environmentId, "/repo/plain-folder")).toBe(true);
});

it("does not answer for a checkout it has not seen", () => {
expect(recallCheckoutIsRepo(environmentId, "/repo/never-opened")).toBeUndefined();
expect(recallCheckoutIsRepo(environmentId, null)).toBeUndefined();
});

it("keeps environments apart", () => {
rememberCheckoutIsRepo(environmentId, "/repo/shared-path", false);
expect(
recallCheckoutIsRepo(EnvironmentId.make("env-other"), "/repo/shared-path"),
).toBeUndefined();
});

it("does not confuse an environment id containing the separator with a path", () => {
rememberCheckoutIsRepo(EnvironmentId.make("env"), "a:b", false);
expect(recallCheckoutIsRepo(EnvironmentId.make("env:a"), "b")).toBeUndefined();
});
});

describe("threadShellHasStarted", () => {
it("counts a thread that has a user message but no latest turn", () => {
expect(
threadShellHasStarted({ latestTurn: null, latestUserMessageAt: now, session: null }),
).toBe(true);
});

it("counts a thread with a live session and nothing else", () => {
expect(
threadShellHasStarted({
latestTurn: null,
latestUserMessageAt: null,
session: {
threadId,
status: "starting",
providerName: "codex",
runtimeMode: "full-access",
activeTurnId: null,
lastError: null,
updatedAt: now,
},
}),
).toBe(true);
});

it("does not count a thread that never sent anything", () => {
expect(
threadShellHasStarted({ latestTurn: null, latestUserMessageAt: null, session: null }),
).toBe(false);
expect(threadShellHasStarted(null)).toBe(false);
});
});
Loading
Loading