Repository navigation
feat(gateway): add Kilo device login on the v2 plugin API - #14998
marius-kilocode wants to merge 4 commits into
Conversation
| "@ai-sdk/vercel": "2.0.39", | ||
| "@ff-labs/fff-bun": "0.10.5", | ||
| "@ff-labs/fff-node": "0.10.5", | ||
| "@kilocode/gateway-core": "workspace:*", |
There was a problem hiding this comment.
CRITICAL: @kilocode/gateway-core is a private, unpublished package ("private": true, no build script, exports points at ./src/index.ts), yet it is added to the runtime dependencies of the publishable @opencode/core.
packages/core/script/build.ts bundles with packages: "external", so the built Core output keeps an import ... from "@kilocode/gateway-core", and packages/sdk/script/verify-package.ts throws @opencode/core has unpacked workspace dependencies: @kilocode/gateway-core because the package is not in its names list. That check runs in CI (test.yml "Verify packed workerd SDK" → bun turbo verify:package --affected --filter=@opencode/sdk), and the same gap breaks the published @opencode/core, which would reference a version that is never published.
Fix: either add gateway-core to the packed/published set (give it a build + dist exports and add it to verify-package.ts/script/publish.ts), or make it a devDependency and bundle it into Core instead of leaving it external.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| const response = yield* request(`${server}/api/device-auth/codes/${encodeURIComponent(code.code)}`) | ||
| if (response.status === 403) return yield* Effect.fail(new Error("Authorization denied by user")) | ||
| if (response.status === 410) return yield* Effect.fail(new Error("Authorization code expired")) | ||
| if (!response.ok) return yield* Effect.fail(new Error(`Failed to poll device authorization: ${response.status}`)) |
There was a problem hiding this comment.
WARNING: A single transient poll response aborts the whole login. Any non-ok, non-403/410 status (429, 408, 5xx) fails immediately with Failed to poll device authorization: <status>, even though start() (line 39) treats 429 as retryable. Since poll hits this endpoint every 3s, a momentary rate limit or server error permanently fails an otherwise valid authorization.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
|
|
||
| // Interrupting the returned effect aborts the request. | ||
| export function request(url: string, init?: RequestInit) { | ||
| return Effect.tryPromise({ |
There was a problem hiding this comment.
SUGGESTION: request has no timeout, so the poll deadline (device.ts:85) is only checked between requests. A server that accepts the connection but never responds leaves fetch pending, so login can hang past expiresIn/expiresAt instead of failing at the deadline. Consider bounding the fetch with AbortSignal.timeout(...), as other providers do.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| await Effect.runPromise( | ||
| Effect.gen(function* () { | ||
| const code = yield* KiloDevice.start(backend.url) | ||
| const fiber = yield* KiloDevice.login(backend.url, code, 1000).pipe(Effect.forkScoped({ startImmediately: true })) |
There was a problem hiding this comment.
SUGGESTION: This does not actually verify that interruption stops polling: the fiber is forked with interval = 1000, but the test only waits 20ms before checking that no new request arrived (lines 67-71). No request could appear in that window even if the loop kept running. Fork with a small interval (e.g. 1) so a non-interruptible loop would add requests during the observation window.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
Code Review SummaryStatus: 2 Issues Found | Recommendation: Address before merge Overview
Issue Details (click to expand)WARNING
SUGGESTION
Files Reviewed (8 files, incremental)
This commit adds the Fix these issues in Kilo Cloud Previous Review Summaries (3 snapshots, latest commit 09a5cda)Current summary above is authoritative. Previous snapshots are kept for context only. Previous review (commit 09a5cda)Status: 1 Issue Found | Recommendation: Address before merge Overview
Issue Details (click to expand)SUGGESTION
Files Reviewed (8 files, incremental)
The three prior findings are resolved at HEAD Fix these issues in Kilo Cloud Previous review (commit 71c7188)Status: 4 Issues Found | Recommendation: Address before merge Overview
Issue Details (click to expand)CRITICAL
WARNING
SUGGESTION
Files Reviewed (23 files)
Notes: The plugin's Fix these issues in Kilo Cloud Previous review (commit 9428130)Status: 4 Issues Found | Recommendation: Address before merge Overview
Issue Details (click to expand)CRITICAL
WARNING
SUGGESTION
Files Reviewed (23 files)
Notes: The plugin's Reviewed by deepseek-v4.1-flash · Input: 140.4K · Output: 31.9K · Cached: 3.1M |
|
too many Kilo warnings/suggestions/critical for me to review (yet), I'll let you fix those and then have a second look at this, thanks! |
Add @kilocode/gateway-core with server and token handling, device auth, profile, default team and provider headers, without OpenCode imports. Replace the upstream KiloPlugin with the kilocode.gateway adapter: device login stores server and organizationID in the credential, requests send Kilo headers and the team header, and signed-out users get the free models with the anonymous key. bun dev no longer reads the v1 login.
9428130 to
71c7188
Compare
@opencode/core depends on the Kilo package at runtime, so verify-package must pack it before core. Add a build to dist and list the package before core (marked kilocode_change).
A network failure, timeout, 408, 429 or 5xx poll response no longer ends the login. Polls retry with backoff until the code expires, and every Kilo request has a timeout that a poll caps at the remaining code lifetime.
| "./testing": "./test/fixture.ts" | ||
| }, | ||
| "files": [ | ||
| "dist" |
There was a problem hiding this comment.
SUGGESTION: The packed tarball only ships dist, but the ./testing export still points at the test fixture.
packages/sdk/script/verify-package.ts rewrites exports from ./src/... to ./dist/... before packing, but "./testing": "./test/fixture.ts" does not match ./src/, so output() turns it into ./test/fixture.js / ./test/fixture.d.ts. Neither is in dist, and files: ["dist"] excludes test/, so the packed manifest advertises a @kilocode/gateway-core/testing subpath whose target is not in the tarball. It is inert today because only the in-repo core test imports it, but the shipped manifest is inconsistent. Either add the fixture to files or drop ./testing from the packed exports.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
After a Kilo login, the TUI shows the v1 account picker (Select Account) when the account has teams, then the model dialog. /teams (team, org, orgs) switches later. The choice goes through the kilocode.gateway RPC (profile, organization.set), is stored per connection in plugin storage and wins over the login default. KILO_ORG_ID still wins, as in v1.
| }), | ||
| }) | ||
| .pipe(Effect.orDie) | ||
| emit = (organizationID) => rpc.events.emit("organization", { organizationID }).pipe(Effect.ignore) |
There was a problem hiding this comment.
WARNING: The cross-location reload this event is meant to drive does not happen.
rpc.events.emit("organization", ...) publishes on the location-scoped bus: Rpc.register's emit calls bus.publish(definition, data, { location: <the emitter's location ref> }) with no global: true (packages/core/src/rpc.ts:98-102), so the event carries the emitting location. ctx.event.subscribe() is bus.subscribe() wrapped in Bus.local(...) (packages/core/src/plugin/host.ts:255-265), and local drops any event whose location is not the subscriber's location (packages/core/src/bus.ts:726-746). A plugin instance in another location therefore never receives rpc.kilocode.gateway.organization, so its provider keeps the previous team header until it reloads for another reason. Compare credential.switched, which is published with { global: true } and does reach every location (packages/core/src/credential.ts:163-168). The line 22 comment and the PR's "instances of all locations reload" claim are not achieved. Emit on the global bus (or drop the cross-location claim).
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
Part of #14991 (PR 1 of the Gateway series: login). Plan unit U-E1-03.
What Problem This Solves
The v2 base reaches the Kilo Gateway only through the generic models.dev
kiloentry. Upstream'sKiloPluginsendsHTTP-Referer: https://opencode.ai/andX-Title: opencode, there is no Kilo login, andbun devborrows the v1 token and team from~/.local/share/kilo/auth.json.Why This Change Was Made
#14991 asks for a Kilo-owned Gateway layer with a clear boundary to OpenCode. This PR adds the first part:
packages/kilo-gateway-core(@kilocode/gateway-core), with no@opencode/*import. It contains the Kilo logic ported from v1packages/kilo-gatewayand the old port (kilo-v2-checkpoints-2026-09):KILO_API_URL, and the server in the token (v1getKiloUrlFromToken)POST /api/device-auth/codes, pollGET /api/device-auth/codes/{code}, local expiry, stops when interrupted. A network failure, a timeout, 408, 429 or 5xx does not end the login: the poll retries with backoff (up to 30 s) until the code expires. v1 ends the login on these, so this is a change from v1defaultOrganizationId: the backendselectedOrganizationId, else the first team when there is no personal account)KILO_ORG_IDwins over the stored team)HTTP-Referer: https://kilocode.ai,X-Title: Kilo Code(v1DEFAULT_HEADERS),X-KILOCODE-EDITORNAME,X-KILOCODE-FEATUREandX-KILOCODE-ORGANIZATIONID, with the v1 overridesKILOCODE_VERSION,KILOCODE_EDITOR_NAMEandKILOCODE_FEATURE@opencodeimport or dependency.turbo.jsongets a@kilocode/gateway-core#testtask, so CI runs it.kilocode.gatewayadapter (packages/core/src/kilocode/gateway.ts) uses only the public plugin API:deviceOAuth method on thekilointegration ("Kilo Gateway (Device Authorization)"). The credential isCredential.OAuthwithmetadata.server,metadata.organizationIDandmetadata.email(connection label). As in v1, the token is also the refresh token and is valid for one year, with no refresh.credential.switched(login, account switch, logout).kilocode.gateway(profile,organization.set), like v1kilo.profileandkilo.organization.set. The team choice is stored per connection in plugin storage. It wins over the login default, andKILO_ORG_IDstill wins over both, as in v1. Plugin storage is global, but each location runs its own plugin instance. So an RPC event makes the instances of all locations reload, as v1 reloaded all instances after it changedaccountId.packages/tui/src/kilocode/gateway.tsx):KiloAutoMethodandgetOrganizationOptions./teams(aliasesteam,org,orgs), only when Kilo is connected: "Select Team", the current account marked, then "Switched to: ". Without teams it shows "No Teams Available", as in v1.connected()ofdialog-integration.tsxopens the Kilo picker before the model dialog./teamsis a built-in TUI plugin.bun devno longer reads the v1 auth file. It starts signed out and keepskilo/kilo-auto/freeas the default model. The skill doc tells how to run a signed-in test withKILO_API_KEYandKILO_ORG_ID.Packaging
@opencode/coreis publishable, and its build keeps packages external. So the coredistimports@kilocode/gateway-coreat run time.packages/sdk/script/verify-package.tspacks a fixed list of packages and rejects aworkspace:dependency that is not in that list. Without a fix, the check fails with@opencode/core has unpacked workspace dependencies: @kilocode/gateway-core.This PR packs the Kilo package: it gets a
buildtodist, and the verify list names it beforecore(one marked line). I chose this option for two reasons:distneeds a bundler change in the core build script and hides a real dependency.packages/core/src/kilocode/removes the package boundary.The package stays
private. If Kilo ever publishes@opencode/core, it must publish this package too. That is not planned.Changes to shared upstream files
packages/core/src/plugin/provider.tsKiloGatewayPluginreplacesKiloPlugin(2 marked lines)packages/core/test/plugin/provider-kilo.test.tspackages/core/package.json,bun.lock@kilocode/gateway-corepackages/sdk/script/verify-package.tskilo-gateway-corebeforecore(1 marked line)packages/tui/src/component/dialog-integration.tsxpackages/tui/src/plugin/builtins.ts/teamsplugin (2 marked lines)turbo.jsonUpstream
provider/kilo.tsstays unchanged and unregistered, to avoid merge conflicts.#14991 scope covered by this PR
KILO_API_KEYand an API key methodKILO_API_KEYis tested live. The API key method (from the models.dev seed) is listed in/connect, and the adapter reads stored keys, but I did not test it liveKILO_API_URLprofileRPC (account choice). No balance or Kilo Passbun devKILO_ORG_ID, team header on Gateway requests/teams) and the RPC (organization.set). No CLI command yetX-KILOCODE-EDITORNAME,X-KILOCODE-FEATURE, and the Kilo referer and title are sent. The user agent is still upstream'sopencode/<channel>/<version>/<name>. v1 sends noX-KILOCODE-VERSION: only the Gateway docs list it as optional (packages/kilo-docs/pages/gateway/authentication.md:128onmain), and v1 puts the version into the editor name and the user agent. It is not addedUser Impact
/connect, then Kilo Gateway, offers "Kilo Gateway (Device Authorization)" and "API key". The device screen shows the verification link and the code./teamsswitches later. The choice survives a restart.Evidence
Automated (tests with
XDG_DATA_HOME,XDG_CONFIG_HOME,XDG_STATE_HOMEandXDG_CACHE_HOMEset to a temporary directory):packages/kilo-gateway-core:bun typecheckpasses.bun test: 17 pass. The tests cover:packages/core:bun typecheckpasses.bun run script/test.ts test/kilocode/gateway.test.ts test/plugin/provider-kilo.test.ts: 9 pass. The tests cover:Integration.oauth.connectagainst a local Gateway fixture, with the stored metadata and labelKILO_API_KEYwithKILO_ORG_IDpackages/core,test/kilocode/gateway.test.tsalso covers the RPC: profile against the fixture,organization.setchanges the team header,nullremoves it, a new login uses its own default again, and signed out both methods fail withunauthorized.packages/tui:bun typecheckpasses (in a copy outside the v1 checkout, because the nested worktree picks up the v1@types/node).bun test test/kilocode test/cli/tui/dialog-integration.test.tsx test/cli/cmd/tui/integration-options.test.ts: 18 pass (option list rules from v1 and the existing connect dialog tests).packages/sdk:bun run verify:packagein a copy outside the v1 checkout. It now gets past the dependency check, the packedbunandnodeimports and thewranglerdry run. The last step (miniflare boot) fails locally withcreateRequire(undefined)in a coreschemachunk. A cleanorigin/v2copy fails at the same step on this machine (Node 25), so that failure is local. CI runs the full check.bun run lintand the Effect ast-grep rules pass on the changed paths.Real CLI against the deployed Gateway (
bun dev, fresh profile each run):run "Reply with exactly: PONG"(defaultkilo/kilo-auto/free)PONG-m kilo/deepseek/deepseek-v4.1-flashError: You need to sign in to use this model.KILO_API_KEYandKILO_ORG_IDfrom the v1 auth file, paid modelPONGKILO_API_URL), signed in and signed outBearer anonymouswhen signed out.HTTP-Referer: https://kilocode.ai,X-Title: Kilo Code,X-KILOCODE-EDITORNAME,X-KILOCODE-FEATUREin both. Noopencode.aireferer oropencodetitlemath.tswith the free model1.2.3, no errors in the log/connect, Kilo Gatewayhttps://app.kilo.ai/device-auth?code=…, "Enter code: …", "Waiting for authorization…". Stopped before approvalKILO_API_KEY,KILO_ORG_ID),kilo/deepseek/deepseek-v4.1-flash1.2.3, no errors in the logAccount choice, TUI with a local stand-in for the Kilo API (
KILO_API_URL). The stand-in approves the device code after two polls, returns a profile with two teams, records the chat request headers and answers with a short stream:/team/teamsis not offered/connect, Kilo Gateway, device methodX-KILOCODE-ORGANIZATIONID: org-beta/teams/teamsto Acme Inc, then restart the CLIorg-acme/teamswith a real device login against the deployed Gateway (team-only account)hasPersonalAccount: false), the login default team marked currentFix during self-test: the first version of the picker requested
/api/profilein a loop. The dialog component returned<Show>as its root. The dialog renders a function root inside a render effect, so each change of the profile signal created the component again. The root is now a<box>, like the other dialogs.Limits: