feat(remote): add directory listing and create_session directory (#13497)

* feat(remote): add directory listing and create_session directory

* fix(remote): harden list_directories containment and per-entry skip

* fix(remote): reject a missing directory under a symlinked ancestor

Filesystem.resolve returns the lexical path on ENOENT, so a requested
path whose final component does not exist skipped canonicalization. A
symlinked ancestor that points outside the launch directory then passed
the containment test. Require the target to exist and be a directory
before canonicalizing.
This commit is contained in:
Igor Šćekić
2026-08-27 18:05:31 +02:00
committed by GitHub
parent 5fba3ea9a8
commit c03a203944
4 changed files with 567 additions and 9 deletions
@@ -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<typeof ListDirectoriesRequest>
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<typeof ListDirectoriesEntry>
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<typeof ListDirectoriesResponse>
export const ExitRequest = z
.object({
protocolVersion: z.literal(1),
@@ -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<SessionID>()
@@ -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
@@ -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)
@@ -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 <R>(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 <R>(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