From 4f0df2b55b47621c65653a7ec89eecaa94342e82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 24 Jul 2026 08:03:12 +0200 Subject: [PATCH 1/4] feat(cli): advertise spawner instance in remote heartbeat The cloud session-ingest DO lists connected kilo remote instances for the mobile Run-on picker from heartbeat instance metadata (cloud #4618), but no CLI build ever sent it, so the picker could never list a local CLI. Add instance { name, projectName, version } to the remote heartbeat schema and send it on every heartbeat (fresh and degraded). name is the sanitized OS hostname, projectName the launch-directory basename, version the CLI version clamped to the 32-char wire cap. --- .../src/kilo-sessions/kilo-sessions.ts | 54 ++++++++++ .../src/kilo-sessions/remote-protocol.ts | 11 ++ .../opencode/src/kilo-sessions/remote-ws.ts | 4 + .../kilocode/sessions/remote-instance.test.ts | 81 ++++++++++++++ .../kilocode/sessions/remote-protocol.test.ts | 54 ++++++++++ .../test/kilocode/sessions/remote-ws.test.ts | 100 ++++++++++++++++++ 6 files changed, 304 insertions(+) create mode 100644 packages/opencode/test/kilocode/sessions/remote-instance.test.ts diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index e4a0ef8b50b..ff0806633fe 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -25,6 +25,7 @@ import { Vcs } from "@/project/vcs" import simpleGit from "simple-git" import type { RemoteWS } from "@/kilo-sessions/remote-ws" import type { RemoteSender } from "@/kilo-sessions/remote-sender" +import { RemoteProtocol } from "@/kilo-sessions/remote-protocol" import { AttachedState } from "@/kilo-sessions/attached-state" import { SessionStatus } from "@/session/status" import { Telemetry } from "@kilocode/kilo-telemetry" @@ -34,6 +35,55 @@ import { withTimeout } from "@/util/timeout" import { Snapshot } from "@/snapshot" import { cumulativeSessionDiff } from "@/kilocode/session-portability/cumulative-diff" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import os from "os" + +/** Strip controls, collapse whitespace, clamp, fall back when empty. */ +export function sanitizeLabel(value: string, fallback: string, max: number): string { + const cleaned = value + .replace(/[\u0000-\u001f\u007f]/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, max) + return cleaned.length > 0 ? cleaned : fallback +} + +/** Basename of a launch directory for the heartbeat `projectName` field. */ +export function projectLabel(directory: string): string { + // Strip trailing slashes so `/tmp/proj/` → "proj" (not ""). + const trimmed = directory.replace(/[\\/]+$/, "") + const base = trimmed.split(/[\\/]/).pop() ?? "" + return sanitizeLabel(base, "unknown-project", 64) +} + +/** OS hostname for the heartbeat `name` field; empty → "Kilo runtime". */ +export function hostLabel(hostname = os.hostname()): string { + return sanitizeLabel(hostname, "Kilo runtime", 64) +} + +/** CLI version clamped to the cloud contract's 32-char cap. */ +export function versionLabel(version = InstallationVersion): string | undefined { + const cleaned = version + .replace(/[\u0000-\u001f\u007f]/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 32) + return cleaned.length > 0 ? cleaned : undefined +} + +/** Build and validate the spawner `instance` once at remote enable. */ +export function buildRemoteInstance(input: { + directory: string + hostname?: string + version?: string +}): RemoteProtocol.Instance { + const version = versionLabel(input.version) + return RemoteProtocol.Instance.parse({ + name: hostLabel(input.hostname), + projectName: projectLabel(input.directory), + ...(version ? { version } : {}), + }) +} async function provide(input: { directory: string; fn: () => R }): Promise { const { provide } = await import("@/kilocode/instance") @@ -480,6 +530,9 @@ export namespace KiloSessions { // Capture directory so the heartbeat timer can re-enter the Instance context // (setInterval runs outside AsyncLocalStorage scope) const directory = Instance.directory + // Spawner identity for the mobile Run-on picker. Validated once here so a + // bad value fails at enable rather than silently per heartbeat. + const instance = buildRemoteInstance({ directory }) const getSessions = async () => { const [gitUrl, gitBranch] = await Promise.all([ getGitUrl().catch(() => undefined), @@ -520,6 +573,7 @@ export namespace KiloSessions { getToken: kilocodeToken, withContext: (fn) => provide({ directory, fn }), getSessions, + instance, log, onOpen: () => { void Bus.publish(Instance.current, Event.RemoteStatusChanged, { enabled: true, connected: true }) diff --git a/packages/opencode/src/kilo-sessions/remote-protocol.ts b/packages/opencode/src/kilo-sessions/remote-protocol.ts index 926a025a9d0..6b97803c8c3 100644 --- a/packages/opencode/src/kilo-sessions/remote-protocol.ts +++ b/packages/opencode/src/kilo-sessions/remote-protocol.ts @@ -23,11 +23,22 @@ export namespace RemoteProtocol { attachments: z.boolean().optional(), }) .optional() + + // Identity of the `kilo remote` spawner for the mobile Run-on picker. + // Absent on legacy CLIs that predate the spawner; the relay excludes those. + export const Instance = z.object({ + name: z.string().min(1).max(64), + projectName: z.string().min(1).max(64), + version: z.string().max(32).optional(), + }) + export type Instance = z.infer + export const Heartbeat = z.object({ type: z.literal("heartbeat"), sessions: z.array(SessionInfo), protocolVersion: z.string().optional(), // lets relay detect CLI capabilities without probing commands capabilities: Capabilities, + instance: Instance.optional(), }) export type Heartbeat = z.infer diff --git a/packages/opencode/src/kilo-sessions/remote-ws.ts b/packages/opencode/src/kilo-sessions/remote-ws.ts index de631d23e1c..5261f01da1b 100644 --- a/packages/opencode/src/kilo-sessions/remote-ws.ts +++ b/packages/opencode/src/kilo-sessions/remote-ws.ts @@ -42,6 +42,8 @@ export namespace RemoteWS { gatherTimeout?: number /** Max unresolved gather operations before cycles send degraded heartbeats. Defaults to 4. */ maxOutstandingGathers?: number + /** Spawner identity advertised on every heartbeat. Omitted on legacy callers. */ + instance?: RemoteProtocol.Instance } export type Connection = { @@ -238,6 +240,7 @@ export namespace RemoteWS { protocolVersion: InstallationVersion, capabilities: { attachments: true }, sessions: fresh, + ...(options.instance ? { instance: options.instance } : {}), }) if (sentLive) { // A waiter requiring a specific id is satisfied only when @@ -274,6 +277,7 @@ export namespace RemoteWS { protocolVersion: InstallationVersion, capabilities: { attachments: true }, sessions: lastGood ?? [], + ...(options.instance ? { instance: options.instance } : {}), }) waiters = cycleWaiters.concat(waiters) } diff --git a/packages/opencode/test/kilocode/sessions/remote-instance.test.ts b/packages/opencode/test/kilocode/sessions/remote-instance.test.ts new file mode 100644 index 00000000000..d151ec61ec8 --- /dev/null +++ b/packages/opencode/test/kilocode/sessions/remote-instance.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test" +import { + buildRemoteInstance, + hostLabel, + projectLabel, + sanitizeLabel, + versionLabel, +} from "../../../src/kilo-sessions/kilo-sessions" + +describe("remote instance labels", () => { + test("hostLabel uses hostname and falls back when empty", () => { + expect(hostLabel("macbook.local")).toBe("macbook.local") + expect(hostLabel(" ")).toBe("Kilo runtime") + expect(hostLabel("")).toBe("Kilo runtime") + }) + + test("hostLabel strips controls, collapses whitespace, clamps to 64", () => { + expect(hostLabel("ab\u0001c\td")).toBe("ab c d") + expect(hostLabel("x".repeat(80))).toBe("x".repeat(64)) + expect(hostLabel("\u0001\u0002")).toBe("Kilo runtime") + }) + + test("projectLabel takes basename and falls back", () => { + expect(projectLabel("/Users/igor/Projects/cloud")).toBe("cloud") + expect(projectLabel("/Users/igor/Projects/cloud/")).toBe("cloud") + expect(projectLabel("C:\\work\\app\\")).toBe("app") + expect(projectLabel("/")).toBe("unknown-project") + expect(projectLabel("")).toBe("unknown-project") + }) + + test("projectLabel sanitizes and clamps", () => { + expect(projectLabel(`/tmp/${"p".repeat(80)}`)).toBe("p".repeat(64)) + expect(projectLabel("/tmp/my\nproj")).toBe("my proj") + }) + + test("versionLabel clamps to 32 and drops empty", () => { + expect(versionLabel("7.4.15")).toBe("7.4.15") + expect(versionLabel("v".repeat(40))).toBe("v".repeat(32)) + expect(versionLabel(" ")).toBeUndefined() + expect(versionLabel("\u0000")).toBeUndefined() + }) + + test("sanitizeLabel applies fallback after empty sanitize", () => { + expect(sanitizeLabel("ok", "fb", 64)).toBe("ok") + expect(sanitizeLabel("\t\n", "fb", 64)).toBe("fb") + }) + + test("buildRemoteInstance validates a complete instance", () => { + const instance = buildRemoteInstance({ + directory: "/tmp/my-app", + hostname: "dev-box", + version: "1.2.3", + }) + expect(instance).toEqual({ + name: "dev-box", + projectName: "my-app", + version: "1.2.3", + }) + }) + + test("buildRemoteInstance applies hostname and project fallbacks", () => { + const instance = buildRemoteInstance({ + directory: "/", + hostname: " ", + version: "9.0.0", + }) + expect(instance.name).toBe("Kilo runtime") + expect(instance.projectName).toBe("unknown-project") + expect(instance.version).toBe("9.0.0") + }) + + test("buildRemoteInstance omits empty version after clamp", () => { + const instance = buildRemoteInstance({ + directory: "/tmp/p", + hostname: "h", + version: " ", + }) + expect(instance).toEqual({ name: "h", projectName: "p" }) + expect(instance).not.toHaveProperty("version") + }) +}) diff --git a/packages/opencode/test/kilocode/sessions/remote-protocol.test.ts b/packages/opencode/test/kilocode/sessions/remote-protocol.test.ts index 6de374028d6..19c1f9d53f4 100644 --- a/packages/opencode/test/kilocode/sessions/remote-protocol.test.ts +++ b/packages/opencode/test/kilocode/sessions/remote-protocol.test.ts @@ -321,4 +321,58 @@ describe("RemoteProtocol", () => { expect(result.data.type).toBe("heartbeat") } }) + + test("heartbeat with instance parses", () => { + const result = RemoteProtocol.Heartbeat.safeParse({ + type: "heartbeat", + sessions: [], + instance: { name: "macbook", projectName: "cloud", version: "7.4.15" }, + }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.instance).toEqual({ + name: "macbook", + projectName: "cloud", + version: "7.4.15", + }) + } + }) + + test("heartbeat without instance parses", () => { + const result = RemoteProtocol.Heartbeat.safeParse({ + type: "heartbeat", + sessions: [], + }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.instance).toBeUndefined() + } + }) + + test("heartbeat rejects empty instance.name", () => { + const result = RemoteProtocol.Heartbeat.safeParse({ + type: "heartbeat", + sessions: [], + instance: { name: "", projectName: "cloud" }, + }) + expect(result.success).toBe(false) + }) + + test("heartbeat rejects overlong instance.projectName", () => { + const result = RemoteProtocol.Heartbeat.safeParse({ + type: "heartbeat", + sessions: [], + instance: { name: "host", projectName: "x".repeat(65) }, + }) + expect(result.success).toBe(false) + }) + + test("instance rejects overlong version", () => { + const result = RemoteProtocol.Instance.safeParse({ + name: "host", + projectName: "proj", + version: "v".repeat(33), + }) + expect(result.success).toBe(false) + }) }) diff --git a/packages/opencode/test/kilocode/sessions/remote-ws.test.ts b/packages/opencode/test/kilocode/sessions/remote-ws.test.ts index c8d2951b54c..bf8c54714f0 100644 --- a/packages/opencode/test/kilocode/sessions/remote-ws.test.ts +++ b/packages/opencode/test/kilocode/sessions/remote-ws.test.ts @@ -255,6 +255,48 @@ describe("RemoteWS", () => { expect(parsed.capabilities).toEqual({ attachments: true }) }) + test("fresh heartbeat includes instance when provided", async () => { + server = createServer() + const connecting = server.waitForConnect() + const msg = server.waitForMessage() + const instance = { name: "macbook", projectName: "cloud", version: "7.4.15" } + + conn = RemoteWS.connect({ + url: server.url, + getToken: async () => "tok", + getSessions: async () => ({ sessions: [] }), + log: nolog(), + heartbeat: 100, + instance, + }) + + await connecting + await settled() + const parsed = JSON.parse(await msg) + expect(parsed.type).toBe("heartbeat") + expect(parsed.instance).toEqual(instance) + }) + + test("fresh heartbeat omits instance key when not provided", async () => { + server = createServer() + const connecting = server.waitForConnect() + const msg = server.waitForMessage() + + conn = RemoteWS.connect({ + url: server.url, + getToken: async () => "tok", + getSessions: async () => ({ sessions: [] }), + log: nolog(), + heartbeat: 100, + }) + + await connecting + await settled() + const parsed = JSON.parse(await msg) + expect(parsed.type).toBe("heartbeat") + expect(parsed).not.toHaveProperty("instance") + }) + test("serializes concurrent heartbeat snapshots", async () => { server = createServer() const connecting = server.waitForConnect() @@ -1271,6 +1313,64 @@ describe("RemoteWS", () => { }) }) + test("degraded heartbeat includes instance when provided", async () => { + await withFakeWebSocket(async (clock) => { + const getSessions = () => new Promise<{ sessions: RemoteWS.SessionInfo[] }>(() => {}) + const instance = { name: "host", projectName: "proj", version: "1.0.0" } + + conn = RemoteWS.connect({ + url: "ws://example.test", + getToken: async () => "tok", + getSessions, + log: nolog(), + heartbeat: 60_000, + timers: clock, + now: () => clock.now, + timeout: 300_000, + gatherTimeout: 1000, + instance, + }) + + await flush() + FakeWebSocket.instances[0].open() + void conn.heartbeat().catch(() => {}) + clock.advance(1000) + await flushLong() + + const parsed = JSON.parse(FakeWebSocket.instances[0].sent[0]) + expect(parsed.type).toBe("heartbeat") + expect(parsed.instance).toEqual(instance) + }) + }) + + test("degraded heartbeat omits instance key when not provided", async () => { + await withFakeWebSocket(async (clock) => { + const getSessions = () => new Promise<{ sessions: RemoteWS.SessionInfo[] }>(() => {}) + + conn = RemoteWS.connect({ + url: "ws://example.test", + getToken: async () => "tok", + getSessions, + log: nolog(), + heartbeat: 60_000, + timers: clock, + now: () => clock.now, + timeout: 300_000, + gatherTimeout: 1000, + }) + + await flush() + FakeWebSocket.instances[0].open() + void conn.heartbeat().catch(() => {}) + clock.advance(1000) + await flushLong() + + const parsed = JSON.parse(FakeWebSocket.instances[0].sent[0]) + expect(parsed.type).toBe("heartbeat") + expect(parsed).not.toHaveProperty("instance") + }) + }) + test("AC4a: degraded heartbeat preserves the last known-good non-empty session list", async () => { await withFakeWebSocket(async (clock) => { let mode: "fresh" | "wedge" = "fresh" From 3d23537960d217a1133fd6eb336f98ee1bd6011a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 24 Jul 2026 08:30:10 +0200 Subject: [PATCH 2/4] fix(cli): accept connection-scoped remote create_session The mobile Run-on spawn sends create_session with a connectionId and no sessionId; the handler required a sessionId and rejected it as invalid. Create the root session in the relay launch directory when sessionId is absent, keep the session-scoped directory when it decodes, and reject a present-but-undecodable sessionId. --- .../src/kilo-sessions/remote-sender.ts | 28 ++- .../kilocode/sessions/remote-sender.test.ts | 180 +++++++++++++++++- 2 files changed, 194 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/kilo-sessions/remote-sender.ts b/packages/opencode/src/kilo-sessions/remote-sender.ts index 165c221d57e..322843b5cf3 100644 --- a/packages/opencode/src/kilo-sessions/remote-sender.ts +++ b/packages/opencode/src/kilo-sessions/remote-sender.ts @@ -683,10 +683,14 @@ export namespace RemoteSender { return } if (msg.command === "create_session") { - // kilocode_change start - remote /new creation: root session, attached + heartbeat before response + // kilocode_change start - remote /new creation: root session, attached + heartbeat before response. + // Scope selection is three explicit cases: + // (1) sessionId ABSENT -> connection-scoped (options.directory / launch dir) + // (2) sessionId present and decodable -> session-scoped (that session's directory) + // (3) sessionId present but UNDECODABLE -> reject (same as parse-gate error) + // A present-but-undecodable sessionId must NOT fall through to (1). const parsed = CreateSessionRequest.safeParse(msg.data) - const current = msg.sessionId ? decodeSessionID(msg.sessionId) : Option.none() - if (!parsed.success || Option.isNone(current)) { + if (!parsed.success) { options.conn.send({ type: "response", id: msg.id, @@ -694,11 +698,27 @@ export namespace RemoteSender { }) return } + // Distinguish absent vs present-but-undecodable: only omit sessionId for (1). + let directory: Promise | string + if (msg.sessionId === undefined) { + directory = options.directory + } else { + const current = decodeSessionID(msg.sessionId) + if (Option.isNone(current)) { + options.conn.send({ + type: "response", + id: msg.id, + error: "invalid create_session command", + }) + return + } + directory = session.get(current.value).then((info) => info.directory) + } const run = options.provide ?? provide void (async () => { try { const result = await run({ - directory: (await session.get(current.value)).directory, + directory: await directory, fn: async () => { const created = await sessionCreate({}) // attachSession is the duplicate-safe seam: it mutates the diff --git a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts index de47de4f06c..7a931e13caa 100644 --- a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts +++ b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts @@ -2904,7 +2904,67 @@ describe("RemoteSender slash commands", () => { expect(sent).toEqual([{ type: "response", id: "req_create", result: { protocolVersion: 1, sessionID: "ses_new" } }]) }) - test("create_session rejects unsupported protocol versions and missing or invalid session IDs", async () => { + test("create_session connection-scoped creates a root session in the launch directory", async () => { + const { conn, sent } = fakeConn() + const dirs: string[] = [] + const createCalls: { input: unknown; calls: number } = { input: undefined, calls: 0 } + const attachCalls: string[] = [] + const getCalls: string[] = [] + const order: string[] = [] + const sender = RemoteSender.create({ + conn, + directory: "/workspace/launch-dir", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => { + dirs.push(input.directory) + return input.fn() + }, + session: { + get: async (sessionID) => { + getCalls.push(sessionID) + throw new Error("must not look up session for connection-scoped create_session") + }, + children: async () => [], + create: async (input) => { + createCalls.calls += 1 + createCalls.input = input + order.push("create") + return { id: SessionID.make("ses_conn_new"), directory: "/workspace/launch-dir", parentID: undefined } as any + }, + }, + attachSession: async (id) => { + attachCalls.push(id) + order.push("attach") + await (conn as any).heartbeat() + }, + }) + ;(conn as any).heartbeat = async () => { + order.push("heartbeat") + } + + const response = expectResponse(conn, sent, "req_create_conn") + sender.handle({ + type: "command", + id: "req_create_conn", + command: "create_session", + data: { protocolVersion: 1 }, + }) + await response.promise + response.restore() + + expect(dirs).toEqual(["/workspace/launch-dir"]) + expect(getCalls).toEqual([]) + expect(createCalls.calls).toBe(1) + expect(createCalls.input).toEqual({}) + expect(attachCalls).toEqual(["ses_conn_new"]) + expect(order).toEqual(["create", "attach", "heartbeat"]) + expect(sent).toEqual([ + { type: "response", id: "req_create_conn", result: { protocolVersion: 1, sessionID: "ses_conn_new" } }, + ]) + }) + + test("create_session rejects invalid request data for both scopes", async () => { const { conn, sent } = fakeConn() const createCalls: unknown[] = [] const sender = RemoteSender.create({ @@ -2930,6 +2990,7 @@ describe("RemoteSender slash commands", () => { throw new Error("must not heartbeat for invalid request") } + // Session-scoped invalid protocol version sender.handle({ type: "command", id: "req_v2", @@ -2937,36 +2998,135 @@ describe("RemoteSender slash commands", () => { sessionId: "ses_current", data: { protocolVersion: 2 }, }) + // Connection-scoped invalid protocol version sender.handle({ type: "command", - id: "req_no_session", + id: "req_v2_conn", command: "create_session", - data: { protocolVersion: 1 }, + data: { protocolVersion: 2 }, }) + // Session-scoped extra field (strict schema) sender.handle({ type: "command", - id: "req_bad_session", + id: "req_extra_field", command: "create_session", - sessionId: "not-a-session-id", - data: { protocolVersion: 1 }, + sessionId: "ses_current", + data: { protocolVersion: 1, extra: true }, }) + // Connection-scoped extra field sender.handle({ type: "command", - id: "req_extra_field", + id: "req_extra_field_conn", command: "create_session", - sessionId: "ses_current", data: { protocolVersion: 1, extra: true }, }) expect(sent).toEqual([ { type: "response", id: "req_v2", error: "invalid create_session command" }, - { type: "response", id: "req_no_session", error: "invalid create_session command" }, - { type: "response", id: "req_bad_session", error: "invalid create_session command" }, + { type: "response", id: "req_v2_conn", error: "invalid create_session command" }, { type: "response", id: "req_extra_field", error: "invalid create_session command" }, + { type: "response", id: "req_extra_field_conn", error: "invalid create_session command" }, ]) expect(createCalls).toHaveLength(0) }) + test("create_session rejects present but undecodable sessionId without creating", async () => { + const { conn, sent } = fakeConn() + const createCalls: unknown[] = [] + const getCalls: string[] = [] + const dirs: string[] = [] + const sender = RemoteSender.create({ + conn, + directory: "/workspace/launch-dir", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => { + dirs.push(input.directory) + return input.fn() + }, + session: { + get: async (sessionID) => { + getCalls.push(sessionID) + throw new Error("must not look up session for undecodable sessionId") + }, + children: async () => [], + create: async (input) => { + createCalls.push(input) + return { id: SessionID.make("ses_unused") } as any + }, + }, + attachSession: async () => { + throw new Error("must not attach for undecodable sessionId") + }, + }) + ;(conn as any).heartbeat = async () => { + throw new Error("must not heartbeat for undecodable sessionId") + } + + // Present malformed sessionId + valid data must NOT fall through to the + // connection-scoped path (options.directory); it is rejected like a parse gate. + sender.handle({ + type: "command", + id: "req_create_bad_session", + command: "create_session", + sessionId: "not-a-session-id", + data: { protocolVersion: 1 }, + }) + + expect(sent).toEqual([ + { type: "response", id: "req_create_bad_session", error: "invalid create_session command" }, + ]) + expect(createCalls).toHaveLength(0) + expect(getCalls).toEqual([]) + expect(dirs).toEqual([]) + }) + + test("create_session rolls back the created session when attachSession fails on the connection-scoped path", async () => { + const { conn, sent } = fakeConn() + const dirs: string[] = [] + const removeCalls: string[] = [] + const getCalls: string[] = [] + const sender = RemoteSender.create({ + conn, + directory: "/workspace/launch-dir", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => { + dirs.push(input.directory) + return input.fn() + }, + session: { + get: async (sessionID) => { + getCalls.push(sessionID) + throw new Error("must not look up session for connection-scoped create_session") + }, + children: async () => [], + create: async () => ({ id: SessionID.make("ses_conn_new"), directory: "/workspace/launch-dir" }) as any, + remove: async (id) => { + removeCalls.push(id) + }, + }, + attachSession: async () => { + throw new Error("attach failed: credential=must-not-leak") + }, + }) + + const response = expectResponse(conn, sent, "req_attach_failed_conn") + sender.handle({ + type: "command", + id: "req_attach_failed_conn", + command: "create_session", + data: { protocolVersion: 1 }, + }) + await response.promise + response.restore() + + expect(dirs).toEqual(["/workspace/launch-dir"]) + expect(getCalls).toEqual([]) + expect(removeCalls).toEqual(["ses_conn_new"]) + expect(sent).toEqual([{ type: "response", id: "req_attach_failed_conn", error: "failed to create session" }]) + }) + test("create_session returns a sanitized error and never reports success when creation throws", async () => { const { conn, sent } = fakeConn() const logEntries: unknown[][] = [] From ffe38074c5151e4e83c2bc7fad50702529e1bc15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 24 Jul 2026 08:58:33 +0200 Subject: [PATCH 3/4] refactor(cli): dedupe remote instance label sanitization --- .../opencode/src/kilo-sessions/kilo-sessions.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index ff0806633fe..39b0d7540a8 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -38,13 +38,18 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { InstallationVersion } from "@opencode-ai/core/installation/version" import os from "os" -/** Strip controls, collapse whitespace, clamp, fall back when empty. */ -export function sanitizeLabel(value: string, fallback: string, max: number): string { - const cleaned = value +/** Strip controls, collapse whitespace, clamp. Empty input may yield "". */ +function cleanLabel(value: string, max: number): string { + return value .replace(/[\u0000-\u001f\u007f]/g, " ") .replace(/\s+/g, " ") .trim() .slice(0, max) +} + +/** Strip controls, collapse whitespace, clamp, fall back when empty. */ +export function sanitizeLabel(value: string, fallback: string, max: number): string { + const cleaned = cleanLabel(value, max) return cleaned.length > 0 ? cleaned : fallback } @@ -63,11 +68,7 @@ export function hostLabel(hostname = os.hostname()): string { /** CLI version clamped to the cloud contract's 32-char cap. */ export function versionLabel(version = InstallationVersion): string | undefined { - const cleaned = version - .replace(/[\u0000-\u001f\u007f]/g, " ") - .replace(/\s+/g, " ") - .trim() - .slice(0, 32) + const cleaned = cleanLabel(version, 32) return cleaned.length > 0 ? cleaned : undefined } From e840a493924f989824e6d27ffe5c601ff9cce527 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 24 Jul 2026 09:06:29 +0200 Subject: [PATCH 4/4] chore(cli): retrigger code review after workspace setup failure