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
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@onkernel/cua-cli",
"version": "0.3.0",
"version": "0.3.1",
"description": "Kernel-cloud-browser computer-use TUI built on @onkernel/cua-agent and pi-tui",
"license": "MIT",
"type": "module",
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/cli-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ export interface HarnessCliFlags {
model?: string;
thinking?: string;
browserProfile?: string;
browserProxy?: string;
browserTimeout?: number;
maxSteps?: number;
out?: string;
Expand Down Expand Up @@ -257,6 +258,7 @@ export async function provisionForFlags(flags: HarnessCliFlags, auth: KernelAuth
timeoutSeconds: flags.browserTimeout,
profileSelector: flags.browserProfile,
saveChanges: flags.profileSaveChanges,
proxySelector: flags.browserProxy,
});
if (flags.verbose) {
stderr.write(`[cua] browser session=${handle.browser.session_id}\n`);
Expand Down Expand Up @@ -668,6 +670,7 @@ export async function runSessionSubcommand(args: string[], flags: HarnessCliFlag
browserTimeoutSeconds: flags.browserTimeout,
profileSelector: flags.browserProfile,
saveProfileChanges: flags.profileSaveChanges,
proxySelector: flags.browserProxy,
model: flags.model ? resolveCuaModelRef(flags.model) : undefined,
});
stdout.write(`name=${meta.name}\n`);
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ Options:
--thinking <level> Thinking level: off | minimal | low | medium | high | xhigh
(default: low; applies to providers that support it)
--profile <name|id> Kernel browser profile to load
--proxy <name|id> Kernel proxy to route the browser through
(must already exist; never auto-created)
--profile-no-save-changes Do not persist changes back to the profile
--browser-timeout <s> Browser inactivity timeout in seconds (default 300)
--max-steps <n> Max turns for action subcommands (default 3)
Expand Down Expand Up @@ -130,6 +132,7 @@ interface CliFlags {
model?: string;
thinking?: string;
browserProfile?: string;
browserProxy?: string;
browserTimeout?: number;
maxSteps?: number;
out?: string;
Expand Down Expand Up @@ -159,6 +162,7 @@ function parseCliArgs(argv: string[]): CliFlags {
model: { type: "string", short: "m" },
thinking: { type: "string" },
profile: { type: "string" },
proxy: { type: "string" },
"profile-no-save-changes": { type: "boolean", default: false },
"browser-timeout": { type: "string" },
"max-steps": { type: "string" },
Expand Down Expand Up @@ -223,6 +227,7 @@ function parseCliArgs(argv: string[]): CliFlags {
model: parsed.values.model as string | undefined,
thinking: parsed.values.thinking as string | undefined,
browserProfile: parsed.values.profile as string | undefined,
browserProxy: parsed.values.proxy as string | undefined,
browserTimeout: Number.isFinite(browserTimeout) ? browserTimeout : undefined,
maxSteps: Number.isFinite(maxSteps) ? maxSteps : undefined,
out: parsed.values.out as string | undefined,
Expand Down Expand Up @@ -259,6 +264,7 @@ function toHarnessFlags(flags: CliFlags): HarnessCliFlags {
model: flags.model,
thinking: flags.thinking,
browserProfile: flags.browserProfile,
browserProxy: flags.browserProxy,
browserTimeout: flags.browserTimeout,
maxSteps: flags.maxSteps,
out: flags.out,
Expand Down
28 changes: 28 additions & 0 deletions packages/cli/src/harness-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export interface ProvisionBrowserOptions {
profileId?: string;
/** Persist changes back to the profile when the session ends. Defaults to false. */
saveChanges?: boolean;
/** Proxy id or name. Must already exist; never auto-created. */
proxySelector?: string;
}

const CUID2_LENGTH = 24;
Expand Down Expand Up @@ -53,6 +55,29 @@ export async function resolveProfileId(client: Kernel, selector: string): Promis
}
}

/**
* Resolve a proxy selector (id or name) to a proxy id. Unlike profiles,
* proxies are never auto-created — creating one requires type/location/
* credential configuration that does not fit a bare name.
*/
export async function resolveProxyId(client: Kernel, selector: string): Promise<string> {
const trimmed = selector.trim();
if (!trimmed) throw new Error("proxy selector is empty");
try {
const existing = await client.proxies.retrieve(trimmed);
if (existing.id) return existing.id;
} catch (err) {
if (!(err instanceof NotFoundError)) {
throw new Error(`looking up proxy "${trimmed}": ${(err as Error).message}`, { cause: err });
}
}
const proxies = await client.proxies.list();
const matches = proxies.filter((proxy) => proxy.name === trimmed && proxy.id);
if (matches.length === 1) return matches[0]!.id!;
if (matches.length > 1) throw new Error(`proxy name "${trimmed}" is ambiguous (${matches.length} proxies); pass the proxy id`);
throw new Error(`proxy "${trimmed}" was not found; create one first (e.g. kernel proxies create)`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Retrieve success falls through names

Medium Severity

In resolveProxyId, when proxies.retrieve succeeds but the response has no truthy id, resolution continues as if the lookup failed and tries a name match via proxies.list(). That can route the browser through the wrong proxy or report not found even though retrieve succeeded.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8d6d83b. Configure here.

}

/** Create a Kernel SDK client with the supplied auth. */
export function createKernelClient(apiKey: string, baseUrl?: string): Kernel {
return new Kernel({ apiKey, ...(baseUrl ? { baseURL: baseUrl } : {}) });
Expand All @@ -75,6 +100,9 @@ export async function provisionBrowser(opts: ProvisionBrowserOptions): Promise<C
if (profileId) {
params.profile = { id: profileId, save_changes: opts.saveChanges ?? false };
}
if (opts.proxySelector && opts.proxySelector.trim()) {
params.proxy_id = await resolveProxyId(client, opts.proxySelector);
}

const browser = await client.browsers.create(params);
return {
Expand Down
11 changes: 10 additions & 1 deletion packages/cli/src/harness-named-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Kernel from "@onkernel/sdk";
import { mkdir, readdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { createKernelClient, resolveProfileId } from "./harness-browser";
import { createKernelClient, resolveProfileId, resolveProxyId } from "./harness-browser";

/**
* Named sessions: durable, slug-keyed pointers to a Kernel cloud browser
Expand All @@ -18,6 +18,7 @@ export interface NamedSessionMetadata {
kernel_session_id: string;
live_url?: string;
profile_id?: string;
proxy_id?: string;
transcript_path?: string;
/** Model ref last used with this session; chained invocations without -m default to it. */
model?: string;
Expand Down Expand Up @@ -129,6 +130,8 @@ export interface StartNamedSessionOptions {
/** Profile id or name (created if missing). Same semantics as `--profile`. */
profileSelector?: string;
saveProfileChanges?: boolean;
/** Proxy id or name (must already exist). Same semantics as `--proxy`. */
proxySelector?: string;
/** Canonical model ref to seed the session with (same semantics as `-m`). */
model?: string;
}
Expand Down Expand Up @@ -163,13 +166,19 @@ export async function startNamedSession(opts: StartNamedSessionOptions): Promise
if (profileId) {
params.profile = { id: profileId, save_changes: opts.saveProfileChanges ?? false };
}
let proxyId: string | undefined;
if (opts.proxySelector && opts.proxySelector.trim()) {
proxyId = await resolveProxyId(client, opts.proxySelector);
params.proxy_id = proxyId;
}
const browser = await client.browsers.create(params);

const meta: NamedSessionMetadata = {
name: opts.name,
kernel_session_id: browser.session_id,
live_url: browser.browser_live_view_url,
profile_id: profileId,
proxy_id: proxyId,
model: opts.model,
created_at: Date.now(),
};
Expand Down
46 changes: 46 additions & 0 deletions packages/cli/test/harness-browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import Kernel, { NotFoundError } from "@onkernel/sdk";
import { describe, expect, it } from "vitest";
import { resolveProxyId } from "../src/harness-browser";

const notFound = () => new NotFoundError(404, { message: "not found" }, "not found", new Headers());

function fakeClient(overrides: {
retrieve?: (id: string) => Promise<{ id?: string }>;
list?: () => Promise<Array<{ id?: string; name?: string }>>;
}): Kernel {
return {
proxies: {
retrieve: overrides.retrieve ?? (async () => Promise.reject(notFound())),
list: overrides.list ?? (async () => []),
},
} as unknown as Kernel;
}

describe("resolveProxyId", () => {
it("returns the id when the selector is an existing proxy id", async () => {
const client = fakeClient({ retrieve: async (id) => ({ id }) });
await expect(resolveProxyId(client, "proxy_abc")).resolves.toBe("proxy_abc");
});

it("falls back to a unique name match from the proxy list", async () => {
const client = fakeClient({ list: async () => [{ id: "proxy_1", name: "residential-us" }, { id: "proxy_2", name: "other" }] });
await expect(resolveProxyId(client, "residential-us")).resolves.toBe("proxy_1");
});

it("rejects an ambiguous name instead of guessing", async () => {
const client = fakeClient({ list: async () => [{ id: "proxy_1", name: "us" }, { id: "proxy_2", name: "us" }] });
await expect(resolveProxyId(client, "us")).rejects.toThrow(/ambiguous/);
});

it("never auto-creates: an unknown selector is an error", async () => {
const client = fakeClient({});
await expect(resolveProxyId(client, "does-not-exist")).rejects.toThrow(/was not found/);
});

it("propagates non-404 lookup failures", async () => {
const client = fakeClient({
retrieve: async () => Promise.reject(new Error("boom")),
});
await expect(resolveProxyId(client, "proxy_abc")).rejects.toThrow(/looking up proxy/);
});
});
5 changes: 5 additions & 0 deletions skills/cua-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ Useful flags:
a bare model id that matches exactly one entry.
- `--max-steps <n>` — bound the agent loop on `cua do` (default 3).
- `--filter interactive` — restrict `cua snapshot` to interactive elements.
- `--proxy <proxy-id-or-name>` — route the browser through a Kernel proxy.
The proxy must already exist (create one via the Kernel API/CLI first);
unlike `--profile`, an unknown name is an error, never auto-created. For
named sessions pass it to `session start`; later `-s` calls attach to the
same browser and inherit it.
- `--profile <profile-id-or-name>` — load a Kernel browser profile for cookies /
storage. Existing ids or names are reused; a non-id name is created if it
does not exist. Use this whenever logged-in state or other persisted browser
Expand Down
Loading