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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **Sign in with Claude, instead of pasting an API key.** Settings → Claude now
has a two-step sign-in: codeoid runs `claude setup-token` for you, shows the
link to approve in your own browser, and takes back the code that page
displays. The subscription credential it mints is stored as
`CLAUDE_CODE_OAUTH_TOKEN` in `~/.codeoid/.env` like any other secret, and
applies to new sessions.

This is for the case where codeoid is the whole surface — a hosted sandbox, a
phone — and there is no shell in which to run the vendor's login by hand. On a
machine with a shell, `claude login` still works and is still picked up.

codeoid brokers the vendor's own command rather than implementing anyone
else's OAuth, and it runs the `claude` binary the Agent SDK already ships, so
there is no new dependency. Wire messages: `backend.login.start` / `.submit` /
`.cancel`, all gated on `settings:write`. Claude is wired today; the mechanism
is per-backend and the others follow.

## [0.4.0] - 2026-07-29

codeoid moves to the Highflame npm org. npm has no way to transfer a package
Expand Down
20 changes: 19 additions & 1 deletion docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,5 +160,23 @@ A variable already set in the real environment still wins.
# ~/.codeoid/.env
TELEGRAM_BOT_TOKEN=123456:AA...
TELEGRAM_ALLOWED_USER_IDS=6714605885
# ANTHROPIC_API_KEY= # only if not logged in via `claude login`
# CLAUDE_CODE_OAUTH_TOKEN= # written by Settings → Claude → "Sign in with Claude"
# ANTHROPIC_API_KEY= # only if you have neither a sign-in nor a Claude Code login
```

## Signing a backend in, instead of pasting a key

Settings → **Claude** → **Sign in with Claude** runs the backend's own login
(`claude setup-token`) inside the daemon and stores the subscription credential
it mints as `CLAUDE_CODE_OAUTH_TOKEN` in the `.env` above. Two steps: open the
link it shows, then paste back the code the page displays.

This exists for the case where codeoid IS the whole surface — a hosted sandbox,
a phone — and there is no shell in which to run the vendor's login by hand. On a
machine where you do have a shell, `claude login` still works and codeoid picks
that credential up unchanged; nothing here replaces it.

The sign-in requires the `settings:write` scope, because it writes to the same
`.env` that scope already governs. It takes effect for **new sessions** — a
running session keeps the environment it started with. Only Claude is wired
today; the mechanism is per-backend and the others follow.
127 changes: 127 additions & 0 deletions packages/protocol/src/backend-login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* Interactive backend sign-in — the wire contract for logging a backend in from
* a codeoid client, instead of pasting a provider API key.
*
* WHY THIS EXISTS. Every backend codeoid runs already has a first-party login
* that mints a subscription credential — `claude setup-token`, `codex login`,
* qwen's OAuth. Each one is an interactive terminal command, so using it meant
* having a shell on the machine the daemon runs on. When codeoid IS the whole
* surface (a hosted sandbox, a phone), nobody does, and an API key was the only
* way in. That is the gap: not that login is impossible, but that it was
* unreachable from the only UI the user has.
*
* The daemon BROKERS the vendor's own command; it never reimplements the
* vendor's OAuth. That distinction is the whole design. Scraping a client id out
* of someone else's CLI and driving their token endpoint ourselves would work
* right up until they change it, and would put us in the business of
* maintaining another company's auth. Running the command they ship means their
* flow changes under us and keeps working.
*
* Two steps, because that is the shape the vendors' commands already use:
*
* 1. `backend.login.start` — the daemon runs the login command and returns the
* authorize URL it prints. The user opens that URL in THEIR browser; the
* daemon never needs one, which is what makes this work headless.
* 2. `backend.login.submit` — the vendor's callback page displays a code. The
* user pastes it back, the daemon hands it to the still-waiting command,
* and the credential the command produces is stored exactly like any other
* secret (`~/.codeoid/.env`, 0600) so the rest of codeoid needs no
* special case for it.
*
* A submitted code and the resulting credential are never echoed back to a
* client and never logged.
*/

import type { SettingsSnapshot } from "./settings.js";

/**
* Backends with an interactive login wired end to end.
*
* Deliberately narrower than the backend catalog: a backend appears here only
* once its login command is driven and tested, so a client can offer "Sign in"
* without first asking the daemon whether it would work. Adding one is an entry
* here plus a flow in the daemon's broker.
*/
export type LoginBackend = "claude";

/** Runtime companion to {@link LoginBackend} — what a client renders a button for. */
export const LOGIN_CAPABLE_BACKENDS: readonly LoginBackend[] = ["claude"] as const;

/** A login the daemon has started and is holding open, waiting for a code. */
export interface PendingBackendLogin {
/** Opaque handle for the in-flight attempt; required to submit or cancel. */
loginId: string;
backend: LoginBackend;
/** The vendor URL the user opens in their OWN browser to approve. */
verificationUrl: string;
/** Epoch ms after which the daemon abandons the attempt and kills the command. */
expiresAt: number;
/** One line naming what the user brings back — wording is vendor-specific. */
codeHint: string;
}

// ── Messages (client → daemon) ────────────────────────────────────────────────

/**
* Begin an interactive login. Resolves only once the vendor command has printed
* its authorize URL, so a client gets a URL or an error — never a handle to an
* attempt it cannot show the user. Starting a login supersedes any attempt
* already in flight for that backend (a reloaded page must not be locked out by
* its own abandoned attempt).
*/
export interface BackendLoginStartMsg {
type: "backend.login.start";
id: string;
backend: LoginBackend;
}

/**
* Hand the vendor's code to the waiting command and wait for the exchange.
* Terminal either way: on failure the attempt is finished and the client must
* start a new one, because the command's own retry loop is not reachable
* through this protocol.
*/
export interface BackendLoginSubmitMsg {
type: "backend.login.submit";
id: string;
loginId: string;
/** The code the vendor's callback page displayed. Never logged or echoed. */
code: string;
}

/** Abandon an in-flight attempt and kill the command. Idempotent. */
export interface BackendLoginCancelMsg {
type: "backend.login.cancel";
id: string;
loginId: string;
}

// ── Messages (daemon → client) ────────────────────────────────────────────────

export interface BackendLoginStartResultMsg {
type: "backend.login.start.result";
requestId: string;
login: PendingBackendLogin;
}

export interface BackendLoginSubmitResultMsg {
type: "backend.login.submit.result";
requestId: string;
ok: boolean;
/**
* Why it failed, safe to display — the vendor's own rejection, with anything
* credential-shaped stripped. Absent when `ok`.
*/
error?: string;
/**
* Settings AFTER the write, so the drawer reflects the new credential without
* a second round trip. The stored secret shows as set; its value never moves.
*/
snapshot: SettingsSnapshot;
}

export interface BackendLoginCancelResultMsg {
type: "backend.login.cancel.result";
requestId: string;
ok: boolean;
}
1 change: 1 addition & 0 deletions packages/protocol/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./types.js";
export * from "./scopes.js";
export * from "./settings.js";
export * from "./backend-login.js";
12 changes: 12 additions & 0 deletions packages/protocol/src/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,18 @@ const samples: { [T in ClientTypes]: Extract<ClientMessage, { type: T }> } = {
nameOverride: "imported",
},
"usage.daily": { type: "usage.daily", id: "r24", days: 30 },
"backend.login.start": { type: "backend.login.start", id: "r25", backend: "claude" },
"backend.login.submit": {
type: "backend.login.submit",
id: "r26",
loginId: "11111111-2222-3333-4444-555555555555",
code: "abc123#state",
},
"backend.login.cancel": {
type: "backend.login.cancel",
id: "r27",
loginId: "11111111-2222-3333-4444-555555555555",
},
"settings.schema": { type: "settings.schema", id: "r29" },
"settings.get": { type: "settings.get", id: "r30" },
"settings.set": {
Expand Down
30 changes: 30 additions & 0 deletions packages/protocol/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,33 @@ export const settingsSetSchema = z.object({
.max(256),
});

/**
* The backends a client may ask to sign in. An explicit enum, not a free
* string: `start` spawns a process chosen by this value, so the set of things
* it can name belongs in the validated surface rather than in a lookup that
* happens to miss.
*/
const loginBackendField = z.enum(["claude"]);

export const backendLoginStartSchema = z.object({
...base,
type: z.literal("backend.login.start"),
backend: loginBackendField,
});

export const backendLoginSubmitSchema = z.object({
...base,
type: z.literal("backend.login.submit"),
loginId: z.string().min(1).max(128),
code: z.string().min(1).max(LIMITS.LOGIN_CODE_MAX),
});

export const backendLoginCancelSchema = z.object({
...base,
type: z.literal("backend.login.cancel"),
loginId: z.string().min(1).max(128),
});

// ── The unions ────────────────────────────────────────────────────────────────

// ── SDLC pipeline ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -661,6 +688,9 @@ export const clientMessageSchema = z.discriminatedUnion("type", [
settingsSchemaSchema,
settingsGetSchema,
settingsSetSchema,
backendLoginStartSchema,
backendLoginSubmitSchema,
backendLoginCancelSchema,
usageDailySchema,
pipelineCreateSchema,
pipelineListSchema,
Expand Down
20 changes: 20 additions & 0 deletions packages/protocol/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ import type {
SettingsGetResultMsg,
SettingsSetResultMsg,
} from "./settings.js";
import type {
BackendLoginStartMsg,
BackendLoginSubmitMsg,
BackendLoginCancelMsg,
BackendLoginStartResultMsg,
BackendLoginSubmitResultMsg,
BackendLoginCancelResultMsg,
} from "./backend-login.js";

/**
* Wire-protocol version. Bump on breaking changes (renamed/removed fields,
Expand Down Expand Up @@ -184,6 +192,12 @@ export const LIMITS = {
* fit comfortably under 8 KiB.
*/
SETTING_VALUE_MAX: 8192,
/**
* Max length of a `backend.login.submit` code. A vendor's out-of-band code is
* a short opaque string (a few hundred bytes with its state suffix); the cap
* is here so a blob never reaches a live pty, not to fit any real code.
*/
LOGIN_CODE_MAX: 1024,
/** Max free-text length on a `session.ui_response` (`value`). */
UI_TEXT_MAX: 65_536,
/** Max number of options on a `session.ui_request` select. */
Expand Down Expand Up @@ -968,6 +982,9 @@ export type ClientMessage =
| SettingsSchemaMsg
| SettingsGetMsg
| SettingsSetMsg
| BackendLoginStartMsg
| BackendLoginSubmitMsg
| BackendLoginCancelMsg
| UsageDailyMsg
| PipelineCreateMsg
| PipelineListMsg
Expand Down Expand Up @@ -2479,6 +2496,9 @@ export type DaemonMessage =
| SettingsSchemaResultMsg
| SettingsGetResultMsg
| SettingsSetResultMsg
| BackendLoginStartResultMsg
| BackendLoginSubmitResultMsg
| BackendLoginCancelResultMsg
| PipelineSnapshotMsg
| PipelineListResultMsg
| PackListResultMsg
Expand Down
Loading
Loading