mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user