Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
b000eac
refactor(providers): adapter factories yield their services (#17381)
juliusmarminge Oct 9, 2026
5785df1
fix(web): show a row spinner instead of a banner when expanding a fol…
juliusmarminge Oct 9, 2026
8f76037
fix(server): a timed-out browser drag no longer exits the server (#17…
ScottN-PV Oct 9, 2026
563645c
fix(server): a logged-out Claude CLI no longer reports as authenticat…
yordis Oct 9, 2026
3b6af0b
fix(server): Pi loads every selected skill without losing prompt text…
StiensWout Oct 9, 2026
c908cea
fix(server): keep the Claude MCP token out of process arguments (#17408)
juliusmarminge Oct 9, 2026
7a2b1c7
fix(server): reconcile Pi native session rewinds (#13839)
StiensWout Oct 9, 2026
cb46c62
test(provider-pi): cover continuation offers through the driver (#17407)
juliusmarminge Oct 9, 2026
43f8a8d
refactor(provider-acp-registry): move the ACP Registry into its own p…
juliusmarminge Oct 9, 2026
101f8b2
fix(server): relay client updates no longer drop the host off T3 Conn…
t3dotgg Oct 9, 2026
ec80933
fix(connect): Cloudflare and other VPN addresses no longer show as Ta…
shivamhwp Oct 9, 2026
2cf0ff0
fix(web): Local environment switch stays reachable after turning it o…
ScottN-PV Oct 9, 2026
c5d4d2d
fix(web): keep chat banners inside the lane beside the docked details…
macodev00 Oct 9, 2026
a947f7c
fix(web): settled and snoozed lines line up with the messages above t…
RakshithBhat03 Oct 9, 2026
22ccf8a
fix(web): distinguish project filter from new project (#12113)
voltcrash Oct 9, 2026
b150867
feat(web): assign a thread details panel shortcut (#16694)
maria-rcks Oct 9, 2026
a669827
fix(web): chat content keeps pace with sidebar resizing (#17383)
flamboh Oct 9, 2026
2c1915b
refactor(provider-core): expose model metadata through a ModelCatalog…
juliusmarminge Oct 9, 2026
b66dd40
refactor(provider-core): follow the Effect service conventions throug…
juliusmarminge Oct 9, 2026
68ab16f
refactor(provider-core): latest-version lookups go through a Provider…
juliusmarminge Oct 9, 2026
784b562
refactor(provider-core): MCP provider sessions live in a McpProviderS…
juliusmarminge Oct 9, 2026
31b04e2
refactor(provider): bring opencode, muse, pi, core and testing in lin…
juliusmarminge Oct 9, 2026
f853d51
refactor(provider-acp): ACP, ACP Registry and Grok follow the Effect …
juliusmarminge Oct 9, 2026
33806e7
refactor(provider-cursor): follow the Effect service conventions (#17…
juliusmarminge Oct 9, 2026
6497246
fix(marketing): use app wordmark in header (#13240)
voltcrash Oct 9, 2026
aa8c666
fix(web): pr merge actions stay visible while the stack refreshes (#1…
maria-rcks Oct 9, 2026
91a6646
chore(deps): upgrade Effect to 4.0.2 (#17571)
juliusmarminge Oct 9, 2026
ecfb734
fix(devices): recover stalled video without losing simulator input (#…
juliusmarminge Oct 9, 2026
7856908
fix(web): keep checkout stable while pr actions load (#16625)
maria-rcks Oct 9, 2026
1fd558d
fix(mobile): show waiting thread status (#16693)
maria-rcks Oct 9, 2026
5722496
feat(web): add parent thread breadcrumb navigation (#16666)
maria-rcks Oct 9, 2026
0a4d789
fix(server): restart inactivity after snoozed threads wake (#16674)
maria-rcks Oct 9, 2026
454b94a
feat(desktop): passkeys in the in-app browser on macOS (#16952)
juliusmarminge Oct 9, 2026
a1db449
fix(client): load earlier turns works for MCP threads over T3 Connect…
juliusmarminge Oct 9, 2026
0e7abea
refactor(client): sign relay request URLs built from the HttpApi cont…
juliusmarminge Oct 9, 2026
28f11ed
refactor(source-control): add @t3tools/source-control-core (#17573)
juliusmarminge Oct 9, 2026
f6e4502
refactor(source-control): Forgejo lives in @t3tools/source-control-fo…
juliusmarminge Oct 9, 2026
d01febb
refactor(source-control): Azure DevOps lives in @t3tools/source-contr…
juliusmarminge Oct 9, 2026
eb459b5
refactor(source-control): GitLab lives in @t3tools/source-control-git…
juliusmarminge Oct 9, 2026
8d1858d
refactor(source-control): Bitbucket lives in @t3tools/source-control-…
juliusmarminge Oct 9, 2026
507361b
refactor(source-control): GitHub lives in @t3tools/source-control-git…
juliusmarminge Oct 9, 2026
c8e03dd
Merge remote-tracking branch 'upstream/main' into yordis/chore-sync-u…
yordis Oct 9, 2026
34c2d2d
fix(server): keep fork code compiling and RPC traces asserted after t…
yordis Oct 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
4 changes: 3 additions & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@
"electron": "44.4.5",
"electron-store": "^8.2.0",
"electron-updater": "^6.8.9",
"electron-webauthn": "catalog:",
"ffi-rs": "1.3.2",
"playwright-core": "1.60.0",
"react-grab": "^0.1.32"
"react-grab": "^0.1.32",
"tldts": "catalog:"
},
"devDependencies": {
"@effect/vitest": "catalog:",
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
import * as DesktopClerk from "./DesktopClerk.ts";
import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";
import * as PreviewPasskeys from "../preview/Passkeys.ts";
import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts";
Expand Down Expand Up @@ -276,6 +277,7 @@ const startup = Effect.gen(function* () {
const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;
const updates = yield* DesktopUpdates.DesktopUpdates;
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const previewPasskeys = yield* PreviewPasskeys.PreviewPasskeys;

yield* shellEnvironment.installIntoProcess;
const hasCommandLinePasswordStore =
Expand Down Expand Up @@ -329,6 +331,7 @@ const startup = Effect.gen(function* () {
});
}
yield* appIdentity.configure;
yield* previewPasskeys.configure;
yield* applicationMenu.configure;
yield* updates.configure;
yield* DesktopRemoteUpdates.listen;
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.
import * as BrowserSession from "./preview/BrowserSession.ts";
import * as DesktopBrowserHost from "./preview/DesktopBrowserHost.ts";
import * as PreviewManager from "./preview/Manager.ts";
import * as PreviewPasskeys from "./preview/Passkeys.ts";
import * as DesktopWindow from "./window/DesktopWindow.ts";
import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts";
import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts";
Expand Down Expand Up @@ -168,6 +169,7 @@ const layerDesktopPreview = PreviewManager.layer.pipe(
// service alongside the manager; both sit on the same BrowserSession.
Layer.provideMerge(BrowserImport.layer.pipe(Layer.provide(LinuxBrowserSecret.layer))),
Layer.provideMerge(BrowserSession.layer),
Layer.provideMerge(PreviewPasskeys.layer),
Layer.provideMerge(layerDesktopFoundation),
);

Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/preview-pick-preload.ts
Original file line number Diff line number Diff line change
@@ -1 +1,10 @@
import { ipcRenderer } from "electron";

import { PASSKEY_BRIDGE_ARGUMENT } from "./preview/GuestProtocol.ts";
import { installPasskeyBridge } from "./preview/PasskeyBridge.ts";
import "./preview/PickPreload.ts";

// Electron hands webPreferences.additionalArguments to sandboxed preloads in process.argv.
if (process.argv.includes(PASSKEY_BRIDGE_ARGUMENT)) {
installPasskeyBridge((channel, publicKey) => ipcRenderer.invoke(channel, publicKey));
}
4 changes: 4 additions & 0 deletions apps/desktop/src/preview/GuestProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,7 @@ export const RECORDING_POINTER_CHANNEL = "preview:recording-pointer";
export const RECORDING_KEY_CHANNEL = "preview:recording-key";
export const RECORDING_INPUT_CHANNEL = "preview:recording-input";
export const RECORDING_CONTROLLER_CHANNEL = "preview:recording-controller";
export const PASSKEY_CREATE_CHANNEL = "preview:passkey-create";
export const PASSKEY_GET_CHANNEL = "preview:passkey-get";
/** Renderer argument that turns on the guest passkey bridge; see Passkeys.ts. */
export const PASSKEY_BRIDGE_ARGUMENT = "--t3code-preview-passkey-bridge";
8 changes: 8 additions & 0 deletions apps/desktop/src/preview/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts";
import * as BrowserSession from "./BrowserSession.ts";
import * as DesktopBrowserHost from "./DesktopBrowserHost.ts";
import * as PreviewManager from "./Manager.ts";
import * as PreviewPasskeys from "./Passkeys.ts";

describe("fitPictureInPictureContentSize", () => {
it("preserves the PiP content area across aspect-ratio changes", () => {
Expand Down Expand Up @@ -301,6 +302,13 @@ const managerLayer = (platform: NodeJS.Platform = "darwin") =>
),
Layer.provideMerge(DesktopBrowserHost.layer),
Layer.provideMerge(layerBrowserSession),
Layer.provideMerge(
Layer.mock(PreviewPasskeys.PreviewPasskeys)({
bridgeEnabled: false,
installSessionHandlers: () => {},
attachGuest: () => () => {},
}),
),
Layer.provideMerge(layerEnvironment),
Layer.provideMerge(layerFileSystem),
Layer.provideMerge(Path.layer),
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/preview/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";
import { MENU_ACTION_CHANNEL, PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts";
import * as DesktopBrowserHost from "./DesktopBrowserHost.ts";
import * as PreviewPasskeys from "./Passkeys.ts";
import * as BrowserSession from "./BrowserSession.ts";
import {
ANNOTATION_CAPTURED_CHANNEL,
Expand Down Expand Up @@ -548,6 +549,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
const crypto = yield* Crypto.Crypto;
const parentScope = yield* Scope.Scope;
const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost;
const passkeys = yield* PreviewPasskeys.PreviewPasskeys;
const context = yield* Effect.context<never>();
const runFork = Effect.runForkWith(context);
const resolvedArtifactDirectory = path.resolve(artifactDirectory);
Expand Down Expand Up @@ -1254,6 +1256,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
) {
const scope = yield* Scope.fork(parentScope, "sequential");
const attachmentId = Symbol();
let detachPasskeys = () => {};
let documentId = 0;
let nextRequestId = 0;
let activeCapture: {
Expand Down Expand Up @@ -1589,6 +1592,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput);
wc.ipc.off(RECORDING_INPUT_CHANNEL, recordingInput);
wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);
detachPasskeys();
}).pipe(Effect.ignore),
);
const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () {
Expand All @@ -1609,6 +1613,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput);
wc.ipc.on(RECORDING_INPUT_CHANNEL, recordingInput);
wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);
detachPasskeys = passkeys.attachGuest(wc);
wc.setWindowOpenHandler((details) => {
const action = previewWindowOpenAction(details);
if (action === "popup") {
Expand Down Expand Up @@ -3686,6 +3691,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const browserSession = yield* BrowserSession.BrowserSession;
const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost;
const passkeys = yield* PreviewPasskeys.PreviewPasskeys;
const downloadSessions = new WeakSet<Electron.Session>();
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
Expand Down Expand Up @@ -3750,6 +3756,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
),
);
placeServerDownloads(session);
passkeys.installSessionHandlers(session);
return session;
},
),
Expand Down
59 changes: 59 additions & 0 deletions apps/desktop/src/preview/PasskeyAttestation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* Reads the raw `authData` bytes out of a CBOR attestation object
* (`{ fmt, attStmt, authData }`). Returns undefined for anything malformed.
* Only the definite-length encodings authenticators emit are understood.
*/
export const authenticatorDataFromAttestation = (bytes: Uint8Array) => {
let offset = 0;
const readHead = () => {
const initial = bytes[offset++];
if (initial === undefined) return undefined;
const info = initial & 31;
let length = info;
if (info >= 24) {
if (info > 27) return undefined;
const size = 1 << (info - 24);
if (offset + size > bytes.length) return undefined;
length = 0;
for (let index = 0; index < size; index++) length = length * 256 + (bytes[offset++] ?? 0);
}
return { major: initial >> 5, length };
};
const skipValue = (): boolean => {
const head = readHead();
if (!head) return false;
switch (head.major) {
case 2:
case 3:
offset += head.length;
return offset <= bytes.length;
case 4:
for (let index = 0; index < head.length; index++) if (!skipValue()) return false;
return true;
case 5:
for (let index = 0; index < head.length * 2; index++) if (!skipValue()) return false;
return true;
case 6:
return skipValue();
default:
return true;
}
};

const map = readHead();
if (map?.major !== 5) return undefined;
for (let entry = 0; entry < map.length; entry++) {
const key = readHead();
if (key?.major !== 3 || offset + key.length > bytes.length) return undefined;
const name = new TextDecoder().decode(bytes.subarray(offset, offset + key.length));
offset += key.length;
if (name !== "authData") {
if (!skipValue()) return undefined;
continue;
}
const value = readHead();
if (value?.major !== 2 || offset + value.length > bytes.length) return undefined;
return bytes.slice(offset, offset + value.length);
}
return undefined;
};
26 changes: 26 additions & 0 deletions apps/desktop/src/preview/PasskeyBackend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { PasskeyCeremonyResult } from "./PasskeyBridge.ts";

/** What the shared bridge has already checked before a backend runs a ceremony. */
export interface PasskeyCeremonyContext {
/** The committed origin of the frame that asked; never the page's own claim. */
readonly origin: string;
/** The window the system sheet attaches to. */
readonly nativeWindowHandle: Buffer;
}

/**
* One platform's way to create and use passkeys for a preview page. The
* bridge in `Passkeys.ts` owns everything platform-neutral: which frame may
* ask, focus, one ceremony at a time, the deadline, and dropping results for
* a page that navigated away. A backend only talks to the platform.
*/
export interface PasskeyBackend {
readonly create: (
publicKey: PublicKeyCredentialCreationOptions,
context: PasskeyCeremonyContext,
) => Promise<PasskeyCeremonyResult>;
readonly get: (
publicKey: PublicKeyCredentialRequestOptions,
context: PasskeyCeremonyContext,
) => Promise<PasskeyCeremonyResult>;
}
96 changes: 96 additions & 0 deletions apps/desktop/src/preview/PasskeyBackendMac.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { getPublicSuffix } from "tldts";

import type { PasskeyBackend, PasskeyCeremonyContext } from "./PasskeyBackend.ts";
import { authenticatorDataFromAttestation } from "./PasskeyAttestation.ts";
import type { PasskeyCeremonyResult } from "./PasskeyBridge.ts";

const ES256 = -7;

// WebAuthn rejects RP IDs that are public suffixes, private registries
// included, so one github.io site cannot mint passkeys for every other one.
const isPublicSuffix = (domain: string) =>
domain !== "localhost" && getPublicSuffix(domain, { allowPrivateDomains: true }) === domain;

// Loaded only once a ceremony runs: the module and its native addon ship on macOS alone.
const nativeOptions = async (context: PasskeyCeremonyContext) => ({
webauthn: await import("electron-webauthn"),
options: {
currentOrigin: context.origin,
topFrameOrigin: context.origin,
nativeWindowHandle: context.nativeWindowHandle,
isPublicSuffix,
},
});

const isBufferSource = (value: unknown): value is BufferSource =>
value instanceof ArrayBuffer || ArrayBuffer.isView(value);

const toBase64Url = (value: BufferSource) =>
Buffer.from(
ArrayBuffer.isView(value)
? new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
: new Uint8Array(value),
).toString("base64url");

/**
* macOS passkeys through AuthenticationServices (`electron-webauthn`): the
* system sheet offers iCloud Keychain, password managers, phones, and security
* keys. Needs Apple's managed browser entitlement in the signed build.
*/
export const macPasskeyBackend: PasskeyBackend = {
create: async (publicKey, context): Promise<PasskeyCeremonyResult> => {
// The native layer only converts P-256 keys and never settles for any
// other algorithm, so ES256 is the only one it may negotiate.
const params: unknown = publicKey.pubKeyCredParams;
if (params !== undefined && !Array.isArray(params))
return { success: false, error: "TypeError" };
const allowsEs256 =
params === undefined ||
params.length === 0 ||
params.some(
(param: unknown) =>
typeof param === "object" && param !== null && "alg" in param && param.alg === ES256,
);
if (!allowsEs256) return { success: false, error: "NotSupportedError" };
const { webauthn, options } = await nativeOptions(context);
const result = await webauthn.createCredential(
{ ...publicKey, pubKeyCredParams: [{ type: "public-key", alg: ES256 }] },
options,
);
if (!result.success) return { success: false, error: result.error };
// The native layer reports parsed authenticator data as JSON and claims
// every credential is a synced platform passkey; neither is reliable.
const authData = authenticatorDataFromAttestation(
Buffer.from(result.data.attestationObject, "base64url"),
);
// Sites verify the new credential from its authenticator data; without it
// the passkey is unusable, so say so now rather than let sign-up fail later.
if (!authData) return { success: false, error: "NotAllowedError" };
return {
success: true,
data: {
...result.data,
authData: Buffer.from(authData).toString("base64url"),
transports: [],
},
};
},
get: async (publicKey, context): Promise<PasskeyCeremonyResult> => {
const { webauthn, options } = await nativeOptions(context);
// WebAuthn defaults the RP ID to the caller's host; the native layer requires it.
const result = await webauthn.getCredential(
{ ...publicKey, rpId: publicKey.rpId ?? new URL(context.origin).hostname },
options,
);
if (!result.success) return { success: false, error: result.error };
// The native layer applies the site's allow list to platform passkeys only,
// so a security key can answer with a credential the site did not ask for.
const allowed = (Array.isArray(publicKey.allowCredentials) ? publicKey.allowCredentials : [])
.filter((credential) => credential?.type === "public-key" && isBufferSource(credential.id))
.map((credential) => toBase64Url(credential.id));
if (allowed.length > 0 && !allowed.includes(result.data.credentialId)) {
return { success: false, error: "NotAllowedError" };
}
return result;
},
};
Loading