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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ chmod +x T4-Code-0.1.30-linux-x86_64.AppImage
- **Sessions.** Browse sessions grouped by their working folder, create new ones, and switch between them. Rename, terminate a stuck runtime, archive, restore, or permanently delete a session from its menu. Recently used sessions stay warm, so switching back is instant and nothing is replayed twice.
- **Composer.** Send prompts, use slash commands (`/model`, `/compact`, `/retry`, `/review`, `/terminal`, and more), and change the session's model, thinking level, or fast mode inline.
- **Panes.** Watch subagents (and cancel them), apply reviews, browse and preview files on the host, and attach to live terminals with real keyboard input and resize.
- **Browser preview.** Open session-linked browser previews to inspect page layouts, follow live navigations, and interact with the page via coordinate-mapped clicks and keyboard input. Previews use pluggable authority gates, lease-based concurrency locks, and strict opt-in security boundaries.
- **Browser (desktop).** The built-in native Browser workspace is separate from host-backed Browser Preview. It manages stable native surfaces with their own URL, title, lifecycle, bounds, and visibility state. New tabs use the credential-isolated `isolated-session` profile. An authenticated profile is never auto-selected: use requires the exact profile explicitly chosen by the user with opt-in. Browser automation is limited to the native surface contract; touch input currently reports unsupported.
- **Browser preview.** Open session-linked host previews to inspect page layouts, follow live navigations, and interact with the page via coordinate-mapped clicks and keyboard input. Preview control remains subject to the host's advertised authority and capability gates.
- **Settings.** Edit host settings over the wire, with an explicit host selector when several hosts are connected; each host keeps its own drafts. Edits stage locally and only apply when the host confirms; a dropped connection never silently writes anything.
- **Hosts & usage.** Run one local appserver per OMP profile, pair remote machines, and read each connected host's account usage and broker status. Everything shown is redacted host truth.
- **Keyboard.** `Ctrl/Cmd+K` search, `Ctrl/Cmd+B` sidebar, `Ctrl/Cmd+1..9` session switch, `Ctrl/Cmd+,` settings. Every workflow is keyboard-operable.
Expand Down
140 changes: 127 additions & 13 deletions apps/desktop/scripts/start-electron.mjs
Original file line number Diff line number Diff line change
@@ -1,17 +1,131 @@
import { spawn } from "node:child_process";
import { createRequire } from "node:module";
import { join } from "node:path";
import { pathToFileURL } from "node:url";

const require = createRequire(import.meta.url);
const electron = process.env.ELECTRON_BIN ?? require("electron");
const cwd = join(import.meta.dirname, "..");
const child = spawn(electron, [join(cwd, "dist-electron", "main.cjs")], {
cwd,
env: { ...process.env, ELECTRON_RUN_AS_NODE: "0" },
stdio: "inherit",
shell: false,
});
child.on("exit", (code, signal) => {
if (signal !== null) process.kill(process.pid, signal);
else process.exit(code ?? 1);
});
const rendererStartupTimeoutMs = 30_000;
const rendererPollIntervalMs = 100;

export function sanitizeEnvironment(environment = process.env) {
const sanitized = { ...environment };
delete sanitized.ELECTRON_RUN_AS_NODE;
return sanitized;
}

export function validateLoopbackRendererUrl(value) {
if (value === undefined) return undefined;

let url;
try {
url = new URL(value);
} catch {
throw new Error("OMP_DESKTOP_RENDERER_URL must be a loopback HTTP URL");
}

const loopback = url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1" || url.hostname === "[::1]";
if ((url.protocol !== "http:" && url.protocol !== "https:") || !loopback) {
throw new Error("OMP_DESKTOP_RENDERER_URL must be a loopback HTTP URL");
}

return url;
}

export async function waitForRenderer(value, {
fetchImpl = globalThis.fetch,
now = Date.now,
sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
timeoutMs = rendererStartupTimeoutMs,
intervalMs = rendererPollIntervalMs,
} = {}) {
const url = validateLoopbackRendererUrl(value);
if (url === undefined) return;

const deadline = now() + timeoutMs;
while (true) {
try {
const response = await fetchImpl(url, { method: "HEAD" });
if (response.ok) return;
} catch {
// The renderer process is still starting.
}

const remaining = deadline - now();
if (remaining <= 0) break;
await sleep(Math.min(intervalMs, remaining));
}

throw new Error(`Renderer did not become ready at ${url.origin}`);
}

export function startElectron({
cwd = join(import.meta.dirname, ".."),
electron,
environment = process.env,
spawnProcess = spawn,
processRef = process,
} = {}) {
const require = createRequire(import.meta.url);
const executable = electron ?? environment.ELECTRON_BIN ?? require("electron");
const child = spawnProcess(executable, [join(cwd, "dist-electron", "main.cjs")], {
cwd,
env: sanitizeEnvironment(environment),
stdio: "inherit",
shell: false,
});

return new Promise((resolve, reject) => {
let settled = false;
let forwarded = false;

const terminate = (signal) => {
if (!settled && !child.killed) child.kill(signal);
};
const forwardSignal = (signal) => {
if (forwarded) return;
forwarded = true;
terminate(signal);
};
const onSigint = () => forwardSignal("SIGINT");
const onSigterm = () => forwardSignal("SIGTERM");
const onProcessExit = () => terminate("SIGTERM");
const cleanup = () => {
processRef.removeListener("SIGINT", onSigint);
processRef.removeListener("SIGTERM", onSigterm);
processRef.removeListener("exit", onProcessExit);
};
const settle = (callback) => {
if (settled) return;
settled = true;
cleanup();
callback();
};

processRef.once("SIGINT", onSigint);
processRef.once("SIGTERM", onSigterm);
processRef.once("exit", onProcessExit);
child.once("error", (error) => settle(() => reject(error)));
child.once("exit", (code, signal) => settle(() => resolve({ code, signal })));
});
}

export async function main(options = {}) {
const environment = sanitizeEnvironment(options.environment);
await waitForRenderer(environment.OMP_DESKTOP_RENDERER_URL, options);
return startElectron({ ...options, environment });
}

function isExecutedDirectly() {
return process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
}

if (isExecutedDirectly()) {
main().then(
({ code }) => {
process.exitCode = code ?? 1;
},
(error) => {
console.error(error);
process.exitCode = 1;
},
);
}
163 changes: 163 additions & 0 deletions apps/desktop/src/browser-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import type { AuthInfo, WebContents } from "electron";

type BrowserCancelableEvent = { readonly preventDefault: () => void };

const MAX_QUEUE = 32;
const MAX_TEXT_BYTES = 512;
const MAX_URL_BYTES = 8_192;
const DEFAULT_TIMEOUT_MS = 30_000;

export interface BrowserAuthChallenge {
readonly url: string;
readonly host: string;
readonly port: number;
readonly realm: string;
readonly scheme: string;
readonly isProxy: boolean;
readonly retry: boolean;
}

export interface BrowserAuthCredentials {
readonly username: string;
readonly password: string;
}

export interface BrowserAuthControllerOptions {
readonly resolve: (challenge: BrowserAuthChallenge) => Promise<BrowserAuthCredentials | null | undefined>;
readonly maxQueue?: number;
readonly timeoutMs?: number;
}

export interface BrowserAuthController {
readonly handleLogin: BrowserAuthLoginHandler;
clear(): void;
dispose(): void;
}

export type BrowserAuthLoginHandler = (
event: BrowserCancelableEvent,
webContents: WebContents,
details: { readonly url: string },
authInfo: AuthInfo,
callback: (username?: string, password?: string) => void,
) => void;

type Pending = {
readonly challenge: BrowserAuthChallenge;
readonly callback: (username?: string, password?: string) => void;
};

function bytes(value: string): number {
return new TextEncoder().encode(value).byteLength;
}

function boundedText(value: string, max = MAX_TEXT_BYTES): string | null {
return typeof value === "string" && value.length > 0 && bytes(value) <= max ? value : null;
}

function challengeFrom(details: { readonly url: string }, info: AuthInfo): BrowserAuthChallenge | null {
const url = boundedText(details.url, MAX_URL_BYTES);
const host = boundedText(info.host);
const realm = typeof info.realm === "string" && bytes(info.realm) <= MAX_TEXT_BYTES ? info.realm : "";
const scheme = boundedText(info.scheme, 64);
if (!url || !host || !scheme || !Number.isInteger(info.port) || info.port < 0 || info.port > 65_535) return null;
return {
url,
host: host.toLowerCase(),
port: info.port,
realm,
scheme: scheme.toLowerCase(),
isProxy: info.isProxy === true,
retry: false,
};
}

function safeCallback(callback: (username?: string, password?: string) => void, credentials?: BrowserAuthCredentials): void {
try {
if (!credentials) {
callback();
return;
}
const username = boundedText(credentials.username);
const password = boundedText(credentials.password);
if (!username || !password) callback();
else callback(username, password);
} catch {
try { callback(); } catch { /* Electron callbacks can be invalid after teardown. */ }
}
}

/**
* Serializes HTTP Basic challenges so a renderer cannot create an unbounded set
* of credential prompts. The resolver is the only component that sees secrets;
* this module never logs, emits, or persists credentials.
*/
export function createBrowserAuthController(options: BrowserAuthControllerOptions): BrowserAuthController {
const requestedQueue = Number.isFinite(options.maxQueue) ? Math.trunc(options.maxQueue as number) : MAX_QUEUE;
const requestedTimeout = Number.isFinite(options.timeoutMs) ? Math.trunc(options.timeoutMs as number) : DEFAULT_TIMEOUT_MS;
const maxQueue = Math.max(1, Math.min(MAX_QUEUE, requestedQueue));
const timeoutMs = Math.max(1_000, Math.min(120_000, requestedTimeout));
const queue: Pending[] = [];
const seen = new Set<string>();
let running = false;
let disposed = false;

const drain = (): void => {
if (running || disposed) return;
const pending = queue.shift();
if (!pending) return;
running = true;
let settled = false;
const finish = (credentials?: BrowserAuthCredentials | null): void => {
if (settled) return;
settled = true;
safeCallback(pending.callback, disposed ? undefined : credentials ?? undefined);
running = false;
drain();
};
const timer = setTimeout(() => finish(), timeoutMs);
void Promise.resolve()
.then(() => options.resolve(pending.challenge))
.then((credentials) => {
clearTimeout(timer);
finish(credentials);
}, () => {
clearTimeout(timer);
finish();
});
};

const handleLogin: BrowserAuthLoginHandler = (event, _webContents, details, authInfo, callback) => {
event.preventDefault();
if (disposed) {
safeCallback(callback);
return;
}
const challenge = challengeFrom(details, authInfo);
if (!challenge || queue.length >= maxQueue || (running && queue.length >= maxQueue - 1)) {
safeCallback(callback);
return;
}
const key = `${challenge.isProxy ? "proxy" : "server"}|${challenge.host}|${challenge.port}|${challenge.realm}|${challenge.scheme}`;
const retry = seen.has(key);
if (seen.size < MAX_QUEUE * 2) seen.add(key);
queue.push({ challenge: { ...challenge, retry }, callback });
drain();
};
return {
handleLogin,
clear(): void {
while (queue.length) safeCallback(queue.shift()!.callback);
seen.clear();
},
dispose(): void {
if (disposed) return;
disposed = true;
while (queue.length) safeCallback(queue.shift()!.callback);
seen.clear();
},
};
}


export const createBrowserBasicAuthController = createBrowserAuthController;
Loading
Loading