diff --git a/packages/opencode/src/kilo-sessions/remote-command.ts b/packages/opencode/src/kilo-sessions/remote-command.ts index 9c204739f2..a072dbf8c2 100644 --- a/packages/opencode/src/kilo-sessions/remote-command.ts +++ b/packages/opencode/src/kilo-sessions/remote-command.ts @@ -10,6 +10,7 @@ import z from "zod" export namespace RemoteCommand { export const MAX_COMMANDS = 256 + export const MAX_DIRECTORIES = 256 export const MAX_STRING_LENGTH = 2_000 export const MAX_ARGUMENTS_LENGTH = 32_768 export const MAX_HINTS = 32 @@ -21,6 +22,31 @@ export namespace RemoteCommand { }) .strict() + export const ListDirectoriesRequest = z + .object({ + protocolVersion: z.literal(1), + path: z.string().min(1).max(MAX_STRING_LENGTH).optional(), + }) + .strict() + export type ListDirectoriesRequest = z.infer + + export const ListDirectoriesEntry = z + .object({ + name: z.string().min(1).max(MAX_STRING_LENGTH), + path: z.string().min(1).max(MAX_STRING_LENGTH), + }) + .strict() + export type ListDirectoriesEntry = z.infer + + export const ListDirectoriesResponse = z + .object({ + protocolVersion: z.literal(1), + path: z.string(), + directories: z.array(ListDirectoriesEntry).max(MAX_DIRECTORIES), + }) + .strict() + export type ListDirectoriesResponse = z.infer + export const ExitRequest = z .object({ protocolVersion: z.literal(1), diff --git a/packages/opencode/src/kilo-sessions/remote-sender.ts b/packages/opencode/src/kilo-sessions/remote-sender.ts index 578e1e3032..6a805fe138 100644 --- a/packages/opencode/src/kilo-sessions/remote-sender.ts +++ b/packages/opencode/src/kilo-sessions/remote-sender.ts @@ -22,6 +22,9 @@ import { ModelV2 } from "@opencode-ai/core/model" import z from "zod" import { zodObject } from "@opencode-ai/core/effect-zod" import { Effect, Option, Schema } from "effect" +import { readdir } from "node:fs/promises" +import { isAbsolute, join, relative, sep } from "path" +import { Filesystem } from "@/util/filesystem" type Provide = typeof import("@/kilocode/instance").provide @@ -61,6 +64,7 @@ const CreateSessionRequest = z agent: z.string().min(1).optional(), model: CreateSessionModel.optional(), orgId: z.string().uuid().optional(), + directory: z.string().min(1).max(RemoteCommand.MAX_STRING_LENGTH).optional(), // kilocode_change - cloneFromKiloSessionId: optional cloud-session import. // The old wire form omits this field and performs a fresh sessionCreate; // remove the fresh-create branch when every shipped CLI advertises sessionClone. @@ -94,6 +98,29 @@ function errorName(error: unknown): string { } // kilocode_change end +// kilocode_change - k1: resolve a client-supplied directory path under the +// launch directory. Reuses Filesystem.resolve (canonicalize) and +// Filesystem.contains (lexical containment on canonical paths) so a symlink +// that escapes the launch directory is rejected, not a bespoke check. +function resolveUnderLaunch(launchDir: string, relative: string | undefined): string { + const launchReal = Filesystem.resolve(launchDir) + if (relative === undefined) return launchReal + if (isAbsolute(relative)) throw new Error("absolute directory paths are not allowed") + // Require the target to exist and be a directory before canonicalizing: + // Filesystem.resolve falls back to the lexical path on ENOENT, so a + // symlinked ancestor with a missing final component would pass the + // containment test below. Both callers select an existing folder. + const requested = join(launchDir, relative) + if (!Filesystem.stat(requested)?.isDirectory()) { + throw new Error("directory does not exist") + } + const requestedReal = Filesystem.resolve(requested) + if (!Filesystem.contains(launchReal, requestedReal)) { + throw new Error("directory path escapes the launch directory") + } + return requestedReal +} + // kilocode_change - create_session cloud-import error mapping. The import seam // rejects with a tagged error carrying the upstream `status` (or a // "CloudSessionImportUnauthorized" tag for missing credentials). Map those to @@ -669,6 +696,80 @@ export namespace RemoteSender { })() return } + // kilocode_change - k1: connection-scoped directory listing for the + // instance-picker folder tree. Lists exactly one level, directories + // only, with symlink escapes skipped. + if (msg.command === "list_directories") { + const parsed = RemoteCommand.ListDirectoriesRequest.safeParse(msg.data) + if (!parsed.success) { + options.conn.send({ + type: "response", + id: msg.id, + error: "invalid list_directories request", + }) + return + } + const launchReal = Filesystem.resolve(options.directory) + let listed: string + try { + listed = resolveUnderLaunch(options.directory, parsed.data.path) + } catch { + options.conn.send({ + type: "response", + id: msg.id, + error: "invalid list_directories path", + }) + return + } + void (async () => { + try { + const entries = await readdir(listed, { withFileTypes: true }) + const directories: RemoteCommand.ListDirectoriesEntry[] = [] + for (const entry of entries) { + if (directories.length >= RemoteCommand.MAX_DIRECTORIES) break + let childReal: string + try { + childReal = Filesystem.resolve(join(listed, entry.name)) + } catch { + continue // skip a child whose canonical path cannot be resolved (ELOOP, EACCES) + } + // A symlink whose canonical path equals the launch directory + // resolves to launchReal, so its relative path is "" and would + // violate ListDirectoriesEntry.path min(1). Never emit it. + const childPath = relative(launchReal, childReal).split(sep).join("/") + if (!childPath) continue + // Windows junctions report isDirectory() without isSymbolicLink(), so apply + // the containment check to every entry, not only symlinks. + if (!Filesystem.contains(launchReal, childReal)) continue + if (entry.isDirectory()) { + directories.push({ name: entry.name, path: childPath }) + continue + } + if (entry.isSymbolicLink()) { + let realIsDir = false + try { + realIsDir = Filesystem.stat(childReal)?.isDirectory() === true + } catch { + continue + } + if (realIsDir) { + directories.push({ name: entry.name, path: childPath }) + } + } + } + const listedRelative = relative(launchReal, listed).split(sep).join("/") + options.conn.send({ + type: "response", + id: msg.id, + result: { protocolVersion: 1, path: listedRelative, directories }, + }) + } catch (error) { + options.log.error("list directories failed", { id: msg.id, error: errorName(error) }) + options.conn.send({ type: "response", id: msg.id, error: "failed to list directories" }) + } + })() + return + } if (msg.command === "send_command") { const parsed = RemoteCommand.SendRequest.safeParse(msg.data) const session = msg.sessionId ? decodeSessionID(msg.sessionId) : Option.none() @@ -870,18 +971,44 @@ export namespace RemoteSender { : {}), ...(parsed.data.orgId ? { metadata: { orgId: parsed.data.orgId } } : {}), } + // kilocode_change - k1: a present `directory` starts the session in a + // contained relative path under the launch directory. Resolve it before + // the create try/catch so a bad path is a request error ("invalid + // create_session directory"), not a create failure. Old clients omit + // `directory`; keep the sessionId/options.directory fallback below until + // every supported client sends it. + const targetOverride = (() => { + if (parsed.data.directory === undefined) return undefined + try { + return resolveUnderLaunch(options.directory, parsed.data.directory) + } catch { + return undefined + } + })() + if (parsed.data.directory !== undefined && targetOverride === undefined) { + options.conn.send({ + type: "response", + id: msg.id, + error: "invalid create_session directory", + }) + return + } const run = options.provide ?? provide void (async () => { try { - // Resolve the target directory: a present `sessionId` keeps the - // legacy mobile /new-inside-a-session behavior (target = that - // session's directory); an absent `sessionId` targets the - // instance's own launch directory (the new instance-picker path). - const targetDirectory = await current.pipe( - Option.map((sid) => session.get(sid)), - Option.map((p) => p.then((info) => info.directory)), - Option.getOrElse(() => Promise.resolve(options.directory)), - ) + // Resolve the target directory: a present `directory` field wins + // (contained relative path under the launch directory); otherwise a + // present `sessionId` keeps the legacy mobile + // /new-inside-a-session behavior (target = that session's + // directory); an absent `sessionId` targets the instance's own + // launch directory (the new instance-picker path). + const targetDirectory = + targetOverride ?? + (await current.pipe( + Option.map((sid) => session.get(sid)), + Option.map((p) => p.then((info) => info.directory)), + Option.getOrElse(() => Promise.resolve(options.directory)), + )) if (cloneId) { // Clone path: import in-process, then attach. Import failures // map to the exact literals and never fall back to a fresh diff --git a/packages/opencode/test/kilocode/sessions/remote-command.test.ts b/packages/opencode/test/kilocode/sessions/remote-command.test.ts index 3797bf138d..c7dbd53e0a 100644 --- a/packages/opencode/test/kilocode/sessions/remote-command.test.ts +++ b/packages/opencode/test/kilocode/sessions/remote-command.test.ts @@ -12,6 +12,36 @@ describe("RemoteCommand", () => { expect(RemoteCommand.ListRequest.safeParse({ protocolVersion: 1, extra: true }).success).toBe(false) }) + test("validates list_directories requests and responses", () => { + expect(RemoteCommand.ListDirectoriesRequest.safeParse({ protocolVersion: 1 }).success).toBe(true) + expect(RemoteCommand.ListDirectoriesRequest.safeParse({ protocolVersion: 1, path: "sub/dir" }).success).toBe(true) + expect(RemoteCommand.ListDirectoriesRequest.safeParse({ protocolVersion: 2 }).success).toBe(false) + expect(RemoteCommand.ListDirectoriesRequest.safeParse({ protocolVersion: 1, extra: true }).success).toBe(false) + expect(RemoteCommand.ListDirectoriesRequest.safeParse({ protocolVersion: 1, path: "" }).success).toBe(false) + expect( + RemoteCommand.ListDirectoriesRequest.safeParse({ + protocolVersion: 1, + path: "x".repeat(RemoteCommand.MAX_STRING_LENGTH + 1), + }).success, + ).toBe(false) + + expect( + RemoteCommand.ListDirectoriesResponse.safeParse({ + protocolVersion: 1, + path: "", + directories: [{ name: "src", path: "src" }], + }).success, + ).toBe(true) + expect( + RemoteCommand.ListDirectoriesResponse.safeParse({ + protocolVersion: 1, + path: "", + directories: [], + extra: true, + }).success, + ).toBe(false) + }) + test("validates strict exit CLI requests", () => { expect(RemoteCommand.ExitRequest.safeParse({ protocolVersion: 1 }).success).toBe(true) expect(RemoteCommand.ExitRequest.safeParse({}).success).toBe(false) diff --git a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts index 50d627c67b..6a139b1326 100644 --- a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts +++ b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts @@ -18,6 +18,9 @@ import { SessionID } from "../../../src/session/schema" import { Session } from "../../../src/session/session" import { Suggestion } from "../../../src/kilocode/suggestion" import { KiloSessionPromptQueue } from "../../../src/kilocode/session/prompt-queue" +import { mkdir, symlink, writeFile } from "node:fs/promises" +import { join } from "node:path" +import { tmpdir } from "../../fixture/fixture" function fakeConn() { const sent: any[] = [] @@ -4303,5 +4306,377 @@ describe("RemoteSender slash commands", () => { // Production catch must clear the re-mark so a later local write is not skipped. expect(consumeRenameAdoption(sid, "Cloud title")).toBe(false) }) + + // k1: list_directories — one level, directories only, no recursion, symlink + // escapes skipped. + test("list_directories lists one level of directories at launch and omits files", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await mkdir(join(dir, "alpha")) + await mkdir(join(dir, "beta", "nested"), { recursive: true }) + await writeFile(join(dir, "file.txt"), "x") + }, + }) + const { conn, sent } = fakeConn() + const sender = RemoteSender.create({ + conn, + directory: tmp.path, + log: nolog, + subscribe: fakeBus().subscribe, + }) + + const response = expectResponse(conn, sent, "req_list") + sender.handle({ type: "command", id: "req_list", command: "list_directories", data: { protocolVersion: 1 } }) + await response.promise + response.restore() + + const result = sent[0]?.result + expect(result.protocolVersion).toBe(1) + expect(result.path).toBe("") + expect(result.directories.map((d: any) => d.name).sort()).toEqual(["alpha", "beta"]) + expect(result.directories.map((d: any) => d.name)).not.toContain("file.txt") + expect(result.directories.find((d: any) => d.name === "beta")?.path).toBe("beta") + }) + + test("list_directories lists only the requested child level, not grandchildren", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await mkdir(join(dir, "beta", "nested", "deep"), { recursive: true }) + await writeFile(join(dir, "beta", "nested", "file.txt"), "x") + }, + }) + const { conn, sent } = fakeConn() + const sender = RemoteSender.create({ + conn, + directory: tmp.path, + log: nolog, + subscribe: fakeBus().subscribe, + }) + + const response = expectResponse(conn, sent, "req_child") + sender.handle({ + type: "command", + id: "req_child", + command: "list_directories", + data: { protocolVersion: 1, path: "beta" }, + }) + await response.promise + response.restore() + + expect(sent).toEqual([ + { + type: "response", + id: "req_child", + result: { protocolVersion: 1, path: "beta", directories: [{ name: "nested", path: "beta/nested" }] }, + }, + ]) + }) + + test("list_directories rejects a relative path outside the launch directory", async () => { + await using tmp = await tmpdir() + const { conn, sent } = fakeConn() + const sender = RemoteSender.create({ + conn, + directory: tmp.path, + log: nolog, + subscribe: fakeBus().subscribe, + }) + + sender.handle({ + type: "command", + id: "req_escape", + command: "list_directories", + data: { protocolVersion: 1, path: ".." }, + }) + + expect(sent).toEqual([{ type: "response", id: "req_escape", error: "invalid list_directories path" }]) + }) + + test("list_directories omits a symlink child that resolves outside the launch directory", async () => { + await using outside = await tmpdir({ + init: async (dir) => { + await mkdir(join(dir, "escape")) + }, + }) + await using tmp = await tmpdir({ + init: async (dir) => { + await mkdir(join(dir, "alpha")) + await symlink(join(outside.path, "escape"), join(dir, "link-out")) + }, + }) + const { conn, sent } = fakeConn() + const sender = RemoteSender.create({ + conn, + directory: tmp.path, + log: nolog, + subscribe: fakeBus().subscribe, + }) + + const response = expectResponse(conn, sent, "req_symlink") + sender.handle({ type: "command", id: "req_symlink", command: "list_directories", data: { protocolVersion: 1 } }) + await response.promise + response.restore() + + const names = sent[0]?.result.directories.map((d: any) => d.name) + expect(names).toContain("alpha") + expect(names).not.toContain("link-out") + }) + + test("list_directories keeps a symlink to a contained directory and omits a symlink to a file", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await mkdir(join(dir, "real-dir")) + await writeFile(join(dir, "real-file.txt"), "x") + await symlink(join(dir, "real-dir"), join(dir, "link-dir")) + await symlink(join(dir, "real-file.txt"), join(dir, "link-file")) + }, + }) + const { conn, sent } = fakeConn() + const sender = RemoteSender.create({ + conn, + directory: tmp.path, + log: nolog, + subscribe: fakeBus().subscribe, + }) + + const response = expectResponse(conn, sent, "req_symlink_in") + sender.handle({ type: "command", id: "req_symlink_in", command: "list_directories", data: { protocolVersion: 1 } }) + await response.promise + response.restore() + + const names = sent[0]?.result.directories.map((d: any) => d.name).sort() + expect(names).toEqual(["link-dir", "real-dir"]) + expect(names).not.toContain("link-file") + expect(names).not.toContain("real-file.txt") + }) + + test("list_directories omits a symlink whose canonical path equals the launch directory", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await mkdir(join(dir, "alpha")) + await symlink(dir, join(dir, "link-self")) + }, + }) + const { conn, sent } = fakeConn() + const sender = RemoteSender.create({ + conn, + directory: tmp.path, + log: nolog, + subscribe: fakeBus().subscribe, + }) + + const response = expectResponse(conn, sent, "req_self_symlink") + sender.handle({ + type: "command", + id: "req_self_symlink", + command: "list_directories", + data: { protocolVersion: 1 }, + }) + await response.promise + response.restore() + + const directories = sent[0]?.result.directories + expect(directories.map((d: any) => d.name).sort()).toEqual(["alpha"]) + expect(directories.every((d: any) => d.path !== "")).toBe(true) + }) + + test("list_directories caps the response at MAX_DIRECTORIES entries", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + for (let i = 0; i < RemoteCommand.MAX_DIRECTORIES + 5; i++) { + await mkdir(join(dir, `dir-${String(i).padStart(3, "0")}`)) + } + }, + }) + const { conn, sent } = fakeConn() + const sender = RemoteSender.create({ + conn, + directory: tmp.path, + log: nolog, + subscribe: fakeBus().subscribe, + }) + + const response = expectResponse(conn, sent, "req_cap") + sender.handle({ type: "command", id: "req_cap", command: "list_directories", data: { protocolVersion: 1 } }) + await response.promise + response.restore() + + const directories = sent[0]?.result.directories + expect(directories).toHaveLength(RemoteCommand.MAX_DIRECTORIES) + }) + + test("list_directories rejects an invalid request", () => { + const { conn, sent } = fakeConn() + const sender = RemoteSender.create({ + conn, + directory: "/tmp/test", + log: nolog, + subscribe: fakeBus().subscribe, + }) + + sender.handle({ type: "command", id: "req_bad", command: "list_directories", data: { protocolVersion: 2 } }) + + expect(sent).toEqual([{ type: "response", id: "req_bad", error: "invalid list_directories request" }]) + }) + + test("list_directories does not shadow the unknown-command fallback for other names", () => { + const { conn, sent } = fakeConn() + const sender = RemoteSender.create({ + conn, + directory: "/tmp/test", + log: nolog, + subscribe: fakeBus().subscribe, + }) + + sender.handle({ type: "command", id: "req_unknown", command: "list_dirs", data: {} } as RemoteProtocol.Command) + + expect(sent).toEqual([{ type: "response", id: "req_unknown", error: "unknown command: list_dirs" }]) + }) + + // k1: create_session.directory — contained relative path override. + test("create_session starts in the contained relative directory when directory is set", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await mkdir(join(dir, "child")) + }, + }) + const { conn, sent } = fakeConn() + const dirs: string[] = [] + const attachCalls: SessionID[] = [] + const sender = RemoteSender.create({ + conn, + directory: tmp.path, + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => { + dirs.push(input.directory) + return input.fn() + }, + session: { + get: async () => { + throw new Error("session.get must not be called when directory overrides") + }, + children: async () => [], + create: async () => ({ id: SessionID.make("ses_dir"), directory: join(tmp.path, "child") }) as any, + }, + attachSession: async (input) => { + attachCalls.push(input) + return + }, + }) + + const response = expectResponse(conn, sent, "req_dir") + sender.handle({ + type: "command", + id: "req_dir", + command: "create_session", + data: { protocolVersion: 1, directory: "child" }, + }) + await response.promise + response.restore() + + expect(dirs).toEqual([join(tmp.path, "child")]) + expect(attachCalls).toEqual([SessionID.make("ses_dir")]) + expect(sent).toEqual([{ type: "response", id: "req_dir", result: { protocolVersion: 1, sessionID: "ses_dir" } }]) + }) + + test("create_session rejects an escaped or absolute directory and does not create a session", async () => { + await using tmp = await tmpdir() + const { conn, sent } = fakeConn() + const createCalls: unknown[] = [] + const attachCalls: unknown[] = [] + const sender = RemoteSender.create({ + conn, + directory: tmp.path, + log: nolog, + subscribe: fakeBus().subscribe, + session: { + get: async () => { + throw new Error("session.get must not be called") + }, + children: async () => [], + create: async (input) => { + createCalls.push(input) + return { id: SessionID.make("ses_x"), directory: tmp.path } as any + }, + }, + attachSession: async (input) => { + attachCalls.push(input) + return + }, + }) + + sender.handle({ + type: "command", + id: "req_escape", + command: "create_session", + data: { protocolVersion: 1, directory: ".." }, + }) + sender.handle({ + type: "command", + id: "req_abs", + command: "create_session", + data: { protocolVersion: 1, directory: "/tmp" }, + }) + + expect(sent).toEqual([ + { type: "response", id: "req_escape", error: "invalid create_session directory" }, + { type: "response", id: "req_abs", error: "invalid create_session directory" }, + ]) + expect(createCalls).toEqual([]) + expect(attachCalls).toEqual([]) + }) + + test("create_session rejects a missing target under a symlinked ancestor that escapes", async () => { + await using outside = await tmpdir() + await using tmp = await tmpdir({ + init: async (dir) => { + await symlink(outside.path, join(dir, "link"), "dir") + }, + }) + const { conn, sent } = fakeConn() + const provideCalls: unknown[] = [] + const createCalls: unknown[] = [] + const sender = RemoteSender.create({ + conn, + directory: tmp.path, + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => { + provideCalls.push(input.directory) + return input.fn() + }, + session: { + get: async () => { + throw new Error("session.get must not be called") + }, + children: async () => [], + create: async (input) => { + createCalls.push(input) + return { id: SessionID.make("ses_x"), directory: tmp.path } as any + }, + }, + }) + + sender.handle({ + type: "command", + id: "req_missing", + command: "create_session", + data: { protocolVersion: 1, directory: "link/missing" }, + }) + sender.handle({ + type: "command", + id: "req_list_missing", + command: "list_directories", + data: { protocolVersion: 1, path: "link/missing" }, + }) + + expect(sent).toEqual([ + { type: "response", id: "req_missing", error: "invalid create_session directory" }, + { type: "response", id: "req_list_missing", error: "invalid list_directories path" }, + ]) + expect(provideCalls).toEqual([]) + expect(createCalls).toEqual([]) + }) }) // kilocode_change end