Skip to content
Open
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
71 changes: 52 additions & 19 deletions bridge/src/auth/protocolHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface ProtocolHandlerOptions {
desktopId?: string;
registerDesktop?: () => Promise<() => Promise<void>>;
clearDefault?: () => Promise<void>;
configuredOwners?: () => Promise<string[] | null>;
}

function runXdgMime(args: string[]): Promise<CommandResult> {
Expand Down Expand Up @@ -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"),
])],
};
}
Expand Down Expand Up @@ -154,29 +156,48 @@ 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);

let removed = false;
return async (): Promise<void> => {
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<string[] | null> {
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);
Expand Down Expand Up @@ -219,19 +240,31 @@ export async function activateT3ProtocolHandler(
): Promise<() => Promise<void>> {
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<void> => undefined;

const currentOwner = async (): Promise<string> => {
const owners = await inspectConfiguredOwners();
return owners?.[0] ?? await queryDefault(command);
};
const restoreOwner = async (): Promise<void> => {
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",
Expand Down
Binary file modified lib/t3-mini-bridge-linux-x64.gz
Binary file not shown.
2 changes: 1 addition & 1 deletion lib/t3-mini-bridge-linux-x64.sha256
Original file line number Diff line number Diff line change
@@ -1 +1 @@
cc00a3e17f4c90ab70a01768f97288941f5370371e1fc232dc167b6daebb7378 t3-mini-bridge
16c5402d30713506e185289a5440936d45d7b89a66ab98ea4301a873587ea4bf t3-mini-bridge
88 changes: 70 additions & 18 deletions tests/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"],
Expand All @@ -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");
Expand Down