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
146 changes: 146 additions & 0 deletions devlog/_plan/260926_kiro_lb_parity2/060_native_device_login.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,12 @@ confirmed suspension refusals can rotate before output. Monthly exhaustion exclu
that login until reset or evidence expiry; completed service clears an older verdict.
Reactive rotation remains available when proactive account preference is off. Accounts are added one at a time —
`ocx account login kiro` hands off to the Kiro CLI and appends the new account to the pool.
To add an account without the Kiro CLI, use `ocx account login kiro --method builder-id`,
`--method google`, or `--method github`. Open the printed verification URL, enter the user
code, and wait for approval. `--no-wait` prints the flow ID; cancel it with
`ocx account cancel kiro --flow <flow-id>`. Native login only adds accounts. To reauthenticate
one, remove it and add it again. A repeated social profile ARN creates another slot and prints
`duplicate_profile_arn`; the slots each carry their own quota and load state.
Kiro can opt into proactive `least-loaded` placement with `pool.kernel` and account preference enabled.
Proactive model preference also requires that account preference be explicitly enabled globally
or for Kiro; an unset or false setting leaves a healthy active account in place. Model lists are
Expand Down
4 changes: 4 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,7 @@
"clearable-deadline.test.ts": "lib",
"cli-account-alias-target.test.ts": "cli",
"cli-account-cancel-flow.test.ts": "cli",
"cli-account-kiro-device.test.ts": "cli",
"cli-account-orca-import.test.ts": "cli",
"cli-account-pin-drain.test.ts": "cli",
"cli-account-pool-verbs.test.ts": "cli",
Expand Down Expand Up @@ -1078,6 +1079,8 @@
"kiro-auth-context-continuation.test.ts": "providers/kiro",
"kiro-builder-id-profile.test.ts": "providers/kiro",
"kiro-calibration.test.ts": "providers/kiro",
"kiro-device-builder.test.ts": "providers/kiro",
"kiro-device-social.test.ts": "providers/kiro",
"kiro-fallback-error-body.test.ts": "providers/kiro",
"kiro-images.test.ts": "providers/kiro",
"kiro-leased-responses.test.ts": "providers/kiro",
Expand Down Expand Up @@ -1664,6 +1667,7 @@
"server-key-failover-e2e.test.ts": "server",
"server-jev-combo-e2e.test.ts": "server",
"server-kiro-completion-e2e.test.ts": "server",
"server-kiro-device-login.test.ts": "server",
"server-kiro-oauth-401-replay.test.ts": "server",
"server-kiro-refusal-e2e.test.ts": "server",
"server-live-frame-log.test.ts": "server",
Expand Down
23 changes: 21 additions & 2 deletions skills/ocx/references/01_management_surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,25 @@ JSON mode: `payload`.
- `show` (the default) reads settings; `set key=value ...` updates selected settings; `reset` restores defaults.
- Values accepted by `set` are parsed as JSON when valid, so booleans, numbers, arrays, objects, and null can be passed directly.

### `ocx account login`

Log in to an OAuth provider; Kiro can add a native device account.

| Method | Route |
|---|---|
| POST | `/api/oauth/login` |
| GET | `/api/oauth/status` |

| Flag | Value | Meaning |
|---|---|---|
| `--method` | string | For Kiro: builder-id, google, or github device login (add only). |
| `--reauth` | boolean | Reauthenticate a selected existing account. |
| `--id` | string | Account id for reauthentication. |
| `--no-wait` | boolean | Return after the login flow starts. |
| `--json` | boolean | Emit flow state as JSON. |

JSON mode: `payload`.

### `ocx account main reauth`

Reauthenticate the native main Codex login with a device code (#3898); headless hubs need no Codex App or keyring.
Expand Down Expand Up @@ -1139,6 +1158,6 @@ JSON mode: `payload`.

## Counts

- declared capabilities: 64
- of those, state-changing: 34
- declared capabilities: 65
- of those, state-changing: 35
- head-resolved invocations: 2
51 changes: 49 additions & 2 deletions src/cli/account-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function writeStdoutFully(text: string): void {
}

const USAGE = `Usage:
ocx account login <provider> [--id <account-id>] [--reauth] [--device] [--code -] [--no-wait] [--json]
ocx account login <provider> [--id <account-id>] [--reauth] [--device] [--method builder-id|google|github] [--code -] [--no-wait] [--json]
ocx account code <provider> [--flow <flow-id>] [--json] (reads the code from stdin)
ocx account cancel <provider> [--flow <flow-id>] [--json] (--flow required for codex)
ocx account reset-credits <account-id|main> [--consume --yes [--operation-id <uuid>]] [--json]
Expand Down Expand Up @@ -72,6 +72,13 @@ interface LoginStart {
deviceCode?: string;
/** Whether the host actually opened a browser. Absent from older proxies. */
browserLaunch?: "started" | "failed" | "skipped";
method?: "builder-id" | "google" | "github";
userCode?: string;
verificationUri?: string;
verificationUriComplete?: string;
expiresAt?: number;
state?: string;
warning?: string;
}

/**
Expand All @@ -94,6 +101,14 @@ const STDIN_SENTINEL = "-";

/** Providers whose ONLY login is already a device flow; --device is redundant, not wrong. */
const DEVICE_NATIVE_PROVIDERS = new Set(["kimi", "nous", "github-copilot"]);
const stripTerminalControls = (value: string): string => value.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/g, "");
export function formatKiroDeviceInstructions(start: { verificationUriComplete?: string; verificationUri?: string; userCode?: string; flowId?: string }): string {
return [
stripTerminalControls(start.verificationUriComplete ?? start.verificationUri ?? ""),
start.userCode ? `User code: ${stripTerminalControls(start.userCode)}` : "",
start.flowId ? `Flow: ${stripTerminalControls(start.flowId)}` : "",
].filter(Boolean).join("\n");
}

const ARGV_WARNING =
"warning: the authorization code was passed as a command-line argument, so it is now in your shell history and was visible in the process list while this ran. Pipe it on stdin instead, or pass `-` to read from stdin.";
Expand Down Expand Up @@ -128,9 +143,17 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise<void> {
const noWait = takeFlag(args, "--no-wait");
const reauth = takeFlag(args, "--reauth");
const device = takeFlag(args, "--device");
const method = takeOption(args, "--method");
const id = takeOption(args, "--id");
const suppliedCode = takeOptionWithSyntax(args, "--code");
if (!provider) throw new CliUsageError("provider is required", USAGE);
if (method !== undefined) {
if (provider !== "kiro" || !["builder-id", "google", "github"].includes(method)) {
throw new CliUsageError("--method requires kiro and builder-id, google, or github", USAGE);
}
if (reauth || id) throw new CliUsageError("native Kiro device login only adds accounts; remove and re-add to reauthenticate", USAGE);
if (device || suppliedCode) throw new CliUsageError("--method cannot be combined with --device or --code", USAGE);
}
// A bare leftover here is plausibly the authorization code itself: this flow takes one
// through --code, and a user who pastes it as a positional would otherwise see it echoed
// back in the usage error. `ocx login codex` reaches this parser too, so the paste lands
Expand All @@ -147,6 +170,30 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise<void> {
// opens the browser flow and polls, and must not block on stdin.
const code = await resolveCode(suppliedCode, deps, false);

if (method) {
const start = await runtimeRequest<LoginStart>("/api/oauth/login", {
method: "POST", body: JSON.stringify({ provider: "kiro", method }),
}, deps);
if (!wantsJson) {
const block = formatKiroDeviceInstructions(start);
if (block) writeStdoutFully(`${block}\n`);
}
Comment on lines +177 to +180

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Expose device instructions when JSON mode waits

When ocx account login kiro --method google --json is run without --no-wait, this condition suppresses the only verification URL and user-code output, and the command then waits for approval before printing any JSON. Since the native route does not open a browser, the user cannot authorize the flow and the invocation inevitably expires or times out. Preserve parseable final JSON while displaying the start instructions on stderr, or require --no-wait for this combination.

Useful? React with 👍 / 👎.

if (noWait) { printData(start, wantsJson, []); return; }
if (!start.flowId) throw new CliUsageError("Kiro device login did not return a flow id");
for (let attempt = 0; attempt < 450; attempt++) {
await Bun.sleep(2_000);
const state = await runtimeRequest<LoginStart>(`/api/oauth/status?provider=kiro&flowId=${encodeURIComponent(start.flowId)}`, {}, deps);
if (state.state === "done") {
printData(state, wantsJson, ["Logged in to kiro.", ...(state.warning ? [`Warning: ${state.warning}`] : [])]);
return;
}
if (state.state === "failed" || state.state === "expired" || state.state === "cancelled") {
throw new CliUsageError(`Kiro device login ${state.state}`);
}
}
throw new CliUsageError("Kiro device login timed out");
}

if (CODEX_NAMES.has(provider)) {
const start = await runtimeRequest<LoginStart>("/api/codex-auth/login", {
method: "POST",
Expand Down Expand Up @@ -290,7 +337,7 @@ async function cancel(argv: string[], deps: RuntimeApiDeps): Promise<void> {
}
const result = await runtimeRequest(codex ? "/api/codex-auth/login/cancel" : "/api/oauth/login/cancel", {
method: "POST",
body: JSON.stringify(codex ? { flowId } : { provider }),
body: JSON.stringify(codex ? { flowId } : { provider, ...(provider === "kiro" && flowId ? { flowId } : {}) }),
}, deps);
printData(result, wantsJson, [`Cancelled ${provider} login.`]);
}
Expand Down
17 changes: 17 additions & 0 deletions src/cli/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,23 @@ export const CAPABILITIES: readonly Capability[] = [
"Values accepted by `set` are parsed as JSON when valid, so booleans, numbers, arrays, objects, and null can be passed directly.",
],
},
{
command: ["account", "login"],
summary: "Log in to an OAuth provider; Kiro can add a native device account.",
routes: [
{ method: "POST", path: "/api/oauth/login" },
{ method: "GET", path: "/api/oauth/status" },
],
flags: [
{ name: "--method", value: "string", summary: "For Kiro: builder-id, google, or github device login (add only)." },
{ name: "--reauth", value: "boolean", summary: "Reauthenticate a selected existing account." },
{ name: "--id", value: "string", summary: "Account id for reauthentication." },
{ name: "--no-wait", value: "boolean", summary: "Return after the login flow starts." },
{ name: "--json", value: "boolean", summary: "Emit flow state as JSON." },
],
mutates: true,
json: "payload",
},
{
command: ["account", "history"],
summary: "Cached quota observations for one stored Codex pool account.",
Expand Down
5 changes: 5 additions & 0 deletions src/oauth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1626,6 +1626,10 @@ export async function runLogin(
const loginProviderConfig = preflightConfig
? (def.resolveProviderConfig?.(preflightConfig) ?? preflightConfig.providers[provider] ?? def.providerConfig)
: def.providerConfig;
if (provider === "kiro" && opts?.reauthAccountId
&& getAccountSet("kiro")?.accounts.some(a => a.id === opts.reauthAccountId && a.loginOrigin === "kiro-device")) {
throw new Error("Native Kiro device accounts cannot be reauthenticated with kiro-cli; remove and re-add the account.");
}
const rawCred = await def.login(ctrl, opts, loginProviderConfig);
const cred: OAuthCredentials = rawCred.source ? rawCred : { ...rawCred, source: "oauth" };
const settleKiroTransaction = deps.settleKiroLoginTransaction ?? settleKiroLoginTransaction;
Expand Down Expand Up @@ -1653,6 +1657,7 @@ export async function runLogin(
}
await (deps.saveAccountCredential ?? saveAccountCredential)(provider, opts.reauthAccountId, cred, {
assertBeforePersist: deps.assertCurrentOwner,
rotateLoginId: true,
});
} else {
const saveOptions = {
Expand Down
Loading
Loading