Repository navigation
feat(cli): add hosted workspaces and cloud system feeds - #15509
Conversation
Code Review SummaryStatus: 1 Issue Found | Recommendation: Address before merge Overview
Incremental review of Issue Details (click to expand)SUGGESTION
Files Reviewed (8 files)
No memory-leak findings: the increment adds no timers or unbounded caches, and the attachment store remains bounded by Previous Review Summaries (3 snapshots, latest commit 6e2b70c)Current summary above is authoritative. Previous snapshots are kept for context only. Previous review (commit 6e2b70c)Status: 4 Issues Found | Recommendation: Address before merge Overview
Incremental review of Issue Details (click to expand)WARNING
SUGGESTION
Files Reviewed (24 files)
Previous review (commit 4c4a75d)Status: 1 Issue Found | Recommendation: Address before merge Overview
Incremental review of Issue Details (click to expand)WARNING
Files Reviewed (6 files)
Checked against current HEAD Fix these issues in Kilo Cloud Previous review (commit d563781)Status: 3 Issues Found | Recommendation: Address before merge Overview
The control-token exchange is well isolated (source credential only reaches the issuer, redirects refused, digest-only cache keys, bounded cache) and the new HTTP routes/schemas line up with the hosted-routing path. The findings are about one contract inconsistency in the new workspace-session mutation and two error-classification issues introduced by routing every Worker call through the exchange. Issue Details (click to expand)WARNING
SUGGESTION
Files Reviewed (28 files)
Not reviewed against a running Cloud stack: the new Reviewed by deepseek-flash-latest · Input: 85.6K · Output: 20.5K · Cached: 1.5M Review guidance: REVIEW.md from base branch |
Maintenance StatusPublication is blocked. The branch is still
The normal push ran the unmodified pre-push hook but stopped at the 210-second command limit before publishing. Bounded workspace checks subsequently completed 28 TypeScript workspaces. The remaining Verified Locally
Feedback And OverlapAll three review threads have evidence-backed replies and remain open. Two defects are fixed only in the unpublished patch; organization payload nesting is unverified because read-only access to Kilo-Org/cloud PR #8972 returns HTTP 502, GitHub authorization unavailable. Sibling payload symmetry is not sufficient reason to change the procedure input. At the published heads, #15504 ( Current required checks at the unchanged head: The filesystem is ephemeral, so the exact unpublished commits are preserved below for recovery without weakening any checks. Unpublished maintenance commits as mailbox patchesFrom c594fc575568976b1561766c977efdcc137f268f Mon Sep 17 00:00:00 2001
From: eshurakov <54751+eshurakov@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:13:02 +0000
Subject: [PATCH 1/2] test(cli): exercise hosted workspace HTTP endpoints
---
.../server/httpapi-exercise-scenarios.ts | 54 +++++++++++++++++++
1 file changed, 54 insertions(+)
diff --git a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts
index 96f5f8640f..23a56e0ee9 100644
--- a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts
+++ b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts
@@ -469,6 +469,14 @@ export const kiloScenarios: Scenario[] = [
.post("/kilo/cloud/session/import", "kilo.cloud.session.import")
.at((ctx) => ({ path: "/kilo/cloud/session/import", headers: ctx.headers(), body: { sessionId: "missing" } }))
.status(401),
+ http.protected
+ .get("/cloud-agent/event", "cloudAgent.systemEvents")
+ .at((ctx) => ({ path: "/cloud-agent/event", headers: ctx.headers() }))
+ .status(400, (_ctx, result) =>
+ Effect.sync(() => {
+ check(result.text.includes("hosted attachment"), "system events without a hosted attachment should be rejected")
+ }),
+ ),
http.protected.get("/kilocode/session-host/provider", "sessionHost.providers").json(200, (body) => {
object(body)
array(body.providers)
@@ -531,6 +539,52 @@ export const kiloScenarios: Scenario[] = [
headers: ctx.headers(),
}))
.status(404),
+ http.protected
+ .get("/kilocode/session-host/workspace", "sessionHost.listWorkspaces")
+ .probe({ path: "/kilocode/session-host/workspace?limit=0" })
+ .at((ctx) => ({ path: "/kilocode/session-host/workspace", headers: ctx.headers() }))
+ .status(401),
+ http.protected
+ .post("/kilocode/session-host/workspace", "sessionHost.createWorkspace")
+ .at((ctx) => ({
+ path: "/kilocode/session-host/workspace",
+ headers: ctx.headers(),
+ body: {
+ repository: { type: "github", repo: "owner/repo" },
+ profile: { id: "33333333-3333-4333-8333-333333333333" },
+ prompt: "hello",
+ agent: { mode: "build", model: "test/fake" },
+ operationKey: "11111111-1111-4111-8111-111111111111",
+ messageID: "msg_00000000000000000000000002",
+ },
+ }))
+ .status(401),
+ http.protected
+ .get("/kilocode/session-host/workspace/{providerID}/{workspaceID}", "sessionHost.getWorkspace")
+ .at((ctx) => ({
+ path: route("/kilocode/session-host/workspace/{providerID}/{workspaceID}", {
+ providerID: "kilo-cloud",
+ workspaceID: "worktree_00000000-0000-4000-8000-000000000001",
+ }),
+ headers: ctx.headers(),
+ }))
+ .status(401),
+ http.protected
+ .post("/kilocode/session-host/workspace/{providerID}/{workspaceID}/session", "sessionHost.startWorkspaceSession")
+ .at((ctx) => ({
+ path: route("/kilocode/session-host/workspace/{providerID}/{workspaceID}/session", {
+ providerID: "kilo-cloud",
+ workspaceID: "worktree_00000000-0000-4000-8000-000000000001",
+ }),
+ headers: ctx.headers(),
+ body: {
+ prompt: "hello",
+ agent: { mode: "build", model: "test/fake" },
+ operationKey: "22222222-2222-4222-8222-222222222222",
+ messageID: "msg_00000000000000000000000003",
+ },
+ }))
+ .status(401),
http.protected.get("/network", "network.list").json(200, array),
http.protected
.post("/network/{requestID}/reply", "network.reply")
--
2.47.3
From 4c4a75d4e6f84d013b47e7baa388a008816eb9b3 Mon Sep 17 00:00:00 2001
From: eshurakov <54751+eshurakov@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:13:08 +0000
Subject: [PATCH 2/2] fix(cli): preserve control token failure semantics
---
.../src/kilocode/cloud/control-token.ts | 10 +++-
.../kilocode/session-hosting/kilo-cloud.ts | 42 +++++++-------
.../test/kilocode/cloud/control-token.test.ts | 4 ++
.../kilo-cloud-auth-flow.test.ts | 55 ++++++++++++++++++-
.../session-hosting/kilo-cloud.test.ts | 2 +-
5 files changed, 88 insertions(+), 25 deletions(-)
diff --git a/packages/opencode/src/kilocode/cloud/control-token.ts b/packages/opencode/src/kilocode/cloud/control-token.ts
index ed083d8e6b..d87eaa2a19 100644
--- a/packages/opencode/src/kilocode/cloud/control-token.ts
+++ b/packages/opencode/src/kilocode/cloud/control-token.ts
@@ -146,10 +146,16 @@ async function issue(
}
function errorForStatus(status: number) {
- if (status === 503 || status >= 500) {
+ if (status === 401 || status === 403) {
+ return new ControlTokenError("unauthorized", "Kilo Cloud denied the control token request")
+ }
+ if (status === 408 || status === 429 || status >= 500) {
return new ControlTokenError("unavailable", "Kilo Cloud token issuance is temporarily unavailable")
}
- return new ControlTokenError("unauthorized", "Kilo Cloud denied the control token request")
+ return new ControlTokenError(
+ "invalid_response",
+ `Kilo control token issuer returned an unexpected HTTP status: ${status}`,
+ )
}
function cacheKey(request: ControlTokenRequest) {
diff --git a/packages/opencode/src/kilocode/session-hosting/kilo-cloud.ts b/packages/opencode/src/kilocode/session-hosting/kilo-cloud.ts
index 43e15c8f7a..40605ab2e3 100644
--- a/packages/opencode/src/kilocode/session-hosting/kilo-cloud.ts
+++ b/packages/opencode/src/kilocode/session-hosting/kilo-cloud.ts
@@ -191,7 +191,7 @@ export function provider(options: Options): Provider {
* short-lived control token first. The source credential is presented only to
* the issuer; the Worker sees only the control token.
*/
- const request = (auth: CloudAuth.Resolved, req: BearerRequest) =>
+ const request = (auth: CloudAuth.Resolved, req: BearerRequest, retryable = true) =>
Effect.gen(function* () {
const base = yield* origin
const issuer = yield* webOrigin
@@ -214,7 +214,13 @@ export function provider(options: Options): Provider {
}).request(req),
catch: () => {
unreachable()
- return new HostedFailure({ code: "unavailable", message: "Kilo Cloud is unreachable", retryable: true })
+ return new HostedFailure({
+ code: "unavailable",
+ message: retryable
+ ? "Kilo Cloud is unreachable"
+ : "Kilo Cloud start outcome is unknown; retry with the same operation key and message ID",
+ retryable,
+ })
},
})
reachable()
@@ -391,20 +397,15 @@ export function provider(options: Options): Provider {
new HostedFailure({ code: "invalid_request", message: "Session start request is invalid", retryable: false }),
)
}
- const response = yield* request(auth, {
- method: "POST",
- path: "/trpc/start",
- headers: { ...headers(auth), "content-type": "application/json" },
- body: JSON.stringify(parsed.data),
- }).pipe(
- Effect.mapError((error) => {
- if (error.code !== "unavailable" || !error.retryable || input.operationKey !== undefined) return error
- return new HostedFailure({
- code: "unavailable",
- message: "Kilo Cloud start outcome is unknown; retry with the same operation key and message ID",
- retryable: false,
- })
- }),
+ const response = yield* request(
+ auth,
+ {
+ method: "POST",
+ path: "/trpc/start",
+ headers: { ...headers(auth), "content-type": "application/json" },
+ body: JSON.stringify(parsed.data),
+ },
+ input.operationKey !== undefined,
)
const body = yield* consume(response)
const envelope = Envelope.safeParse(body)
@@ -623,7 +624,10 @@ export function provider(options: Options): Provider {
}
const auth = yield* resolve(input.organizationID)
const headers: Record<string, string> = {
- ...buildKiloHeaders(undefined, auth.organizationID ? { kilocodeOrganizationId: auth.organizationID } : undefined),
+ ...buildKiloHeaders(
+ undefined,
+ auth.organizationID ? { kilocodeOrganizationId: auth.organizationID } : undefined,
+ ),
}
for (const [key, value] of input.headers.entries()) headers[key] = value
const query = mapped.query.toString()
@@ -631,9 +635,7 @@ export function provider(options: Options): Provider {
method: mapped.method,
path: query.length === 0 ? mapped.remote : `${mapped.remote}?${query}`,
headers,
- ...(input.body && input.body.length > 0
- ? { body: new Blob([new Uint8Array(input.body).buffer]) }
- : {}),
+ ...(input.body && input.body.length > 0 ? { body: new Blob([new Uint8Array(input.body).buffer]) } : {}),
// Both native event streams are long-lived; every other call keeps the default bound.
...(mapped.remote === "/kilo/event" || mapped.remote === "/kilo/cloud-agent/event" ? { timeoutMs: 0 } : {}),
})
diff --git a/packages/opencode/test/kilocode/cloud/control-token.test.ts b/packages/opencode/test/kilocode/cloud/control-token.test.ts
index 0fa001004b..e19cb25ba9 100644
--- a/packages/opencode/test/kilocode/cloud/control-token.test.ts
+++ b/packages/opencode/test/kilocode/cloud/control-token.test.ts
@@ -124,8 +124,12 @@ test("normalizes issuer failures without retrying", async () => {
for (const item of [
{ status: 401, reason: "unauthorized" },
{ status: 403, reason: "unauthorized" },
+ { status: 408, reason: "unavailable" },
+ { status: 429, reason: "unavailable" },
{ status: 503, reason: "unavailable" },
{ status: 500, reason: "unavailable" },
+ { status: 400, reason: "invalid_response" },
+ { status: 404, reason: "invalid_response" },
] as const) {
const app = issuer(() => Response.json({ error: "denied" }, { status: item.status }))
try {
diff --git a/packages/opencode/test/kilocode/session-hosting/kilo-cloud-auth-flow.test.ts b/packages/opencode/test/kilocode/session-hosting/kilo-cloud-auth-flow.test.ts
index 9b7bddd599..dc67ab83b8 100644
--- a/packages/opencode/test/kilocode/session-hosting/kilo-cloud-auth-flow.test.ts
+++ b/packages/opencode/test/kilocode/session-hosting/kilo-cloud-auth-flow.test.ts
@@ -34,7 +34,7 @@ function harness() {
const worker: Recorded[] = []
let now = 1_000_000
const state = {
- issue: "ok" as "ok" | "deny" | "unavailable",
+ issue: "ok" as "ok" | "deny" | "unavailable" | "throttled",
token: CONTROL,
}
const server = Bun.serve({
@@ -54,6 +54,7 @@ function harness() {
issuer.push(recorded)
if (state.issue === "deny") return Response.json({ error: "denied" }, { status: 401 })
if (state.issue === "unavailable") return Response.json({ error: "disabled" }, { status: 503 })
+ if (state.issue === "throttled") return Response.json({ error: "limited" }, { status: 429 })
return Response.json({ token: state.token, expiresAt: new Date(now + 600_000).toISOString() })
}
worker.push(recorded)
@@ -161,7 +162,7 @@ it.effect("reports a denied exchange and never dispatches the Worker", () => {
}).pipe(Effect.ensuring(Effect.sync(() => app.stop())))
})
-it.effect("reports an unavailable issuer without dispatching the Worker", () => {
+it.effect("keeps issuer outages retryable before reads and keyless starts", () => {
const app = harness()
const store = authStore()
const cloud = client(app, store)
@@ -169,7 +170,57 @@ it.effect("reports an unavailable issuer without dispatching the Worker", () =>
return Effect.gen(function* () {
const error = yield* cloud.list({}).pipe(Effect.flip)
expect(error).toMatchObject({ code: "unavailable", retryable: true })
+ const start = yield* cloud
+ .start({
+ repository: { type: "github", repo: "Kilo-Org/kilocode" },
+ prompt: "hello",
+ agent: { mode: "build", model: "test/fake" },
+ })
+ .pipe(Effect.flip)
+ expect(start).toMatchObject({ code: "unavailable", retryable: true })
+ expect(app.issuer).toHaveLength(2)
+ expect(app.worker).toHaveLength(0)
+ expect(store.writes).toEqual({ set: 0, remove: 0 })
+ }).pipe(Effect.ensuring(Effect.sync(() => app.stop())))
+})
+
+it.effect("reports issuer throttling as retryable without dispatching the Worker", () => {
+ const app = harness()
+ const store = authStore()
+ const cloud = client(app, store)
+ app.state.issue = "throttled"
+ return Effect.gen(function* () {
+ const error = yield* cloud.list({}).pipe(Effect.flip)
+ expect(error).toMatchObject({ code: "unavailable", retryable: true })
+ expect(app.issuer).toHaveLength(1)
expect(app.worker).toHaveLength(0)
+ expect(store.writes).toEqual({ set: 0, remove: 0 })
+ }).pipe(Effect.ensuring(Effect.sync(() => app.stop())))
+})
+
+it.effect("keeps a keyless start retryable when the issuer transport fails", () => {
+ const app = harness()
+ const store = authStore()
+ const cloud = kiloCloud({
+ auth: store.interface,
+ env: { CLOUD_AGENT_NEXT_BASE_URL: app.url, KILO_WEB_APP_URL: "http://127.0.0.1:1" },
+ })
+ return Effect.gen(function* () {
+ const error = yield* cloud
+ .start({
+ repository: { type: "github", repo: "Kilo-Org/kilocode" },
+ prompt: "hello",
+ agent: { mode: "build", model: "test/fake" },
+ })
+ .pipe(Effect.flip)
+ expect(error).toMatchObject({
+ code: "unavailable",
+ message: "Kilo control token could not be issued",
+ retryable: true,
+ })
+ expect(app.issuer).toHaveLength(0)
+ expect(app.worker).toHaveLength(0)
+ expect(store.writes).toEqual({ set: 0, remove: 0 })
}).pipe(Effect.ensuring(Effect.sync(() => app.stop())))
})
diff --git a/packages/opencode/test/kilocode/session-hosting/kilo-cloud.test.ts b/packages/opencode/test/kilocode/session-hosting/kilo-cloud.test.ts
index fccac9f416..3f34fdd0f1 100644
--- a/packages/opencode/test/kilocode/session-hosting/kilo-cloud.test.ts
+++ b/packages/opencode/test/kilocode/session-hosting/kilo-cloud.test.ts
@@ -338,7 +338,7 @@ it.effect("marks an ambiguous start as non-retryable without an idempotency key"
const app = harness()
const cloud = kiloCloud({
auth: auth(signedIn()),
- env: { CLOUD_AGENT_NEXT_BASE_URL: "http://127.0.0.1:1", KILO_WEB_APP_URL: "http://127.0.0.1:1" },
+ env: { CLOUD_AGENT_NEXT_BASE_URL: "http://127.0.0.1:1", KILO_WEB_APP_URL: app.url },
})
return Effect.gen(function* () {
const failure = yield* cloud
--
2.47.3 |
| const cloud = client(app.url) | ||
| app.state.membership = "nested" | ||
| app.state.detail = "null" | ||
| expect(yield* membership(cloud)).toBeUndefined() |
There was a problem hiding this comment.
SUGGESTION: This test cannot tell skipping apart from aborting the scan
The catalog here has a single workspace, so membership returns undefined whether nested skips the vanished workspace and keeps scanning or accidentally ends the whole scan. The behavior promised by the comment at kilo-cloud.ts:635 is a skip: a later workspace that holds the session must still be found. Consider serving two workspaces where the first detail is null and the second holds the session, then asserting membership resolves to the second workspace.
|
Recovered the failed post-merge v2-port tracker in #15523 ( |
Task Board
This PR is part of the Kilo Task Board and implements task-board issue #51.
Issue
No existing issue is linked. This is the requested client portion of the cross-repository Cloud Agent facade work.
Cloud implementation: Kilo-Org/cloud#8972
Local-stack integration coverage: Kilo-Org/cloud#8973
Context
Local Kilo exposed hosted sessions but not hosted workspace operations or the Cloud preparation/sandbox feed. Hosted requests also need the current source-to-control token exchange instead of sending the original user credential to the Worker.
Implementation
expiresAt; do not change normal authentication or fall back after denial.GET /cloud-agent/eventin the actual HTTP API and route it through opaque hosted attachments. Keep both native and system SSE requests long-lived; reject unbound system-feed requests instead of running locally.dev:localset the Worker and issuer origins explicitly and fail if the local Worker port is missing.Screenshots / Video
N/A: API and transport changes only.
How to Test
Manual/local verification
Agent-executed live local-stack verification passed all six paths through a separate local Kilo process: workspace listing and creation/replay, a second session in the workspace, preparation/sandbox status SSE, native snapshot/fence/live events, and two follow-up messages with durable-history checks. The test used a separate HOME/XDG configuration and left normal CLI authentication unchanged. No human/UI verification was performed.
Only the LLM was deterministic fake. The local Worker, Durable Objects, PostgreSQL, Docker sandbox, wrapper, and Kilo processes were real. Owned workspace and profile records were deleted after the run.
Automated checks executed by the agent:
git diff --checkpassed.Reviewer test steps
cloud-agentgroup with an existing funded local account.pnpm --filter cloud-agent-next run e2e:local-kilo --kilo-repo /path/to/kilocode --bun /path/to/pinned/bun --email local-user@example.com.dev/logs/local-kilo-*.Blocked checks and substitute verification
No relevant check is blocked. An earlier broader server-directory test run showed unrelated suite-parallelism flakes in permission/model-filter tests; those passed in isolation. The final focused 86-test suite and six-scenario live run passed.
Checklist