diff --git a/CHANGELOG.md b/CHANGELOG.md index b28946b..bb4f4fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ All notable user-visible changes are documented here. This project follows its complete license inventory. - Limited desktop callback registration to the active T3 Connect login window and restored the previous scheme owner before removing the hidden handler. +- Made callback ownership use explicit user MIME associations, preventing an + `xdg-utils` cache fallback from opening T3 Code instead of completing login, + while preserving handler changes made during the login window. - Made approval-required the per-task default and added an explicit warning confirmation before a new task can use broader runtime access. - Made the marketplace SEA checkout-path-independent and added a CI-enforced diff --git a/README.md b/README.md index 494fcf5..e7dfdd2 100644 --- a/README.md +++ b/README.md @@ -114,8 +114,11 @@ Clerk client token, pending callback secret, and DPoP private material in Secret Service, plus the selected environment under the user's XDG state directory. Choosing **Sign in with T3 Connect** explicitly creates a hidden desktop callback entry and temporarily claims `t3code://`; both the entry and -the prior scheme-owner change are reversed when that login window closes. -Installation requires no privilege escalation and runs no remote build. +the prior scheme-owner change are reversed when that login window closes. The +bridge does not edit T3 Code's desktop entry, and it only restores the prior +owner if the plugin still owns the scheme. A later login replaces and removes +any plugin callback entry left by an interrupted attempt. Installation requires +no privilege escalation and runs no remote build. The root [LICENSE](LICENSE), [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md), and [licenses](licenses/) inventory cover the plugin, embedded Node runtime, @@ -219,9 +222,12 @@ The full real-account procedure is in [docs/ACCEPTANCE.md](docs/ACCEPTANCE.md). ## Troubleshooting - **Browser returns to the wrong T3 application:** retry from the panel and - verify `xdg-mime` can update the user's MIME associations. The bridge creates - its hidden desktop entry only for the active login window and restores any - previous `t3code` handler afterward. + verify `xdg-mime` can update the user's MIME associations. If `xdg-mime query + default x-scheme-handler/t3code` and `gio mime x-scheme-handler/t3code` + disagree, an older `xdg-utils` may be reporting a cache fallback. The bridge + uses the explicit user association for ownership checks, creates its hidden + desktop entry only for the active login window, and restores the prior owner + afterward. - **Secret store unavailable:** verify `secret-tool lookup application io.github.digitalpals.omarchy-t3code item t3-connect-clerk-client >/dev/null` can reach an unlocked Secret Service. Do not print its value or replace it with a diff --git a/bridge/src/auth/protocolHandler.ts b/bridge/src/auth/protocolHandler.ts index 8686fcf..a03a530 100644 --- a/bridge/src/auth/protocolHandler.ts +++ b/bridge/src/auth/protocolHandler.ts @@ -23,6 +23,7 @@ export interface ProtocolHandlerOptions { desktopId?: string; registerDesktop?: () => Promise<() => Promise>; clearDefault?: () => Promise; + configuredOwners?: () => Promise; } function runXdgMime(args: string[]): Promise { @@ -90,12 +91,13 @@ function xdgRoots(environment: NodeJS.ProcessEnv = process.env): { throw new BridgeError("AUTH_CALLBACK_REGISTRATION_FAILED", "Desktop callback paths must be absolute."); } } + const dataApplications = join(data, "applications"); return { - applications: join(data, "applications"), + applications: dataApplications, mimeapps: [...new Set([ join(config, "mimeapps.list"), join(config, "applications", "mimeapps.list"), - join(data, "applications", "mimeapps.list"), + join(dataApplications, "mimeapps.list"), ])], }; } @@ -154,16 +156,6 @@ export async function installT3CallbackDesktop( const { applications } = xdgRoots(environment); const desktopPath = join(applications, CALLBACK_DESKTOP_ID); await mkdir(applications, { recursive: true, mode: 0o700 }); - let previous: Buffer | null = null; - let previousMode = 0o644; - try { - [previous, previousMode] = await Promise.all([ - readFile(desktopPath), - stat(desktopPath).then((value) => value.mode & 0o777), - ]); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - } await atomicWrite(desktopPath, desktopEntry(command), 0o644); await refreshDesktopDatabase(applications); @@ -171,12 +163,41 @@ export async function installT3CallbackDesktop( return async (): Promise => { if (removed) return; removed = true; - if (previous === null) await rm(desktopPath, { force: true }); - else await atomicWrite(desktopPath, previous, previousMode); + await rm(desktopPath, { force: true }); await refreshDesktopDatabase(applications); }; } +function configuredOwners(contents: string): string[] | null { + let defaults = false; + for (const line of contents.split(/\r?\n/u)) { + const section = line.trim(); + if (section.startsWith("[") && section.endsWith("]")) { + defaults = section === "[Default Applications]"; + continue; + } + if (!defaults) continue; + const match = line.match(/^\s*x-scheme-handler\/t3code\s*=\s*(.*)$/u); + if (!match) continue; + return (match[1] ?? "").split(";").map((owner) => owner.trim()).filter(Boolean); + } + return null; +} + +export async function configuredT3ProtocolOwners( + environment: NodeJS.ProcessEnv = process.env, +): Promise { + for (const path of xdgRoots(environment).mimeapps) { + try { + const owners = configuredOwners(await readFile(path, "utf8")); + if (owners !== null) return owners; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + return null; +} + function withoutDesktopAssociation(contents: string, desktopId: string): string { const hadFinalNewline = contents.endsWith("\n"); const lines = contents.split(/\r?\n/u); @@ -219,19 +240,31 @@ export async function activateT3ProtocolHandler( ): Promise<() => Promise> { const command = options.command ?? runXdgMime; const desktopId = options.desktopId ?? CALLBACK_DESKTOP_ID; - const removeDesktop = await (options.registerDesktop ?? installT3CallbackDesktop)(); + const inspectConfiguredOwners = options.configuredOwners ?? configuredT3ProtocolOwners; const clearDefault = options.clearDefault ?? (() => clearT3ProtocolDefault(desktopId)); let previous = ""; + let removeDesktop = async (): Promise => undefined; + + const currentOwner = async (): Promise => { + const owners = await inspectConfiguredOwners(); + return owners?.[0] ?? await queryDefault(command); + }; const restoreOwner = async (): Promise => { - if (await queryDefault(command) !== desktopId) return; + if (await currentOwner() !== desktopId) return; if (previous.length > 0 && previous !== desktopId) await setDefault(command, previous); else await clearDefault(); }; try { - previous = await queryDefault(command); - if (previous !== desktopId) await setDefault(command, desktopId); - const active = await queryDefault(command); + const configured = await inspectConfiguredOwners(); + previous = configured?.find((owner) => owner !== desktopId) ?? ""; + if (previous.length === 0) { + const queried = await queryDefault(command); + if (queried !== desktopId) previous = queried; + } + removeDesktop = await (options.registerDesktop ?? installT3CallbackDesktop)(); + await setDefault(command, desktopId); + const active = await currentOwner(); if (active !== desktopId) { throw new BridgeError( "AUTH_CALLBACK_REGISTRATION_FAILED", diff --git a/lib/t3-mini-bridge-linux-x64.gz b/lib/t3-mini-bridge-linux-x64.gz index 5926478..4021cbc 100644 Binary files a/lib/t3-mini-bridge-linux-x64.gz and b/lib/t3-mini-bridge-linux-x64.gz differ diff --git a/lib/t3-mini-bridge-linux-x64.sha256 b/lib/t3-mini-bridge-linux-x64.sha256 index b9ef21f..a9567fc 100644 --- a/lib/t3-mini-bridge-linux-x64.sha256 +++ b/lib/t3-mini-bridge-linux-x64.sha256 @@ -1 +1 @@ -cc00a3e17f4c90ab70a01768f97288941f5370371e1fc232dc167b6daebb7378 t3-mini-bridge +16c5402d30713506e185289a5440936d45d7b89a66ab98ea4301a873587ea4bf t3-mini-bridge diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 053ab9b..2bdd9ee 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -12,7 +12,9 @@ import { NativeClerkProvider } from "../bridge/src/auth/nativeProvider.ts"; import { makePkceRequest, stateMatches } from "../bridge/src/auth/pkce.ts"; import { activateT3ProtocolHandler, + CALLBACK_DESKTOP_ID, clearT3ProtocolDefault, + configuredT3ProtocolOwners, installT3CallbackDesktop, type MimeCommand, } from "../bridge/src/auth/protocolHandler.ts"; @@ -318,70 +320,97 @@ test("native callback forwarding rejects unexpected schemes and missing pending ); }); -test("T3 callback handler temporarily preserves an existing desktop owner", async () => { - let current = "t3code-nightly.desktop"; +test("T3 callback handler ignores a misleading xdg fallback and restores the explicit owner", async () => { + let configured = ["t3code-nightly.desktop"]; const calls: string[][] = []; const command: MimeCommand = async (args) => { calls.push(args); - if (args[0] === "query") return { code: 0, stdout: `${current}\n`, stderr: "" }; + if (args[0] === "query") return { code: 0, stdout: `${CALLBACK_DESKTOP_ID}\n`, stderr: "" }; assert.equal(args[0], "default"); - current = args[1] ?? ""; + configured = args[1] ? [args[1]] : []; return { code: 0, stdout: "", stderr: "" }; }; let desktopRemoved = false; const restore = await activateT3ProtocolHandler({ command, + configuredOwners: async () => configured, registerDesktop: async () => async () => { desktopRemoved = true; }, }); - assert.equal(current, "io.github.digitalpals.omarchy-t3code-callback.desktop"); + assert.deepEqual(configured, [CALLBACK_DESKTOP_ID]); await restore(); - assert.equal(current, "t3code-nightly.desktop"); + assert.deepEqual(configured, ["t3code-nightly.desktop"]); assert.equal(desktopRemoved, true); - assert(calls.some((args) => args.join(" ").includes("x-scheme-handler/t3code"))); + assert.deepEqual(calls, [ + ["default", CALLBACK_DESKTOP_ID, "x-scheme-handler/t3code"], + ["default", "t3code-nightly.desktop", "x-scheme-handler/t3code"], + ]); }); test("legacy callback ownership is restored after the login window", async () => { - let current = "io.github.omarchy-t3code-callback.desktop"; + let configured = ["io.github.omarchy-t3code-callback.desktop"]; const command: MimeCommand = async (args) => { - if (args[0] === "query") return { code: 0, stdout: `${current}\n`, stderr: "" }; - current = args[1] ?? ""; + if (args[0] === "query") return { code: 0, stdout: `${configured[0] ?? ""}\n`, stderr: "" }; + configured = args[1] ? [args[1]] : []; return { code: 0, stdout: "", stderr: "" }; }; const restore = await activateT3ProtocolHandler({ command, + configuredOwners: async () => configured, registerDesktop: async () => async () => undefined, }); - assert.equal(current, "io.github.digitalpals.omarchy-t3code-callback.desktop"); + assert.deepEqual(configured, [CALLBACK_DESKTOP_ID]); await restore(); - assert.equal(current, "io.github.omarchy-t3code-callback.desktop"); + assert.deepEqual(configured, ["io.github.omarchy-t3code-callback.desktop"]); }); test("callback handler clears a newly created default before removing its desktop entry", async () => { - let current = ""; + let configured: string[] | null = null; let cleared = false; let removed = false; const command: MimeCommand = async (args) => { - if (args[0] === "query") return { code: 0, stdout: `${current}\n`, stderr: "" }; - current = args[1] ?? ""; + if (args[0] === "query") return { code: 0, stdout: "", stderr: "" }; + configured = args[1] ? [args[1]] : []; return { code: 0, stdout: "", stderr: "" }; }; const restore = await activateT3ProtocolHandler({ command, - clearDefault: async () => { current = ""; cleared = true; }, + clearDefault: async () => { configured = null; cleared = true; }, + configuredOwners: async () => configured, registerDesktop: async () => async () => { removed = true; }, }); - assert.equal(current, "io.github.digitalpals.omarchy-t3code-callback.desktop"); + assert.deepEqual(configured, [CALLBACK_DESKTOP_ID]); await restore(); - assert.equal(current, ""); + assert.equal(configured, null); assert.equal(cleared, true); assert.equal(removed, true); }); +test("callback cleanup does not overwrite a handler changed during login", async () => { + let configured = ["t3code-nightly.desktop"]; + let removed = false; + const command: MimeCommand = async (args) => { + if (args[0] === "query") return { code: 0, stdout: `${configured[0] ?? ""}\n`, stderr: "" }; + configured = args[1] ? [args[1]] : []; + return { code: 0, stdout: "", stderr: "" }; + }; + const restore = await activateT3ProtocolHandler({ + command, + configuredOwners: async () => configured, + registerDesktop: async () => async () => { removed = true; }, + }); + configured = ["other-t3-client.desktop"]; + await restore(); + assert.deepEqual(configured, ["other-t3-client.desktop"]); + assert.equal(removed, true); +}); + test("callback desktop registration is hidden, quoted, and removed after login", async () => { const root = await mkdtemp(join(tmpdir(), "t3-callback-desktop-")); const data = join(root, "data"); const desktop = join(data, "applications", "io.github.digitalpals.omarchy-t3code-callback.desktop"); try { + await mkdir(join(data, "applications"), { recursive: true }); + await writeFile(desktop, "stale callback entry\n"); const removeDesktop = await installT3CallbackDesktop( { HOME: root, XDG_CONFIG_HOME: join(root, "config"), XDG_DATA_HOME: data }, ["/opt/T3 Mini/t3-mini-bridge"], @@ -397,6 +426,29 @@ test("callback desktop registration is hidden, quoted, and removed after login", } }); +test("configured T3 owner comes from the explicit user MIME association", async () => { + const root = await mkdtemp(join(tmpdir(), "t3-configured-owner-")); + const config = join(root, "config"); + try { + await mkdir(config, { recursive: true }); + await writeFile(join(config, "mimeapps.list"), [ + "[Default Applications]", + "x-scheme-handler/t3code=t3code-url-handler.desktop;", + "", + ].join("\n")); + assert.deepEqual( + await configuredT3ProtocolOwners({ + HOME: root, + XDG_CONFIG_HOME: config, + XDG_DATA_HOME: join(root, "data"), + }), + ["t3code-url-handler.desktop"], + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("clearing an ephemeral callback default preserves other MIME owners", async () => { const root = await mkdtemp(join(tmpdir(), "t3-callback-mimeapps-")); const config = join(root, "config");