diff --git a/.kilo/skills/v2-migration/SKILL.md b/.kilo/skills/v2-migration/SKILL.md index ebd8fe105bac..0c14277b39b3 100644 --- a/.kilo/skills/v2-migration/SKILL.md +++ b/.kilo/skills/v2-migration/SKILL.md @@ -42,7 +42,7 @@ Kilo moves onto upstream OpenCode v2 from a clean base, one feature at a time. V - Run the CLI only through `bun dev` (`script/dev-isolated.ts`). It keeps all data in one profile directory. Never run `bun run --cwd packages/cli`, `dev:live` or `opencode` directly. They use the installed OpenCode data and service. - Use a fresh profile per test: `KILO_V2_PROFILE=/tmp/kilo-v2-`. Add `--standalone` so parallel agents do not share the service port. -- `bun dev` reuses the v1 Kilo login (read only). For a signed-out test, start it with `XDG_DATA_HOME=/tmp/kilo-v2-empty`. +- `bun dev` starts signed out and does not read the v1 login. Signed out, the Kilo Gateway serves its free models. Sign in with `/connect`, or start a signed-in test with `KILO_API_KEY` (and `KILO_ORG_ID` for a team) from the v1 auth file, so that no secret shows on screen. - Do not edit the main checkout, `~/.config/kilo`, `~/.local/share/kilo` or OpenCode directories. `bun run extension` does not exist on v2. - Models: `kilo/kilo-auto/free` by default. Use `kilo/deepseek/deepseek-v4.1-flash` only when a paid model is needed. - Isolate tests too. Upstream tests can write into the real home and leave servers running. Run them with `T=$(mktemp -d)` and `env OPENCODE_TEST_HOME=$T XDG_DATA_HOME=$T/data XDG_CONFIG_HOME=$T/config XDG_STATE_HOME=$T/state XDG_CACHE_HOME=$T/cache bun test ...`. After the run, stop any of your processes that `pgrep -fl 'opencode-test-|opencode-service-lifecycle'` lists. @@ -100,7 +100,7 @@ st type --submit --value "env KILO_V2_PROFILE=$T/profile bun $V2/script/dev-isol st wait --text "ctrl+p commands" >/dev/null && screen ``` -The TUI is ready when the last command prints the home screen (about 15 seconds). To test signed out, start the CLI with `env XDG_DATA_HOME=$T/empty KILO_V2_PROFILE=...` instead. +The TUI is ready when the last command prints the home screen (about 15 seconds). The CLI starts signed out. To test signed in, start it with `env KILO_API_KEY=$(jq -r .kilo.access ~/.local/share/kilo/auth.json) KILO_ORG_ID=$(jq -r '.kilo.accountId // empty' ~/.local/share/kilo/auth.json) KILO_V2_PROFILE=...` instead. ### 2. Test diff --git a/bun.lock b/bun.lock index ed4faff16f9d..14f39b6e0c44 100644 --- a/bun.lock +++ b/bun.lock @@ -352,6 +352,7 @@ "@ai-sdk/vercel": "2.0.39", "@ff-labs/fff-bun": "0.10.5", "@ff-labs/fff-node": "0.10.5", + "@kilocode/gateway-core": "workspace:*", "@lydell/node-pty": "catalog:", "@modelcontextprotocol/client": "2.0.0", "@modelcontextprotocol/core": "2.0.0", @@ -587,6 +588,19 @@ "@typescript/native-preview": "catalog:", }, }, + "packages/kilo-gateway-core": { + "name": "@kilocode/gateway-core", + "version": "0.0.0", + "dependencies": { + "effect": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:", + }, + }, "packages/kilo-plugin": { "name": "@kilocode/kilo-plugin", "version": "0.0.0", @@ -2025,6 +2039,8 @@ "@jsx-email/text": ["@jsx-email/text@1.0.2", "", { "peerDependencies": { "react": "^18.2.0" } }, "sha512-0zzwEwrKtY6tfjPJF0r3krKCDpP/ySYDvkn4+MvIFrIH5RZKmn3XDa5o/3hkbxMwpLn4MsXGIXn9XzMTaqTfUA=="], + "@kilocode/gateway-core": ["@kilocode/gateway-core@workspace:packages/kilo-gateway-core"], + "@kilocode/kilo-plugin": ["@kilocode/kilo-plugin@workspace:packages/kilo-plugin"], "@kitlangton/solid-motion": ["@kitlangton/solid-motion@0.2.2", "", { "dependencies": { "motion-dom": "14.0.0", "motion-utils": "14.0.0" }, "peerDependencies": { "solid-js": "^1.8.0" } }, "sha512-Fp7T8gJta3aRGJakBPQ6WIWmF6U6TmWhT6sGNNgkBiJ8cOVfuKkF9yYXzhvgRtKpo7WfzxtNLku907dLHhTDtw=="], diff --git a/packages/core/package.json b/packages/core/package.json index c528e08e7583..f6d4769acd8a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -103,6 +103,7 @@ "@ai-sdk/vercel": "2.0.39", "@ff-labs/fff-bun": "0.10.5", "@ff-labs/fff-node": "0.10.5", + "@kilocode/gateway-core": "workspace:*", "@lydell/node-pty": "catalog:", "@modelcontextprotocol/client": "2.0.0", "@modelcontextprotocol/core": "2.0.0", diff --git a/packages/core/src/kilocode/gateway-rpc.ts b/packages/core/src/kilocode/gateway-rpc.ts new file mode 100644 index 000000000000..8f5b4bee1ecd --- /dev/null +++ b/packages/core/src/kilocode/gateway-rpc.ts @@ -0,0 +1,42 @@ +export * as KiloGatewayRpc from "./gateway-rpc.js" + +import { Rpc } from "@opencode/schema/rpc" +import { Schema } from "effect" + +// Standard Schemas, so the promise client of the TUI accepts the contract too. +const portable = Schema.toStandardSchemaV1 +const Failure = { unauthorized: portable(Schema.Undefined), failed: portable(Schema.Undefined) } +const OrganizationID = Schema.NullOr(Schema.String) + +export const Profile = Schema.Struct({ + email: Schema.String, + organizations: Schema.Array( + Schema.Struct({ id: Schema.String, name: Schema.String, role: Schema.optional(Schema.String) }), + ), + hasPersonalAccount: Schema.Boolean, + /** The team that requests use now. `null` is the personal account. */ + organizationID: OrganizationID, +}) + +export type Profile = typeof Profile.Type + +// The TUI uses this contract for the account picker after login and for /teams, as v1 uses +// `kilo.profile` and `kilo.organization.set`. +export const Definition = Rpc.define({ + id: "kilocode.gateway", + methods: { + profile: { + input: portable(Schema.Struct({})), + output: portable(Profile), + errors: Failure, + }, + "organization.set": { + input: portable(Schema.Struct({ organizationID: OrganizationID })), + output: portable(Schema.Struct({})), + errors: Failure, + }, + }, + events: { + organization: { schema: portable(Schema.Struct({ organizationID: OrganizationID })) }, + }, +}) diff --git a/packages/core/src/kilocode/gateway.ts b/packages/core/src/kilocode/gateway.ts new file mode 100644 index 000000000000..ef36e0f1231f --- /dev/null +++ b/packages/core/src/kilocode/gateway.ts @@ -0,0 +1,177 @@ +import { KiloAccount, KiloDevice, KiloHeaders, KiloProfile, KiloServer } from "@kilocode/gateway-core" +import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration" +import { define } from "@opencode/plugin/effect/plugin" +import { Credential } from "@opencode/schema/credential" +import { IntegrationMethodID } from "@opencode/schema/integration-id" +import { Clock, Effect, Option, Schema, Scope, Stream } from "effect" +import { Bus } from "../bus.js" +import { KiloGatewayRpc } from "./gateway-rpc.js" + +// v2 adapter for the Kilo Gateway (issue #14991, PR 1: login and teams). +// Kilo logic lives in @kilocode/gateway-core; this file only maps it onto the public plugin API. +// The `kilo` integration and provider come from the models.dev seed. +const id = "kilo" +const methodID = IntegrationMethodID.make("device") + +// Written by the device method. Decoded again because stored credentials are read back from the database. +const Metadata = Schema.Struct({ + server: Schema.optional(Schema.String), + organizationID: Schema.optional(Schema.NullOr(Schema.String)), +}) +const decodeMetadata = Schema.decodeUnknownOption(Metadata) +// The team choice in plugin storage. `null` is the personal account. +const decodeSelection = Schema.decodeUnknownOption(Schema.Struct({ organizationID: Schema.NullOr(Schema.String) })) +// Published on the global bus (`{ global: true }`), so plugin instances of other locations also reload. +const OrganizationEvent = Bus.ephemeral({ + type: `rpc.${KiloGatewayRpc.Definition.id}.organization`, + schema: { organizationID: Schema.NullOr(Schema.String) }, +}) + +export const KiloGatewayPlugin = define({ + id: "kilocode.gateway", + effect: Effect.fn(function* (ctx) { + const bus = yield* Bus.Service + const server = KiloServer.fromEnv() + let account: KiloAccount.Info | undefined + let envActive = false + + const load = Effect.gen(function* () { + // Environment tokens (`KILO_API_KEY`, `KILOCODE_TOKEN`, `KILO_AUTH_CONTENT`) take precedence over stored + // credentials, matching v1 (`acd3d1ff9b`) and supporting headless/cloud consumers. + const envInput = KiloAccount.envToken() + if (envInput) { + const selection = decodeSelection(yield* ctx.storage.get("organization:env")) + envActive = true + account = KiloAccount.resolve({ + ...envInput, + server, + organizationID: Option.isSome(selection) ? selection.value.organizationID : envInput.organizationID, + }) + return + } + envActive = false + const connection = yield* ctx.integration.connection.active(id) + const credential = connection + ? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined)) + : undefined + const selection = connection ? decodeSelection(yield* ctx.storage.get(organizationKey(connection))) : Option.none() + account = credential ? toAccount(credential, Option.getOrUndefined(selection)) : undefined + }) + + yield* ctx.integration.transform((editor) => editor.method.update(device(server))) + yield* load + yield* ctx.provider.transform((editor) => { + if (!editor.get(id)) return + editor.update(id, (provider) => { + const { apiKey: _, ...rest } = provider.settings ?? {} + const settings = { ...rest, baseURL: KiloServer.gateway(account?.server ?? server) } + provider.headers = { + ...provider.headers, + ...KiloHeaders.provider({ version: ctx.app.version, organizationID: account?.organizationID }), + } + if (account) { + if (envActive) { + provider.activation = "enabled" + provider.settings = { ...settings, apiKey: account.token } + return + } + provider.activation = "auto" + provider.settings = settings + return + } + // Signed out, the integration has no connection and the provider would stay hidden. The Gateway + // serves its free models to the anonymous key, as in v1. + provider.activation = "enabled" + provider.settings = { ...settings, apiKey: KiloAccount.ANONYMOUS_KEY } + }) + }) + const reload = load.pipe(Effect.andThen(ctx.provider.reload())) + yield* ctx.rpc + .register(KiloGatewayRpc.Definition, { + profile: (_input, context) => + Effect.gen(function* () { + if (!account) return yield* Effect.fail(context.error("unauthorized", "Not signed in to Kilo Gateway")) + const profile = yield* KiloProfile.get(account.server, account.token).pipe( + Effect.mapError((error) => context.error("failed", error.message)), + ) + return { + email: profile.email, + organizations: profile.organizations, + hasPersonalAccount: profile.hasPersonalAccount !== false, + organizationID: account.organizationID, + } + }), + // Same as v1 `setOrganization`, which stores the team in the auth record of the login. The choice + // wins over the login default and is kept per connection. + "organization.set": (input, context) => + Effect.gen(function* () { + const envInput = KiloAccount.envToken() + const connection = envInput ? undefined : yield* ctx.integration.connection.active(id) + const key = envInput ? "organization:env" : connection ? organizationKey(connection) : undefined + if (!key || !account) + return yield* Effect.fail(context.error("unauthorized", "Not signed in to Kilo Gateway")) + yield* ctx.storage.set(key, { organizationID: input.organizationID }) + yield* reload + yield* bus.publish(OrganizationEvent, { organizationID: input.organizationID }, { global: true }) + return {} + }), + }) + .pipe(Effect.orDie) + yield* ctx.event.subscribe().pipe( + Stream.filter( + (event) => + (event.type === "credential.switched" && event.data.integrationID === id) || + event.type === OrganizationEvent.type, + ), + Stream.runForEach(() => reload), + Effect.forkScoped({ startImmediately: true }), + ) + }), +}) + +function device(server: string) { + return { + integrationID: id, + method: { id: methodID, type: "oauth", label: "Kilo Gateway (Device Authorization)" }, + authorize: () => + Effect.gen(function* () { + const code = yield* KiloDevice.start(server) + return { + mode: "auto" as const, + url: code.verificationUrl, + instructions: `Enter code: ${code.code}`, + expiresAt: (yield* Clock.currentTimeMillis) + code.expiresIn * 1000, + callback: Effect.gen(function* () { + const login = yield* KiloDevice.login(server, code) + // v1 reuses the token as the refresh token and has no refresh endpoint. + return Credential.OAuth.make({ + type: "oauth", + methodID, + access: login.token, + refresh: login.token, + expires: (yield* Clock.currentTimeMillis) + KiloDevice.TOKEN_LIFETIME_MS, + metadata: { server, organizationID: login.organizationID, email: login.email }, + }) + }), + } + }), + label: (credential) => (typeof credential.metadata?.email === "string" ? credential.metadata.email : undefined), + } satisfies IntegrationOAuthMethodRegistration +} + +function organizationKey( + connection: { readonly type: "credential"; readonly id: string } | { readonly type: "env"; readonly name: string }, +) { + return `organization:${connection.type === "credential" ? connection.id : `env:${connection.name}`}` +} + +function toAccount(credential: Credential.Value, selection: { readonly organizationID: string | null } | undefined) { + if (credential.type === "external") return undefined + const metadata = Option.getOrUndefined(decodeMetadata(credential.metadata ?? {})) + return KiloAccount.resolve({ + token: credential.type === "oauth" ? credential.access : credential.key, + server: metadata?.server, + // The team chosen after login wins over the login default. + organizationID: selection ? selection.organizationID : metadata?.organizationID, + }) +} diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts index d7b0654a6ce6..572a9be23944 100644 --- a/packages/core/src/plugin/provider.ts +++ b/packages/core/src/plugin/provider.ts @@ -11,7 +11,7 @@ import { DynamicProviderPlugin } from "./provider/dynamic.js" import { GithubCopilotPlugin } from "./provider/github-copilot.js" import { GitLabPlugin } from "./provider/gitlab.js" import { GoogleVertexPlugin } from "./provider/google-vertex.js" -import { KiloPlugin } from "./provider/kilo.js" +import { KiloGatewayPlugin } from "../kilocode/gateway.js" // kilocode_change - Kilo Gateway plugin replaces upstream KiloPlugin import { LLMGatewayPlugin } from "./provider/llmgateway.js" import { LMStudioPlugin } from "./provider/lmstudio.js" import { ModalPlugin } from "./provider/modal.js" @@ -45,7 +45,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [ GithubCopilotPlugin, GitLabPlugin, GoogleVertexPlugin, - KiloPlugin, + KiloGatewayPlugin, // kilocode_change - replaces KiloPlugin, which sends the OpenCode referer and title LLMGatewayPlugin, LMStudioPlugin, ModalPlugin, diff --git a/packages/core/test/kilocode/gateway.test.ts b/packages/core/test/kilocode/gateway.test.ts new file mode 100644 index 000000000000..8520845a61a6 --- /dev/null +++ b/packages/core/test/kilocode/gateway.test.ts @@ -0,0 +1,279 @@ +import { describe, expect } from "bun:test" +import { fixture } from "@kilocode/gateway-core/testing" +import { Effect } from "effect" +import { Credential } from "@opencode/core/credential" +import { Bus } from "@opencode/core/bus" +import { Integration } from "@opencode/core/integration" +import { KiloGatewayPlugin } from "@opencode/core/kilocode/gateway" +import { Plugin } from "@opencode/core/plugin" +import { PluginHost } from "@opencode/core/plugin/host" +import { ProviderPlugins } from "@opencode/core/plugin/provider" +import { Provider } from "@opencode/core/provider" +import { Rpc } from "@opencode/core/rpc" +import { withEnv } from "../fixture/env" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "../plugin/fixture" + +const it = testEffect(PluginTestLayer) +const kilo = Provider.ID.make("kilo") +const integrationID = Integration.ID.make("kilo") +const methodID = Integration.MethodID.make("device") +// Keep the developer environment out of the expected headers. +const env = { + KILO_API_KEY: undefined, + KILOCODE_TOKEN: undefined, + KILO_AUTH_CONTENT: undefined, + KILO_API_URL: undefined, + KILO_AI_GATEWAY_URL: undefined, + KILO_ORG_ID: undefined, + KILOCODE_EDITOR_NAME: undefined, + KILOCODE_FEATURE: undefined, + KILOCODE_VERSION: undefined, +} +const headers = (team?: string) => ({ + "HTTP-Referer": "https://kilocode.ai", + "X-Title": "Kilo Code", + "X-KILOCODE-EDITORNAME": "Kilo CLI unknown", + "X-KILOCODE-FEATURE": "cli", + ...(team ? { "X-KILOCODE-ORGANIZATIONID": team } : {}), +}) + +// Adds the provider in the shape of the models.dev seed, then starts the plugin. +const addPlugin = Effect.fn(function* () { + const providers = yield* Provider.Service + yield* providers.transform((editor) => { + editor.update(kilo, (provider) => { + provider.integrationID = integrationID + provider.package = "@opencode/ai/providers/openai-compatible" + provider.settings = { baseURL: "https://api.kilo.ai/api/gateway" } + }) + }) + const plugin = yield* Plugin.Service + const host = yield* PluginHost.make(plugin) + yield* KiloGatewayPlugin.effect(host) + return host +}) + +const oauth = (metadata: Record) => + Credential.OAuth.make({ type: "oauth", methodID, access: "token", refresh: "token", expires: 0, metadata }) + +function eventually(effect: Effect.Effect, predicate: (value: A) => boolean, remaining = 1000) { + return Effect.gen(function* (): Effect.fn.Return { + const value = yield* effect + if (predicate(value)) return value + if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value")) + yield* Effect.promise(() => Bun.sleep(1)) + return yield* eventually(effect, predicate, remaining - 1) + }) +} + +describe("KiloGatewayPlugin", () => { + it.effect("replaces the upstream Kilo plugin", () => + Effect.sync(() => { + const ids = ProviderPlugins.map((item) => item.id) + expect(ids).toContain("kilocode.gateway") + expect(ids).not.toContain("opencode.provider.kilo") + }), + ) + + it.live("logs in with the device method and stores the server and the default team", () => + Effect.acquireUseRelease( + Effect.sync(() => { + const backend = fixture() + // Approve on the first poll so the test does not wait for the 3 second poll interval. + backend.state.pending = 0 + return backend + }), + (backend) => + withEnv({ ...env, KILO_API_URL: backend.url }, () => + Effect.gen(function* () { + yield* addPlugin() + const integrations = yield* Integration.Service + expect((yield* integrations.get(integrationID))?.methods).toContainEqual({ + id: methodID, + type: "oauth", + label: "Kilo Gateway (Device Authorization)", + }) + const attempt = yield* integrations.oauth.connect({ integrationID, methodID }) + expect(attempt.url).toBe(`${backend.url}/device-auth?code=ABCD-1234`) + expect(attempt.instructions).toBe("Enter code: ABCD-1234") + const status = yield* eventually( + integrations.oauth.status({ integrationID, attemptID: attempt.attemptID }), + (status) => status.status !== "pending", + ) + expect(status.status).toBe("complete") + const credential = (yield* (yield* Credential.Service).list(integrationID))[0] + expect(credential).toMatchObject({ + label: "device@example.test", + value: { + type: "oauth", + methodID, + access: "fixture-token", + refresh: "fixture-token", + metadata: { server: backend.url, organizationID: "selected", email: "device@example.test" }, + }, + }) + // v1 keeps the token for one year and has no refresh. + expect(credential?.value.type === "oauth" && credential.value.expires).toBeGreaterThan( + Date.now() + 364 * 24 * 60 * 60 * 1000, + ) + const provider = yield* eventually( + (yield* Provider.Service).get(kilo), + (provider) => provider?.headers?.["X-KILOCODE-ORGANIZATIONID"] === "selected", + ) + expect(provider?.settings?.baseURL).toBe(`${backend.url}/api/gateway/v1`) + }), + ), + (backend) => Effect.sync(() => backend[Symbol.dispose]()), + ), + ) + + it.live("serves free models to the anonymous key when signed out", () => + withEnv(env, () => + Effect.gen(function* () { + yield* addPlugin() + const providers = yield* Provider.Service + const provider = yield* providers.get(kilo) + expect(provider?.activation).toBe("enabled") + expect(provider?.settings).toMatchObject({ baseURL: "https://ai-gateway.kilo.ai/api/v1", apiKey: "anonymous" }) + // Kilo headers only: no OpenCode referer or title. + expect(provider?.headers).toEqual(headers()) + expect((yield* providers.available()).map((item) => item.id)).toContain(kilo) + }), + ), + ) + + it.live("follows the active credential: team header, login server, switch and logout", () => + withEnv(env, () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + const team = yield* credentials.create({ + integrationID, + label: "team", + value: oauth({ server: "https://staging.kilo.test/", organizationID: "team", email: "a@example.test" }), + }) + yield* addPlugin() + const providers = yield* Provider.Service + const signedIn = yield* providers.get(kilo) + expect(signedIn?.headers).toEqual(headers("team")) + expect(signedIn?.settings?.baseURL).toBe("https://staging.kilo.test/api/gateway") + expect(signedIn?.settings).not.toHaveProperty("apiKey") + expect(signedIn?.activation).toBe("auto") + + const personal = yield* credentials.create({ + integrationID, + label: "personal", + value: oauth({ server: "https://api.kilo.ai", organizationID: null, email: "a@example.test" }), + }) + expect( + (yield* eventually(providers.get(kilo), (provider) => !provider?.headers?.["X-KILOCODE-ORGANIZATIONID"])) + ?.headers, + ).toEqual(headers()) + + yield* credentials.remove(personal.id) + yield* credentials.remove(team.id) + const signedOut = yield* eventually(providers.get(kilo), (provider) => provider?.activation === "enabled") + expect(signedOut?.settings?.apiKey).toBe("anonymous") + }), + ), + ) + + it.live("gives KILO_API_KEY and KILO_ORG_ID precedence over a stored credential like v1", () => + withEnv({ ...env, KILO_API_KEY: "env-token", KILO_ORG_ID: "env-team" }, () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + yield* credentials.create({ + integrationID, + label: "stored", + value: oauth({ server: "https://staging.kilo.test/", organizationID: "stored-team", email: "a@example.test" }), + }) + yield* addPlugin() + const provider = yield* (yield* Provider.Service).get(kilo) + expect(provider?.headers).toEqual(headers("env-team")) + expect(provider?.settings?.apiKey).toBe("env-token") + }), + ), + ) + + it.live("reads the profile and stores the team choice per connection, as v1 does after login", () => + Effect.acquireUseRelease( + Effect.sync(() => fixture()), + (backend) => + withEnv(env, () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + yield* credentials.create({ + integrationID, + label: "a@example.test", + value: oauth({ server: backend.url, organizationID: "selected", email: "a@example.test" }), + }) + const host = yield* addPlugin() + const rpc = yield* Rpc.Service + const providers = yield* Provider.Service + expect(yield* rpc.call("kilocode.gateway", "profile", {})).toEqual({ + email: "profile@example.test", + organizations: [ + { id: "first", name: "First", role: "member" }, + { id: "selected", name: "Selected", role: "owner" }, + ], + hasPersonalAccount: true, + organizationID: "selected", + }) + expect(backend.requests.at(-1)).toEqual({ method: "GET", path: "/api/profile", authorization: "Bearer token" }) + + yield* rpc.call("kilocode.gateway", "organization.set", { organizationID: "first" }) + expect((yield* providers.get(kilo))?.headers).toEqual(headers("first")) + expect(yield* rpc.call("kilocode.gateway", "profile", {})).toMatchObject({ organizationID: "first" }) + yield* rpc.call("kilocode.gateway", "organization.set", { organizationID: null }) + expect((yield* providers.get(kilo))?.headers).toEqual(headers()) + + // Global bus broadcast reloads when another location publishes `rpc.kilocode.gateway.organization`. + yield* host.storage.set(`organization:${(yield* credentials.list(integrationID))[0]?.id}`, { + organizationID: "selected", + }) + yield* (yield* Bus.Service).publish( + Bus.ephemeral({ + type: "rpc.kilocode.gateway.organization", + schema: {}, + }), + {}, + { global: true }, + ) + expect( + (yield* eventually( + providers.get(kilo), + (provider) => provider?.headers?.["X-KILOCODE-ORGANIZATIONID"] === "selected", + ))?.headers, + ).toEqual(headers("selected")) + + // A new login is a new connection, so its own default team applies again. + yield* credentials.create({ + integrationID, + label: "b@example.test", + value: oauth({ server: backend.url, organizationID: "selected", email: "b@example.test" }), + }) + expect( + (yield* eventually(providers.get(kilo), (provider) => provider?.headers?.["X-KILOCODE-ORGANIZATIONID"] === "selected")) + ?.headers, + ).toEqual(headers("selected")) + }), + ), + (backend) => Effect.sync(() => backend[Symbol.dispose]()), + ), + ) + + it.live("rejects account calls when signed out", () => + withEnv(env, () => + Effect.gen(function* () { + yield* addPlugin() + const rpc = yield* Rpc.Service + expect(yield* rpc.call("kilocode.gateway", "profile", {}).pipe(Effect.flip)).toMatchObject({ + type: "unauthorized", + }) + expect( + yield* rpc.call("kilocode.gateway", "organization.set", { organizationID: "team" }).pipe(Effect.flip), + ).toMatchObject({ type: "unauthorized" }) + }), + ), + ) +}) diff --git a/packages/core/test/plugin/provider-kilo.test.ts b/packages/core/test/plugin/provider-kilo.test.ts index b8298f64df85..7a389cd7b01a 100644 --- a/packages/core/test/plugin/provider-kilo.test.ts +++ b/packages/core/test/plugin/provider-kilo.test.ts @@ -17,9 +17,12 @@ const addPlugin = Effect.fn(function* () { }) describe("KiloPlugin", () => { - test("is registered so legacy referer headers can be applied", () => { - expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.kilo") + // kilocode_change start - the Kilo Gateway plugin (test/kilocode/gateway.test.ts) replaces this plugin + test("is replaced by the Kilo Gateway plugin", () => { + expect(ProviderPlugins.map((item) => item.id)).not.toContain("opencode.provider.kilo") + expect(ProviderPlugins.map((item) => item.id)).toContain("kilocode.gateway") }) + // kilocode_change end it.effect("applies legacy referer headers only to Kilo endpoints", () => Effect.gen(function* () { diff --git a/packages/kilo-gateway-core/package.json b/packages/kilo-gateway-core/package.json new file mode 100644 index 000000000000..a89425403118 --- /dev/null +++ b/packages/kilo-gateway-core/package.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@kilocode/gateway-core", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./testing": "./src/testing.ts" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "test": "bun test --timeout 30000 --only-failures", + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "effect": "catalog:" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/kilo-gateway-core/src/account.ts b/packages/kilo-gateway-core/src/account.ts new file mode 100644 index 000000000000..85519f8b2756 --- /dev/null +++ b/packages/kilo-gateway-core/src/account.ts @@ -0,0 +1,55 @@ +export * as KiloAccount from "./account.js" + +import { Schema } from "effect" +import { KiloServer } from "./server.js" + +export const ANONYMOUS_KEY = "anonymous" + +export interface Input { + readonly token: string + readonly server?: string + readonly organizationID?: string | null +} + +export interface Info { + readonly token: string + readonly server: string + readonly organizationID: string | null +} + +const AuthContent = Schema.Struct({ + kilo: Schema.optional( + Schema.Struct({ + key: Schema.optional(Schema.String), + access: Schema.optional(Schema.String), + accountId: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), +}) +const decodeAuthContent = Schema.decodeUnknownOption(Schema.fromJsonString(AuthContent)) + +// Reads a token from the environment in v1 and cloud-consumer priority order: +// KILO_API_KEY > KILOCODE_TOKEN > KILO_AUTH_CONTENT. +export function envToken(env: Record = process.env): Input | undefined { + const direct = env.KILO_API_KEY || env.KILOCODE_TOKEN + if (direct) return { token: direct } + const raw = env.KILO_AUTH_CONTENT + if (!raw) return undefined + const parsed = decodeAuthContent(raw) + if (parsed._tag === "None") return undefined + const kilo = parsed.value.kilo + const token = kilo?.key || kilo?.access + if (!token) return undefined + return { token, organizationID: kilo?.accountId ?? null } +} + +// Server order: token-embedded URL (v1 `auth/token.ts`), then login server, then KILO_API_URL. +// Team order: KILO_ORG_ID, then selected or login team, then personal account. +export function resolve(input: Input, env: Record = process.env): Info { + const server = KiloServer.fromToken(input.token) ?? (input.server ? KiloServer.normalize(input.server) : KiloServer.fromEnv(env)) + return { + token: input.token, + server, + organizationID: env.KILO_ORG_ID || input.organizationID || null, + } +} diff --git a/packages/kilo-gateway-core/src/device.ts b/packages/kilo-gateway-core/src/device.ts new file mode 100644 index 000000000000..85cbbb3d3972 --- /dev/null +++ b/packages/kilo-gateway-core/src/device.ts @@ -0,0 +1,110 @@ +export * as KiloDevice from "./device.js" + +import { Effect, Option, Schema } from "effect" +import { json, request } from "./http.js" +import { KiloProfile } from "./profile.js" +import { KiloServer } from "./server.js" + +const POLL_INTERVAL_MS = 3_000 +const MAX_BACKOFF_MS = 30_000 +// v1 sets the OAuth expiry one year ahead because Kilo tokens do not use a refresh flow yet. +export const TOKEN_LIFETIME_MS = 365 * 24 * 60 * 60 * 1000 + +const Code = Schema.Struct({ + code: Schema.String, + verificationUrl: Schema.String, + expiresIn: Schema.Number, +}) + +const Poll = Schema.Struct({ + status: Schema.Literals(["pending", "approved", "denied", "expired"]), + token: Schema.optional(Schema.String), + userEmail: Schema.optional(Schema.String), +}) + +export type Code = typeof Code.Type + +export interface Login { + readonly token: string + readonly email: string + readonly organizationID: string | null +} + +const decodeCode = Schema.decodeUnknownOption(Code) +const decodePoll = Schema.decodeUnknownOption(Poll) + +export function start(server: string): Effect.Effect { + return Effect.gen(function* () { + const response = yield* request( + `${KiloServer.normalize(server)}/api/device-auth/codes`, + { method: "POST", headers: { "Content-Type": "application/json" } }, + "Failed to initiate device authorization", + ) + if (!response.ok) { + return yield* Effect.fail( + new Error( + response.status === 429 + ? "Too many pending authorization requests. Please try again later." + : `Failed to initiate device authorization: ${response.status}`, + ), + ) + } + const parsed = decodeCode(yield* json(response, "Failed to initiate device authorization")) + if (Option.isNone(parsed)) return yield* Effect.fail(new Error("Invalid Kilo response")) + // The TUI opens this URL in the user's browser; only accept HTTP(S). + if (!/^https?:\/\//.test(parsed.value.verificationUrl)) + return yield* Effect.fail(new Error(`Invalid device verification URL: ${parsed.value.verificationUrl}`)) + return parsed.value + }) +} + +export function login(server: string, code: Code, intervalMs = POLL_INTERVAL_MS): Effect.Effect { + return Effect.gen(function* () { + const approved = yield* poll(KiloServer.normalize(server), code.code, Date.now() + code.expiresIn * 1000, intervalMs, 0) + const organizationID = yield* KiloProfile.get(server, approved.token).pipe( + Effect.map(KiloProfile.defaultOrganizationID), + Effect.orElseSucceed(() => null), + ) + return { ...approved, organizationID } + }) +} + +function poll( + server: string, + code: string, + expiresAt: number, + intervalMs: number, + failures: number, +): Effect.Effect<{ token: string; email: string }, Error> { + return Effect.gen(function* () { + const remaining = expiresAt - Date.now() + if (remaining <= 0) return yield* Effect.fail(new Error("Authorization code expired")) + const wait = (next: number) => + Effect.sleep(Math.min(intervalMs * 2 ** Math.min(next, 4), MAX_BACKOFF_MS, Math.max(0, expiresAt - Date.now()))).pipe( + Effect.andThen(() => poll(server, code, expiresAt, intervalMs, next)), + ) + const response = yield* request( + `${server}/api/device-auth/codes/${encodeURIComponent(code)}`, + { headers: { "Content-Type": "application/json" } }, + "Failed to poll device authorization", + Math.min(10_000, remaining), + ).pipe(Effect.option) + // Network error, timeout, 408, 429 or 5xx: back off and keep polling until the code expires. + if (Option.isNone(response) || response.value.status === 408 || response.value.status === 429 || response.value.status >= 500) { + return yield* wait(failures + 1) + } + if (response.value.status === 202) return yield* wait(0) + if (response.value.status === 403) return yield* Effect.fail(new Error("Authorization denied by user")) + if (response.value.status === 410) return yield* Effect.fail(new Error("Authorization code expired")) + if (!response.value.ok) { + return yield* Effect.fail(new Error(`Failed to poll device authorization: ${response.value.status}`)) + } + const parsed = decodePoll(yield* json(response.value, "Failed to poll device authorization")) + if (Option.isNone(parsed)) return yield* Effect.fail(new Error("Invalid Kilo response")) + if (parsed.value.status === "pending") return yield* wait(0) + if (parsed.value.status === "denied") return yield* Effect.fail(new Error("Authorization denied by user")) + if (parsed.value.status === "expired") return yield* Effect.fail(new Error("Authorization code expired")) + if (!parsed.value.token) return yield* Effect.fail(new Error("Invalid Kilo response")) + return { token: parsed.value.token, email: parsed.value.userEmail ?? "" } + }) +} diff --git a/packages/kilo-gateway-core/src/headers.ts b/packages/kilo-gateway-core/src/headers.ts new file mode 100644 index 000000000000..0af03218374d --- /dev/null +++ b/packages/kilo-gateway-core/src/headers.ts @@ -0,0 +1,22 @@ +export * as KiloHeaders from "./headers.js" + +export const HEADER_ORGANIZATION = "X-KILOCODE-ORGANIZATIONID" +export const HEADER_EDITOR = "X-KILOCODE-EDITORNAME" +export const HEADER_FEATURE = "X-KILOCODE-FEATURE" + +export interface Options { + readonly version: string + readonly organizationID?: string | null +} + +// Provider-level attribution headers from v1 (`packages/opencode/src/kilocode/const.ts` and +// `packages/kilo-gateway/src/headers.ts`). Per-request task/project/machine headers belong to PR 3. +export function provider(options: Options, env: Record = process.env): Record { + return { + "HTTP-Referer": "https://kilocode.ai", + "X-Title": "Kilo Code", + [HEADER_EDITOR]: env.KILOCODE_EDITOR_NAME || `Kilo CLI ${env.KILOCODE_VERSION || options.version}`, + [HEADER_FEATURE]: env.KILOCODE_FEATURE || "cli", + ...(options.organizationID ? { [HEADER_ORGANIZATION]: options.organizationID } : {}), + } +} diff --git a/packages/kilo-gateway-core/src/http.ts b/packages/kilo-gateway-core/src/http.ts new file mode 100644 index 000000000000..12d2315b3f24 --- /dev/null +++ b/packages/kilo-gateway-core/src/http.ts @@ -0,0 +1,23 @@ +import { Effect } from "effect" + +const TIMEOUT_MS = 10_000 + +// Runs `fetch` with a request timeout and aborts it when the Effect fiber is interrupted. +export function request(url: string, init: RequestInit, label: string, timeoutMs = TIMEOUT_MS) { + return Effect.tryPromise({ + try: (signal) => fetch(url, { ...init, signal: AbortSignal.any([signal, AbortSignal.timeout(Math.max(1, timeoutMs))]) }), + catch: (cause) => + new Error( + cause instanceof Error && cause.name === "TimeoutError" + ? `${label}: request timed out` + : `${label}: ${cause instanceof Error ? cause.message : String(cause)}`, + ), + }) +} + +export function json(response: Response, label: string): Effect.Effect { + return Effect.tryPromise({ + try: () => response.json(), + catch: () => new Error(`${label}: response was not JSON`), + }) +} diff --git a/packages/kilo-gateway-core/src/index.ts b/packages/kilo-gateway-core/src/index.ts new file mode 100644 index 000000000000..5efdea3170a8 --- /dev/null +++ b/packages/kilo-gateway-core/src/index.ts @@ -0,0 +1,5 @@ +export * as KiloAccount from "./account.js" +export * as KiloDevice from "./device.js" +export * as KiloHeaders from "./headers.js" +export * as KiloProfile from "./profile.js" +export * as KiloServer from "./server.js" diff --git a/packages/kilo-gateway-core/src/profile.ts b/packages/kilo-gateway-core/src/profile.ts new file mode 100644 index 000000000000..aa6d0a2523d9 --- /dev/null +++ b/packages/kilo-gateway-core/src/profile.ts @@ -0,0 +1,77 @@ +export * as KiloProfile from "./profile.js" + +import { Effect, Option, Schema } from "effect" +import { json, request } from "./http.js" +import { KiloServer } from "./server.js" + +const Organization = Schema.Struct({ + id: Schema.String, + name: Schema.String, + role: Schema.optional(Schema.String), +}) + +// `/api/profile` has shipped with `email` and `name` either under `user` or at the top level. +const Response = Schema.Struct({ + user: Schema.optional( + Schema.Struct({ + email: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + email: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.NullOr(Schema.String)), + organizations: Schema.optional(Schema.NullOr(Schema.Array(Organization))), + selectedOrganizationId: Schema.optional(Schema.NullOr(Schema.String)), + hasPersonalAccount: Schema.optional(Schema.NullOr(Schema.Boolean)), +}) + +export type Organization = typeof Organization.Type + +export interface Info { + readonly email: string + readonly name?: string + readonly organizations: readonly Organization[] + readonly selectedOrganizationId?: string + readonly hasPersonalAccount?: boolean +} + +const decode = Schema.decodeUnknownOption(Response) + +export function get(server: string, token: string): Effect.Effect { + return Effect.gen(function* () { + const response = yield* request( + KiloServer.apiUrl(server, "profile"), + { headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" } }, + "Profile fetch failed", + ) + if (response.status === 401 || response.status === 403) { + return yield* Effect.fail(new Error("Invalid token")) + } + if (!response.ok) { + return yield* Effect.fail(new Error(`Profile fetch failed with status ${response.status}`)) + } + const parsed = decode(yield* json(response, "Profile fetch failed")) + if (Option.isNone(parsed)) return yield* Effect.fail(new Error("Invalid Kilo profile response")) + const body = parsed.value + const name = body.user?.name ?? body.name ?? undefined + return { + email: body.user?.email ?? body.email ?? "", + ...(name ? { name } : {}), + organizations: body.organizations ?? [], + ...(body.selectedOrganizationId ? { selectedOrganizationId: body.selectedOrganizationId } : {}), + ...(typeof body.hasPersonalAccount === "boolean" ? { hasPersonalAccount: body.hasPersonalAccount } : {}), + } + }) +} + +// Same rule as v1 `defaultOrganizationId` (`packages/kilo-gateway/src/api/profile.ts`): +// prefer the team selected on the backend; otherwise use the first team only when the +// account has no personal account. +export function defaultOrganizationID(profile: Info): string | null { + const ids = new Set(profile.organizations.map((org) => org.id)) + if (profile.selectedOrganizationId && ids.has(profile.selectedOrganizationId)) { + return profile.selectedOrganizationId + } + if (profile.hasPersonalAccount === false) return profile.organizations[0]?.id ?? null + return null +} diff --git a/packages/kilo-gateway-core/src/server.ts b/packages/kilo-gateway-core/src/server.ts new file mode 100644 index 000000000000..af699bacbe65 --- /dev/null +++ b/packages/kilo-gateway-core/src/server.ts @@ -0,0 +1,72 @@ +export * as KiloServer from "./server.js" + +export const DEFAULT_API_URL = "https://api.kilo.ai" +export const DEFAULT_AI_GATEWAY_URL = "https://ai-gateway.kilo.ai/api/v1" +// Same regex as v1 `getKiloUrlFromToken` (`packages/kilo-gateway/src/auth/token.ts`): +// matches `http(s)://host(:port)(/path):secret` without greedily consuming colons inside the secret. +const TOKEN = /^(https?:\/\/[^:/]+(?::\d+)?(?:\/[^:]*)?):(?!\d+$).+/ + +export function fromEnv(env: Record = process.env) { + return normalize(env.KILO_API_URL?.trim() || DEFAULT_API_URL) +} + +// Same as `resolveKiloAiGatewayBase` on main (`packages/kilo-gateway/src/api/constants.ts`): +// KILO_AI_GATEWAY_URL takes precedence; when only KILO_API_URL is set, gateway traffic goes to +// `${KILO_API_URL}/api/gateway/v1`; otherwise defaults to `https://ai-gateway.kilo.ai/api/v1`. +export function aiGatewayFromEnv(env: Record = process.env) { + const gatewayUrl = env.KILO_AI_GATEWAY_URL?.trim() + if (gatewayUrl) return normalize(gatewayUrl) + const apiUrl = env.KILO_API_URL?.trim() + if (apiUrl) return `${normalize(apiUrl)}/api/gateway/v1` + return DEFAULT_AI_GATEWAY_URL +} + +// Development tokens can carry the backend URL before the token secret, as in v1 (`auth/token.ts`). +export function fromToken(token: string) { + const match = TOKEN.exec(token) + return match?.[1] && URL.canParse(match[1]) ? normalize(match[1]) : undefined +} + +export function normalize(input: string) { + const url = parse(input) + return `${url.origin}${url.pathname}`.replace(/\/+$/, "") +} + +function parse(input: string) { + if (!URL.canParse(input)) throw new Error(`Invalid Kilo API URL: ${input}`) + const url = new URL(input) + if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`Invalid Kilo API URL: ${input}`) + return url +} + +// Builds an `/api/` route on `raw`, stripping any existing `/api/...` suffix first +// (same prefix rule as `route` in `packages/kilo-gateway/src/api/url.ts`). +export function apiUrl(raw: string, ...segments: string[]) { + const url = parse(raw) + url.search = "" + url.hash = "" + const parts = url.pathname.split("/").filter(Boolean) + const api = parts.lastIndexOf("api") + const prefix = api >= 0 ? parts.slice(0, api) : parts + url.pathname = `/${[...prefix, "api", ...segments].join("/")}` + return url.toString().replace(/\/+$/, "") +} + +export function isConfiguredBase( + server: string | undefined, + env: Record = process.env, +): boolean { + if (!server) return true + const normalized = normalize(server) + return normalized === fromEnv(env) || normalized === aiGatewayFromEnv(env) +} + +// Resolves the AI gateway base URL for inference, matching `resolveKiloGatewayBaseUrl` / `resolveKiloOpenRouterBaseUrl` +// on main (`packages/kilo-gateway/src/api/url.ts`): +// - Uses `aiGatewayFromEnv(env)` (`https://ai-gateway.kilo.ai/api/v1` by default, or `${KILO_API_URL}/api/gateway/v1`, +// or `KILO_AI_GATEWAY_URL`) when `server` is the configured base. +// - Keeps legacy API app routing (`${prefix}/api/gateway`) for custom or token-embedded server URLs. +export function gateway(server?: string, env: Record = process.env) { + if (isConfiguredBase(server, env)) return aiGatewayFromEnv(env) + return apiUrl(server!, "gateway") +} diff --git a/packages/kilo-gateway-core/src/testing.ts b/packages/kilo-gateway-core/src/testing.ts new file mode 100644 index 000000000000..6642d44c3fde --- /dev/null +++ b/packages/kilo-gateway-core/src/testing.ts @@ -0,0 +1,55 @@ +// Local stand-in for the Kilo API. It answers the device auth and profile routes in the shape of the +// deployed Gateway and records each request. +export function fixture() { + const state = { + createStatus: 200, + expiresIn: 60, + verificationUrl: undefined as string | undefined, + pending: 1, + // Statuses that the poll route returns first, one per request, before it answers normally. + pollErrors: [] as number[], + // The poll route never answers. + hang: false, + pollStatus: 200, + approved: { status: "approved", token: "fixture-token", userEmail: "device@example.test" } as unknown, + profileStatus: 200, + profile: { + user: { email: "profile@example.test", name: "Fixture" }, + organizations: [ + { id: "first", name: "First", role: "member" }, + { id: "selected", name: "Selected", role: "owner" }, + ], + selectedOrganizationId: "selected", + hasPersonalAccount: true, + } as unknown, + } + const requests: { method: string; path: string; authorization: string | null }[] = [] + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + const path = new URL(request.url).pathname + requests.push({ method: request.method, path, authorization: request.headers.get("authorization") }) + if (path === "/api/device-auth/codes") { + return Response.json( + { + code: "ABCD-1234", + verificationUrl: state.verificationUrl ?? new URL("/device-auth?code=ABCD-1234", request.url).href, + expiresIn: state.expiresIn, + }, + { status: state.createStatus }, + ) + } + if (path === "/api/device-auth/codes/ABCD-1234") { + if (state.hang) return new Promise(() => {}) + const error = state.pollErrors.shift() + if (error) return new Response(null, { status: error }) + if (state.pending-- > 0) return new Response(null, { status: 202 }) + return Response.json(state.approved, { status: state.pollStatus }) + } + if (path === "/api/profile") return Response.json(state.profile, { status: state.profileStatus }) + return new Response(null, { status: 404 }) + }, + }) + return { state, requests, url: server.url.origin, [Symbol.dispose]: () => server.stop(true) } +} diff --git a/packages/kilo-gateway-core/test/account.test.ts b/packages/kilo-gateway-core/test/account.test.ts new file mode 100644 index 000000000000..758c66d99bc0 --- /dev/null +++ b/packages/kilo-gateway-core/test/account.test.ts @@ -0,0 +1,93 @@ +import { expect, test } from "bun:test" +import { KiloAccount, KiloHeaders, KiloServer } from "../src/index.js" + +test("normalizes the server and resolves KILO_API_URL and KILO_AI_GATEWAY_URL like main", () => { + expect(KiloServer.fromEnv({})).toBe("https://api.kilo.ai") + expect(KiloServer.fromEnv({ KILO_API_URL: "http://localhost:3000/" })).toBe("http://localhost:3000") + expect(KiloServer.normalize("https://example.test/prefix//?query#hash")).toBe("https://example.test/prefix") + expect(() => KiloServer.normalize("ftp://example.test")).toThrow("Invalid Kilo API URL") + expect(() => KiloServer.fromEnv({ KILO_API_URL: "not a url" })).toThrow("Invalid Kilo API URL") + expect(KiloServer.aiGatewayFromEnv({})).toBe("https://ai-gateway.kilo.ai/api/v1") + expect(KiloServer.aiGatewayFromEnv({ KILO_API_URL: "http://localhost:3000/" })).toBe( + "http://localhost:3000/api/gateway/v1", + ) + expect( + KiloServer.aiGatewayFromEnv({ + KILO_API_URL: "http://localhost:3000", + KILO_AI_GATEWAY_URL: "http://localhost:3001/api/v1/", + }), + ).toBe("http://localhost:3001/api/v1") + expect(KiloServer.gateway("https://api.kilo.ai", {})).toBe("https://ai-gateway.kilo.ai/api/v1") + expect(KiloServer.gateway("http://localhost:3000", { KILO_API_URL: "http://localhost:3000" })).toBe( + "http://localhost:3000/api/gateway/v1", + ) + expect(KiloServer.gateway("https://staging.kilo.test", {})).toBe("https://staging.kilo.test/api/gateway") +}) + +test("reads the server from a token like v1", () => { + expect(KiloServer.fromToken("http://localhost:3000:secret")).toBe("http://localhost:3000") + expect(KiloServer.fromToken("http://localhost:3000:abc:def")).toBe("http://localhost:3000") + expect(KiloServer.fromToken("http://localhost:3000")).toBeUndefined() + expect(KiloServer.fromToken("https://staging.example.test/base:secret")).toBe("https://staging.example.test/base") + expect(KiloServer.fromToken("eyJhbGciOiJIUzI1NiJ9.payload.signature")).toBeUndefined() +}) + +test("reads environment tokens in v1 and cloud-consumer order", () => { + expect(KiloAccount.envToken({})).toBeUndefined() + expect(KiloAccount.envToken({ KILO_API_KEY: "api-key", KILOCODE_TOKEN: "cloud-token" })).toEqual({ + token: "api-key", + }) + expect(KiloAccount.envToken({ KILOCODE_TOKEN: "cloud-token" })).toEqual({ + token: "cloud-token", + }) + expect( + KiloAccount.envToken({ + KILO_AUTH_CONTENT: JSON.stringify({ kilo: { access: "content-token", accountId: "content-team" } }), + }), + ).toEqual({ + token: "content-token", + organizationID: "content-team", + }) +}) + +test("resolves the account server and team in v1 order", () => { + expect(KiloAccount.resolve({ token: "plain" }, {})).toEqual({ + token: "plain", + server: "https://api.kilo.ai", + organizationID: null, + }) + expect( + KiloAccount.resolve({ token: "plain", server: "https://login.example.test/", organizationID: "team" }, { + KILO_API_URL: "https://env.example.test", + }), + ).toEqual({ token: "plain", server: "https://login.example.test", organizationID: "team" }) + expect( + KiloAccount.resolve({ token: "http://localhost:3000:secret", server: "https://login.example.test" }, { + KILO_ORG_ID: "env-team", + }), + ).toEqual({ token: "http://localhost:3000:secret", server: "http://localhost:3000", organizationID: "env-team" }) +}) + +test("builds Kilo provider headers without OpenCode attribution", () => { + expect(KiloHeaders.provider({ version: "2.0.0", organizationID: "team" }, {})).toEqual({ + "HTTP-Referer": "https://kilocode.ai", + "X-Title": "Kilo Code", + "X-KILOCODE-EDITORNAME": "Kilo CLI 2.0.0", + "X-KILOCODE-FEATURE": "cli", + "X-KILOCODE-ORGANIZATIONID": "team", + }) + expect( + KiloHeaders.provider( + { version: "2.0.0", organizationID: null }, + { KILOCODE_EDITOR_NAME: "Visual Studio Code 1.110.0", KILOCODE_FEATURE: "vscode-extension" }, + ), + ).toEqual({ + "HTTP-Referer": "https://kilocode.ai", + "X-Title": "Kilo Code", + "X-KILOCODE-EDITORNAME": "Visual Studio Code 1.110.0", + "X-KILOCODE-FEATURE": "vscode-extension", + }) + expect(KiloHeaders.provider({ version: "local" }, { KILOCODE_VERSION: "7.1.0" })["X-KILOCODE-EDITORNAME"]).toBe( + "Kilo CLI 7.1.0", + ) +}) diff --git a/packages/kilo-gateway-core/test/boundary.test.ts b/packages/kilo-gateway-core/test/boundary.test.ts new file mode 100644 index 000000000000..269decb7ee08 --- /dev/null +++ b/packages/kilo-gateway-core/test/boundary.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from "bun:test" +import path from "node:path" + +// Kilo Gateway logic must stay independent of OpenCode. Only the v2 adapter in packages/core imports both. +test("has no OpenCode imports or dependencies", async () => { + const root = path.join(import.meta.dir, "..") + const files = await Array.fromAsync(new Bun.Glob("src/**/*.ts").scan({ cwd: root })) + expect(files.length).toBeGreaterThan(0) + const offenders = await Promise.all( + files.map(async (file) => ((await Bun.file(path.join(root, file)).text()).includes("@opencode") ? file : undefined)), + ) + expect(offenders.filter(Boolean)).toEqual([]) + const pkg = await Bun.file(path.join(root, "package.json")).json() + expect(Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).filter((name) => name.startsWith("@opencode"))).toEqual([]) +}) diff --git a/packages/kilo-gateway-core/test/device.test.ts b/packages/kilo-gateway-core/test/device.test.ts new file mode 100644 index 000000000000..3eecd2a3ccac --- /dev/null +++ b/packages/kilo-gateway-core/test/device.test.ts @@ -0,0 +1,96 @@ +import { expect, test } from "bun:test" +import { Effect, Fiber } from "effect" +import { KiloDevice } from "../src/index.js" +import { fixture } from "../src/testing.js" + +const login = (url: string) => + Effect.runPromise( + KiloDevice.start(url).pipe(Effect.flatMap((code) => KiloDevice.login(url, code, 1))), + ) + +test("logs in with the device protocol and selects the backend team", async () => { + using backend = fixture() + expect(await login(backend.url)).toEqual({ + token: "fixture-token", + email: "device@example.test", + organizationID: "selected", + }) + expect(backend.requests).toEqual([ + { method: "POST", path: "/api/device-auth/codes", authorization: null }, + { method: "GET", path: "/api/device-auth/codes/ABCD-1234", authorization: null }, + { method: "GET", path: "/api/device-auth/codes/ABCD-1234", authorization: null }, + { method: "GET", path: "/api/profile", authorization: "Bearer fixture-token" }, + ]) +}) + +test("starts on the personal account when the profile request fails", async () => { + using backend = fixture() + backend.state.profileStatus = 500 + expect((await login(backend.url)).organizationID).toBeNull() +}) + +for (const [status, body, message] of [ + [403, {}, "denied"], + [410, {}, "expired"], + [200, { status: "denied" }, "denied"], + [200, { status: "approved" }, "Invalid Kilo response"], +] as const) { + test(`poll HTTP ${status} ${JSON.stringify(body)} fails without a profile request`, async () => { + using backend = fixture() + backend.state.pollStatus = status + backend.state.approved = body + await expect(login(backend.url)).rejects.toThrow(message) + expect(backend.requests.some((item) => item.path === "/api/profile")).toBe(false) + }) +} + +test("reports rate limits and rejects a verification URL that is not HTTP(S)", async () => { + using backend = fixture() + backend.state.createStatus = 429 + await expect(login(backend.url)).rejects.toThrow("Too many pending authorization requests") + backend.state.createStatus = 200 + backend.state.verificationUrl = "javascript:alert(1)" + await expect(login(backend.url)).rejects.toThrow("Invalid device verification URL") +}) + +test("retries temporary poll failures until the user approves", async () => { + using backend = fixture() + backend.state.pollErrors = [503, 429, 408, 500] + expect((await login(backend.url)).token).toBe("fixture-token") + expect(backend.requests.filter((item) => item.path === "/api/device-auth/codes/ABCD-1234")).toHaveLength(6) +}) + +test("ends at the code expiry when the Gateway keeps failing or never answers", async () => { + using backend = fixture() + backend.state.expiresIn = 0.05 + backend.state.pending = Number.MAX_SAFE_INTEGER + await expect(login(backend.url)).rejects.toThrow("expired") + + backend.state.pollErrors = Array(1000).fill(503) + await expect(login(backend.url)).rejects.toThrow("expired") + + backend.state.hang = true + const started = Date.now() + await expect(login(backend.url)).rejects.toThrow("expired") + // The request timeout follows the remaining code lifetime, so a hung request cannot outlive the code. + expect(Date.now() - started).toBeLessThan(1000) +}) + +test("stops polling when interrupted", async () => { + using backend = fixture() + backend.state.pending = Number.MAX_SAFE_INTEGER + await Effect.runPromise( + Effect.gen(function* () { + const code = yield* KiloDevice.start(backend.url) + const fiber = yield* KiloDevice.login(backend.url, code, 1).pipe(Effect.forkScoped({ startImmediately: true })) + yield* Effect.sleep(30) + // With a 1 ms interval, a loop that survived the interruption would add requests below. + expect(backend.requests.length).toBeGreaterThan(3) + yield* Fiber.interrupt(fiber) + yield* Effect.sleep(10) + const count = backend.requests.length + yield* Effect.sleep(50) + expect(backend.requests).toHaveLength(count) + }).pipe(Effect.scoped), + ) +}) diff --git a/packages/kilo-gateway-core/test/profile.test.ts b/packages/kilo-gateway-core/test/profile.test.ts new file mode 100644 index 000000000000..ae5add254e53 --- /dev/null +++ b/packages/kilo-gateway-core/test/profile.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "bun:test" +import { Effect } from "effect" +import { KiloProfile } from "../src/index.js" +import { fixture } from "../src/testing.js" + +const teams = [ + { id: "first", name: "First" }, + { id: "second", name: "Second" }, +] + +test("reads nested and flat profile responses", async () => { + using backend = fixture() + expect(await Effect.runPromise(KiloProfile.get(backend.url, "token"))).toEqual({ + email: "profile@example.test", + name: "Fixture", + organizations: [ + { id: "first", name: "First", role: "member" }, + { id: "selected", name: "Selected", role: "owner" }, + ], + selectedOrganizationId: "selected", + hasPersonalAccount: true, + }) + backend.state.profile = { email: "flat@example.test", selectedOrganizationId: null, hasPersonalAccount: null } + expect(await Effect.runPromise(KiloProfile.get(backend.url, "token"))).toEqual({ + email: "flat@example.test", + organizations: [], + }) + backend.state.profileStatus = 401 + await expect(Effect.runPromise(KiloProfile.get(backend.url, "token"))).rejects.toThrow("Invalid token") +}) + +test("selects the default team like v1", () => { + const profile = { email: "a@example.test", organizations: teams } + expect(KiloProfile.defaultOrganizationID({ ...profile, selectedOrganizationId: "second" })).toBe("second") + // A selected team that the account no longer belongs to is ignored. + expect(KiloProfile.defaultOrganizationID({ ...profile, selectedOrganizationId: "missing" })).toBeNull() + expect(KiloProfile.defaultOrganizationID(profile)).toBeNull() + expect(KiloProfile.defaultOrganizationID({ ...profile, hasPersonalAccount: false })).toBe("first") + expect(KiloProfile.defaultOrganizationID({ email: "a@example.test", organizations: [], hasPersonalAccount: false })).toBeNull() +}) diff --git a/packages/kilo-gateway-core/tsconfig.build.json b/packages/kilo-gateway-core/tsconfig.build.json new file mode 100644 index 000000000000..0663660641ad --- /dev/null +++ b/packages/kilo-gateway-core/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "noEmit": false, + "declaration": true, + "allowImportingTsExtensions": false + }, + "include": ["src"] +} diff --git a/packages/kilo-gateway-core/tsconfig.json b/packages/kilo-gateway-core/tsconfig.json new file mode 100644 index 000000000000..3bf882840f0b --- /dev/null +++ b/packages/kilo-gateway-core/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "noUnusedLocals": true, + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false + } +} diff --git a/packages/sdk/script/verify-package.ts b/packages/sdk/script/verify-package.ts index 4d1b60111403..6a56aaee3975 100644 --- a/packages/sdk/script/verify-package.ts +++ b/packages/sdk/script/verify-package.ts @@ -16,6 +16,7 @@ const names = [ "client", "plugin", "plugin-browser", + "kilo-gateway-core", // kilocode_change - Kilo Gateway logic, a runtime dependency of core "core", "simulation", "server", diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 845a408c7959..95a5dffeafa8 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -27,6 +27,7 @@ import { DialogSelect } from "../ui/dialog-select" import { Link } from "../ui/link" import { useToast } from "../ui/toast" import { errorMessage } from "../util/error" +import { chooseKiloAccount } from "../kilocode/gateway" // kilocode_change - Kilo account choice after login import { formInitialValues, formLabel, @@ -1235,6 +1236,9 @@ async function connected( data.location.provider.sync(location), ]) toast.show({ variant: "success", message: `Connected ${integration.name}` }) + // kilocode_change start - after a Kilo login, choose the personal account or a team, as in v1 + if (integration.id === "kilo" && !(await chooseKiloAccount(dialog, location))) return + // kilocode_change end if (onConnected && integration.metadata?.source !== "mcp") { onConnected(providerID(data, location, integration.id)) return diff --git a/packages/tui/src/kilocode/gateway.tsx b/packages/tui/src/kilocode/gateway.tsx new file mode 100644 index 000000000000..e766cebaf7d5 --- /dev/null +++ b/packages/tui/src/kilocode/gateway.tsx @@ -0,0 +1,161 @@ +// Kilo account choice in the TUI: the personal account or a team, as in v1. The `kilocode.gateway` core +// plugin stores the choice and sends it as the team header. +import type { LocationRef } from "@opencode/client" +import { KiloGatewayRpc } from "@opencode/core/kilocode/gateway-rpc" +import { Plugin } from "@opencode/plugin/tui" +import { createSignal, onMount, Show } from "solid-js" +import { useClient } from "../context/client" +import { useTheme } from "../context/theme" +import type { useDialog } from "../ui/dialog" +import { DialogSelect } from "../ui/dialog-select" +import { useToast } from "../ui/toast" + +type Profile = typeof KiloGatewayRpc.Definition.methods.profile.output.Type + +/** + * After a Kilo login, asks which account requests use. Same flow as v1 `KiloAutoMethod`: no picker + * without teams, the first team preselected, and the personal account when the profile request fails. + * Resolves `false` when the user closes the dialog. + */ +export function chooseKiloAccount(dialog: ReturnType, location: LocationRef) { + return new Promise((resolve) => { + dialog.replace(() => , () => resolve(false)) + }) +} + +function DialogKiloAccount(props: { location: LocationRef; onDone: (proceed: boolean) => void }) { + const client = useClient() + const toast = useToast() + const theme = useTheme().surface("dialog") + const rpc = client.api.rpc(KiloGatewayRpc.Definition) + const options = { location: { directory: props.location.directory } } + const [profile, setProfile] = createSignal() + + onMount(() => { + rpc.profile({}, options).then( + (result) => (result.organizations.length > 0 ? setProfile(result) : props.onDone(true)), + () => { + toast.show({ variant: "warning", message: "Couldn't fetch organizations, using personal account" }) + props.onDone(true) + }, + ) + }) + + // The root must be an element, not . A function root makes the dialog run this component again + // whenever `profile` changes. + return ( + + + Fetching organizations... + + } + > + {(profile) => ( + + void rpc["organization.set"]({ organizationID: option.value }, options).then( + () => props.onDone(true), + () => { + toast.show({ + variant: "warning", + message: "Failed to set organization; continuing with current account", + }) + props.onDone(true) + }, + ) + } + /> + )} + + + ) +} + +// Same options as v1 `getOrganizationOptions`. `current` marks the account that requests use now. +export function accountOptions(profile: Profile, current: string | null) { + return [ + ...(profile.hasPersonalAccount || profile.organizations.length === 0 + ? [ + { + title: "Personal Account", + value: null, + description: current === null ? "→ (current)" : undefined, + category: "Accounts", + }, + ] + : []), + ...profile.organizations.map((organization) => ({ + title: organization.name, + value: organization.id as string | null, + description: + organization.id === current ? ["→ (current)", organization.role].filter(Boolean).join(" ") : organization.role, + category: "Teams", + })), + ] +} + +// `/teams`, as in v1: switch between the personal account and the teams of the signed-in account. +export default Plugin.define({ + id: "kilocode.teams", + setup(context) { + context.ui.slot({ + append: "app", + render() { + context.keymap.layer(() => ({ + mode: "global", + commands: [ + { + id: "kilo.teams", + title: "Teams", + description: "Switch between Kilo Gateway teams", + group: "Kilo", + palette: true, + slash: { name: "teams", aliases: ["team", "org", "orgs"] }, + enabled: () => + (context.data.location.integration.list() ?? []).some( + (integration) => integration.id === "kilo" && integration.connections.length > 0, + ), + run: () => teams(context), + }, + ], + })) + return null + }, + }) + }, +}) + +async function teams(context: Plugin.Context) { + const rpc = context.client.rpc(KiloGatewayRpc.Definition) + const options = { location: { directory: (context.location ?? context.data.location.default()).directory } } + const profile = await rpc.profile({}, options).catch(() => undefined) + if (!profile) + return context.ui.dialog.alert({ + title: "Error", + message: "Failed to fetch teams. Please ensure you're authenticated with Kilo Gateway.", + }) + if (profile.organizations.length === 0) + return context.ui.dialog.alert({ + title: "No Teams Available", + message: "You're not a member of any teams.\nVisit https://app.kilo.ai to create or join a team.", + }) + const choice = await context.ui.dialog.select({ + title: "Select Team", + options: accountOptions(profile, profile.organizationID), + current: profile.organizationID, + }) + if (choice === undefined) return + const switched = await rpc["organization.set"]({ organizationID: choice }, options).then( + () => true, + () => false, + ) + if (!switched) return context.ui.toast.show({ variant: "error", message: "Failed to switch team" }) + const name = choice ? profile.organizations.find((organization) => organization.id === choice)?.name : "Personal" + context.ui.toast.show({ variant: "success", message: `Switched to: ${name}` }) +} diff --git a/packages/tui/src/plugin/builtins.ts b/packages/tui/src/plugin/builtins.ts index c9b86c3fbef9..d3355566f707 100644 --- a/packages/tui/src/plugin/builtins.ts +++ b/packages/tui/src/plugin/builtins.ts @@ -11,6 +11,7 @@ import Storybook from "../feature-plugins/system/storybook" import Stats from "../feature-plugins/system/stats" import Latex from "@opencode/latex/plugin" import Merman from "@opencode/merman/plugin" +import KiloTeams from "../kilocode/gateway" // kilocode_change - Kilo /teams command export const builtins = [ HomeFooter, @@ -28,4 +29,5 @@ export const builtins = [ // normal launches and register it only for OPENCODE_STORY runs. ...(process.env.OPENCODE_STORY ? [Storybook] : []), DiffViewer, + KiloTeams, // kilocode_change ] diff --git a/packages/tui/test/kilocode/gateway.test.ts b/packages/tui/test/kilocode/gateway.test.ts new file mode 100644 index 000000000000..1799c65916cf --- /dev/null +++ b/packages/tui/test/kilocode/gateway.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "bun:test" +import { accountOptions } from "../../src/kilocode/gateway" + +const organizations = [ + { id: "acme", name: "Acme", role: "owner" }, + { id: "beta", name: "Beta" }, +] + +test("lists the personal account and the teams like v1, and marks the current account", () => { + expect(accountOptions({ email: "a@example.test", organizations, hasPersonalAccount: true, organizationID: null }, "acme")) + .toEqual([ + { title: "Personal Account", value: null, description: undefined, category: "Accounts" }, + { title: "Acme", value: "acme", description: "→ (current) owner", category: "Teams" }, + { title: "Beta", value: "beta", description: undefined, category: "Teams" }, + ]) + expect( + accountOptions({ email: "a@example.test", organizations, hasPersonalAccount: true, organizationID: null }, null)[0], + ).toMatchObject({ title: "Personal Account", description: "→ (current)" }) +}) + +test("hides the personal account for team-only accounts, unless there are no teams", () => { + const profile = { email: "a@example.test", organizations, hasPersonalAccount: false, organizationID: "beta" } + expect(accountOptions(profile, "beta").map((option) => option.title)).toEqual(["Acme", "Beta"]) + expect(accountOptions({ ...profile, organizations: [] }, null).map((option) => option.title)).toEqual([ + "Personal Account", + ]) +}) diff --git a/script/dev-isolated.ts b/script/dev-isolated.ts index 6522ebe10e80..e83fecec0401 100644 --- a/script/dev-isolated.ts +++ b/script/dev-isolated.ts @@ -1,7 +1,6 @@ // `bun dev` on the v2 branch: runs the CLI from this checkout with a profile that is separate from // installed Kilo and OpenCode. Data, config, state, cache, home and tmp all live under one directory, // so no global store, managed service or database outside it is read or changed. -import os from "node:os" import path from "node:path" import { mkdir } from "node:fs/promises" @@ -9,8 +8,7 @@ const root = path.resolve(import.meta.dir, "..") const profile = path.resolve(process.env.KILO_V2_PROFILE ?? path.join(root, ".kilo-v2-dev")) const dirs = ["data", "config", "state", "cache", "home", "tmp"].map((name) => path.join(profile, name)) await Promise.all(dirs.map((dir) => mkdir(dir, { recursive: true }))) -const login = await kiloLogin() -console.error(`kilo v2 dev profile: ${profile}${login.KILO_API_KEY ? " (Kilo login from v1 Kilo)" : ""}`) +console.error(`kilo v2 dev profile: ${profile}`) const child = Bun.spawn( [ @@ -25,7 +23,10 @@ const child = Bun.spawn( cwd: process.env.INIT_CWD ?? process.cwd(), env: { ...process.env, - ...login, + // Default to the free Kilo model unless the caller passes its own config. + ...(process.env.OPENCODE_CONFIG_CONTENT + ? {} + : { OPENCODE_CONFIG_CONTENT: JSON.stringify({ model: "kilo/kilo-auto/free" }) }), XDG_DATA_HOME: dirs[0], XDG_CONFIG_HOME: dirs[1], XDG_STATE_HOME: dirs[2], @@ -40,29 +41,3 @@ const child = Bun.spawn( }, ) process.exit(await child.exited) - -// Development only: reuse the v1 Kilo login so the Kilo Gateway works without a separate sign-in, -// billed to the selected team like v1. The v1 auth file is only read. KILO_API_KEY in the -// environment wins. Product login (device auth, team switching) replaces this later. -async function kiloLogin(): Promise> { - const config = (headers: Record) => - process.env.OPENCODE_CONFIG_CONTENT - ? {} - : { - OPENCODE_CONFIG_CONTENT: JSON.stringify({ - model: "kilo/kilo-auto/free", - provider: { kilo: { options: { headers } } }, - }), - } - if (process.env.KILO_API_KEY) return config({}) - const file = Bun.file( - path.join(process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share"), "kilo", "auth.json"), - ) - if (!(await file.exists())) return config({}) - const auth = (await file.json()).kilo - if (auth?.type !== "oauth" || typeof auth.access !== "string") return config({}) - return { - KILO_API_KEY: auth.access, - ...config(typeof auth.accountId === "string" ? { "X-KILOCODE-ORGANIZATIONID": auth.accountId } : {}), - } -} diff --git a/script/upstream/shared-patches.json b/script/upstream/shared-patches.json index f7a2b85a6a36..03281df3a259 100644 --- a/script/upstream/shared-patches.json +++ b/script/upstream/shared-patches.json @@ -1,9 +1,9 @@ { "$comment": "Shared-patch baseline (U-L3-01). Read by script/check-shared-patches.ts and script/check-opencode-annotations.ts. Each shared upstream file that Kilo adds or changes needs an entry: rung (3 = seam patch, 4 = feature patch) and the missing seam. `unmarked` allows lines without markers and states why. Raise a limit only in the PR that adds the patch, and lower it when a patch goes away.", "limits": { - "files": 5, - "markedLines": 20, - "unmarkedLines": 13, + "files": 12, + "markedLines": 34, + "unmarkedLines": 17, "deletedFiles": 0 }, "files": { @@ -25,9 +25,39 @@ "seam": "Root `dev` script runs script/dev-isolated.ts, so `bun dev` never uses the installed OpenCode or Kilo data. Goes away when the Kilo host owns the dev entry (U-E0-01).", "unmarked": "JSON has no comments." }, + "packages/core/package.json": { + "rung": 3, + "seam": "Runtime dependency on `@kilocode/gateway-core` for the built-in `KiloGatewayPlugin` adapter (`packages/core/src/kilocode/gateway.ts`). Goes away when the Kilo host registers the Gateway plugin outside `@opencode/core` (U-E0-01).", + "unmarked": "JSON has no comments." + }, + "packages/core/src/plugin/provider.ts": { + "rung": 3, + "seam": "Replace upstream `KiloPlugin` (which stamps OpenCode referer and title headers) with `KiloGatewayPlugin` (`packages/core/src/kilocode/gateway.ts`) until the Kilo host registers built-in Kilo plugins via `SdkPlugins.register` (U-E0-01)." + }, + "packages/core/test/plugin/provider-kilo.test.ts": { + "rung": 3, + "seam": "Update the built-in `ProviderPlugins` assertion to expect `kilocode.gateway` in place of `opencode.provider.kilo`." + }, "packages/core/test/tool-execute.test.ts": { "rung": 4, "seam": "Temporary: skips `execute rejects filesystem fetches`, which also fails on upstream v2.0.27 (the expected text does not match packages/core/src/codemode/web.ts). Upstream fixed it in #54699 (e5f4bf4c241, in v2.0.28). Remove the skip, this entry and the raised limits in the v2.0.28 sync." + }, + "packages/sdk/script/verify-package.ts": { + "rung": 3, + "seam": "Pack `@kilocode/gateway-core` before `@opencode/core` in the workerd SDK verification script because `@opencode/core` depends on `@kilocode/gateway-core`." + }, + "packages/tui/src/component/dialog-integration.tsx": { + "rung": 3, + "seam": "Prompt for Personal Account vs Team (`chooseKiloAccount`) after a Kilo Gateway login completes, before closing or chaining the integration dialog. `TuiPlugin` has no post-integration-connect hook." + }, + "packages/tui/src/plugin/builtins.ts": { + "rung": 3, + "seam": "Register the built-in `KiloTeams` TUI plugin (`/teams`, `/team`, `/org`, `/orgs`) until the Kilo host owns the TUI built-in plugin list (U-E0-01)." + }, + "turbo.json": { + "rung": 3, + "seam": "Register `@kilocode/gateway-core#test` in Turborepo so `bun turbo test --affected` runs the Gateway core unit and boundary tests in CI.", + "unmarked": "JSON has no comments." } } } diff --git a/turbo.json b/turbo.json index 52d045674ccd..7d5ff6665abe 100644 --- a/turbo.json +++ b/turbo.json @@ -87,6 +87,9 @@ "dependsOn": ["^build"], "outputs": [] }, + "@kilocode/gateway-core#test": { + "outputs": [] + }, "@opencode/sdk#verify:package": { "outputs": [] }