From 3da9630ce518b7b44bb97b849d5efd35bb1f5d6f Mon Sep 17 00:00:00 2001 From: Aarav Sharma Date: Fri, 31 Jul 2026 20:37:28 -0600 Subject: [PATCH 1/2] feat(world): add browser automation runtime --- .gitignore | 1 + bun.lock | 19 +- package.json | 3 +- packages/kilo-world/package.json | 39 ++ packages/kilo-world/script/build-daemon.ts | 116 ++++ packages/kilo-world/script/daemon.ts | 105 ++++ packages/kilo-world/src/client.ts | 6 + .../kilo-world/src/commands/browser/click.ts | 25 + .../kilo-world/src/commands/browser/close.ts | 9 + .../src/commands/browser/cookies.ts | 79 +++ .../kilo-world/src/commands/browser/drag.ts | 42 ++ .../src/commands/browser/evaluate.ts | 27 + .../kilo-world/src/commands/browser/fill.ts | 22 + .../kilo-world/src/commands/browser/hover.ts | 21 + .../src/commands/browser/navigate.ts | 27 + .../src/commands/browser/press-key.ts | 24 + .../src/commands/browser/screenshot.ts | 47 ++ .../kilo-world/src/commands/browser/scroll.ts | 23 + .../src/commands/browser/snapshot.ts | 11 + .../kilo-world/src/commands/browser/status.ts | 26 + .../kilo-world/src/commands/browser/tabs.ts | 69 +++ .../kilo-world/src/commands/browser/type.ts | 27 + .../src/commands/browser/wait-for.ts | 32 ++ packages/kilo-world/src/config.ts | 58 ++ .../kilo-world/src/core/browser/chrome.ts | 50 ++ .../kilo-world/src/core/browser/detect.ts | 22 + .../kilo-world/src/core/browser/launch.ts | 48 ++ packages/kilo-world/src/core/browser/refs.ts | 243 ++++++++ .../kilo-world/src/core/browser/runner.ts | 213 +++++++ packages/kilo-world/src/daemon/build.ts | 108 ++++ packages/kilo-world/src/daemon/client.ts | 540 ++++++++++++++++++ packages/kilo-world/src/daemon/dispatch.ts | 344 +++++++++++ packages/kilo-world/src/daemon/entry.ts | 7 + packages/kilo-world/src/daemon/protocol.ts | 104 ++++ packages/kilo-world/src/daemon/runtime.ts | 22 + packages/kilo-world/src/daemon/server.ts | 493 ++++++++++++++++ packages/kilo-world/src/index.ts | 64 +++ packages/kilo-world/src/path.ts | 18 + packages/kilo-world/src/script-parser.ts | 97 ++++ packages/kilo-world/src/types.ts | 122 ++++ packages/kilo-world/test/build.test.ts | 51 ++ packages/kilo-world/test/daemon.test.ts | 307 ++++++++++ packages/kilo-world/test/launch.test.ts | 64 +++ packages/kilo-world/test/parse-script.test.ts | 146 +++++ packages/kilo-world/test/runner-node.ts | 37 ++ packages/kilo-world/tsconfig.json | 11 + 46 files changed, 3967 insertions(+), 2 deletions(-) create mode 100644 packages/kilo-world/package.json create mode 100644 packages/kilo-world/script/build-daemon.ts create mode 100644 packages/kilo-world/script/daemon.ts create mode 100644 packages/kilo-world/src/client.ts create mode 100644 packages/kilo-world/src/commands/browser/click.ts create mode 100644 packages/kilo-world/src/commands/browser/close.ts create mode 100644 packages/kilo-world/src/commands/browser/cookies.ts create mode 100644 packages/kilo-world/src/commands/browser/drag.ts create mode 100644 packages/kilo-world/src/commands/browser/evaluate.ts create mode 100644 packages/kilo-world/src/commands/browser/fill.ts create mode 100644 packages/kilo-world/src/commands/browser/hover.ts create mode 100644 packages/kilo-world/src/commands/browser/navigate.ts create mode 100644 packages/kilo-world/src/commands/browser/press-key.ts create mode 100644 packages/kilo-world/src/commands/browser/screenshot.ts create mode 100644 packages/kilo-world/src/commands/browser/scroll.ts create mode 100644 packages/kilo-world/src/commands/browser/snapshot.ts create mode 100644 packages/kilo-world/src/commands/browser/status.ts create mode 100644 packages/kilo-world/src/commands/browser/tabs.ts create mode 100644 packages/kilo-world/src/commands/browser/type.ts create mode 100644 packages/kilo-world/src/commands/browser/wait-for.ts create mode 100644 packages/kilo-world/src/config.ts create mode 100644 packages/kilo-world/src/core/browser/chrome.ts create mode 100644 packages/kilo-world/src/core/browser/detect.ts create mode 100644 packages/kilo-world/src/core/browser/launch.ts create mode 100644 packages/kilo-world/src/core/browser/refs.ts create mode 100644 packages/kilo-world/src/core/browser/runner.ts create mode 100644 packages/kilo-world/src/daemon/build.ts create mode 100644 packages/kilo-world/src/daemon/client.ts create mode 100644 packages/kilo-world/src/daemon/dispatch.ts create mode 100644 packages/kilo-world/src/daemon/entry.ts create mode 100644 packages/kilo-world/src/daemon/protocol.ts create mode 100644 packages/kilo-world/src/daemon/runtime.ts create mode 100644 packages/kilo-world/src/daemon/server.ts create mode 100644 packages/kilo-world/src/index.ts create mode 100644 packages/kilo-world/src/path.ts create mode 100644 packages/kilo-world/src/script-parser.ts create mode 100644 packages/kilo-world/src/types.ts create mode 100644 packages/kilo-world/test/build.test.ts create mode 100644 packages/kilo-world/test/daemon.test.ts create mode 100644 packages/kilo-world/test/launch.test.ts create mode 100644 packages/kilo-world/test/parse-script.test.ts create mode 100644 packages/kilo-world/test/runner-node.ts create mode 100644 packages/kilo-world/tsconfig.json diff --git a/.gitignore b/.gitignore index ad3b43d63d5..75497263517 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ node_modules playground tmp dist +packages/kilo-world/.world-daemon-* ts-dist storybook-static .turbo diff --git a/bun.lock b/bun.lock index e2b68afdf4f..bcb86ee4d73 100644 --- a/bun.lock +++ b/bun.lock @@ -474,6 +474,20 @@ "typescript": "catalog:", }, }, + "packages/kilo-world": { + "name": "@kilocode/world", + "version": "0.1.0", + "dependencies": { + "playwright": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@types/node": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:", + }, + }, "packages/llm": { "name": "@opencode-ai/llm", "version": "7.4.18", @@ -901,7 +915,7 @@ "@opentui/keymap": "0.3.4", "@opentui/solid": "0.3.4", "@pierre/diffs": "1.2.10", - "@playwright/test": "1.59.1", + "@playwright/test": "1.57.0", "@shikijs/stream": "4.2.0", "@solid-primitives/storage": "4.3.3", "@solidjs/meta": "0.29.4", @@ -932,6 +946,7 @@ "marked": "17.0.1", "marked-shiki": "1.2.1", "opentui-spinner": "0.0.7", + "playwright": "1.57.0", "remeda": "2.26.0", "remend": "1.3.0", "semver": "7.7.4", @@ -1554,6 +1569,8 @@ "@kilocode/sdk": ["@kilocode/sdk@workspace:packages/sdk/js"], + "@kilocode/world": ["@kilocode/world@workspace:packages/kilo-world"], + "@kobalte/core": ["@kobalte/core@0.13.11", "", { "dependencies": { "@floating-ui/dom": "^1.5.1", "@internationalized/date": "^3.4.0", "@internationalized/number": "^3.2.1", "@kobalte/utils": "^0.9.1", "@solid-primitives/props": "^3.1.8", "@solid-primitives/resize-observer": "^2.0.26", "solid-presence": "^0.1.8", "solid-prevent-scroll": "^0.1.4" }, "peerDependencies": { "solid-js": "^1.8.15" } }, "sha512-hK7TYpdib/XDb/r/4XDBFaO9O+3ZHz4ZWryV4/3BfES+tSQVgg2IJupDnztKXB0BqbSRy/aWlHKw1SPtNPYCFQ=="], "@kobalte/utils": ["@kobalte/utils@0.9.1", "", { "dependencies": { "@solid-primitives/event-listener": "^2.2.14", "@solid-primitives/keyed": "^1.2.0", "@solid-primitives/map": "^0.4.7", "@solid-primitives/media": "^2.2.4", "@solid-primitives/props": "^3.1.8", "@solid-primitives/refs": "^1.0.5", "@solid-primitives/utils": "^6.2.1" }, "peerDependencies": { "solid-js": "^1.8.8" } }, "sha512-eeU60A3kprIiBDAfv9gUJX1tXGLuZiKMajUfSQURAF2pk4ZoMYiqIzmrMBvzcxP39xnYttgTyQEVLwiTZnrV4w=="], diff --git a/package.json b/package.json index cb7a4a91b07..af5837b8569 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,8 @@ "marked": "17.0.1", "marked-shiki": "1.2.1", "remend": "1.3.0", - "@playwright/test": "1.59.1", + "@playwright/test": "1.57.0", + "playwright": "1.57.0", "semver": "7.7.4", "typescript": "5.8.2", "@typescript/native-preview": "7.0.0-dev.20260316.1", diff --git a/packages/kilo-world/package.json b/packages/kilo-world/package.json new file mode 100644 index 00000000000..6d80a874ef9 --- /dev/null +++ b/packages/kilo-world/package.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@kilocode/world", + "version": "0.1.0", + "type": "module", + "license": "MIT", + "private": true, + "description": "Browser-automation library backing the kilo world agent tool.", + "main": "./src/index.ts", + "module": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./client": "./src/client.ts", + "./daemon": "./src/daemon/runtime.ts", + "./runner": "./src/core/browser/runner.ts", + "./types": "./src/types.ts" + }, + "files": [ + "src" + ], + "scripts": { + "typecheck": "tsgo --noEmit", + "build": "bun build src/index.ts --target bun --external electron --external chromium-bidi --outdir dist --entry-naming index.js && bun run build:daemon", + "build:daemon": "bun run script/build-daemon.ts", + "test": "bun test --timeout 30000", + "test:ci": "bunx playwright install chromium && mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml" + }, + "dependencies": { + "playwright": "catalog:" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@types/node": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/kilo-world/script/build-daemon.ts b/packages/kilo-world/script/build-daemon.ts new file mode 100644 index 00000000000..449f7541c8c --- /dev/null +++ b/packages/kilo-world/script/build-daemon.ts @@ -0,0 +1,116 @@ +#!/usr/bin/env bun +import fs from "node:fs/promises" +import path from "node:path" +import { WorldDaemon } from "./daemon" +import { fingerprint, fresh, LOCK, LOCK_TIMEOUT_MS, STAMP } from "../src/daemon/build" + +const dir = path.resolve(import.meta.dirname, "../dist") +const root = path.dirname(dir) +await fs.mkdir(dir, { recursive: true }) +const manifest = path.join(dir, WorldDaemon.manifest) +const lock = path.join(dir, LOCK) +const key = await fingerprint(path.resolve(import.meta.dirname, "..")) +const handle = await acquire(Date.now() + LOCK_TIMEOUT_MS) +const pulse = setInterval(() => { + const now = new Date() + void handle.utimes(now, now).catch((err: unknown) => { + process.stderr.write(`failed to refresh kilo-world daemon build lock: ${String(err)}\n`) + }) +}, 1000) +try { + await clean() + await build() +} finally { + clearInterval(pulse) + await release(handle) +} + +async function build(): Promise { + if (await fresh(dir, key, WorldDaemon.filename, WorldDaemon.manifest)) return + const stage = await fs.mkdtemp(path.join(root, ".world-daemon-")) + try { + const file = await WorldDaemon.copy(await WorldDaemon.bundle(), stage) + await Bun.write(path.join(stage, STAMP), `${key}\n`) + const files: unknown = JSON.parse(await Bun.file(path.join(stage, WorldDaemon.manifest)).text()) + if (!Array.isArray(files) || !files.every((item) => typeof item === "string")) { + throw new Error("kilo-world daemon build wrote an invalid manifest") + } + const names = [...new Set([...files, STAMP])] + await Bun.write(path.join(stage, WorldDaemon.manifest), `${JSON.stringify(names, null, 2)}\n`) + await WorldDaemon.smoke(file, path.join(stage, "smoke")) + + const previous = await listed(manifest) + const chunks = names.filter( + (name) => name !== WorldDaemon.filename && name !== WorldDaemon.manifest && name !== STAMP, + ) + for (const name of [...chunks, WorldDaemon.filename, WorldDaemon.manifest, STAMP]) { + await fs.rename(path.join(stage, name), path.join(dir, name)) + } + await Promise.all( + previous.filter((name) => !names.includes(name)).map((name) => fs.rm(path.join(dir, name), { force: true })), + ) + console.log(`built ${path.join(dir, WorldDaemon.filename)}`) + } finally { + await fs.rm(stage, { recursive: true, force: true }) + } +} + +async function acquire(deadline: number): Promise { + try { + const file = await fs.open(lock, "wx", 0o600) + await file.writeFile(`${process.pid}\n`) + return file + } catch (err) { + if (!(err instanceof Error) || !("code" in err) || err.code !== "EEXIST") throw err + if (!(await alive())) await fs.rm(lock, { force: true }) + if (Date.now() >= deadline) { + throw new Error(`timed out waiting for kilo-world daemon build lock: ${lock}`, { cause: err }) + } + await Bun.sleep(100) + return acquire(deadline) + } +} + +async function alive(): Promise { + const age = await fs + .stat(lock) + .then((value) => Date.now() - value.mtimeMs) + .catch(() => Number.POSITIVE_INFINITY) + if (age >= LOCK_TIMEOUT_MS) return false + const pid = await fs + .readFile(lock, "utf8") + .then((value) => Number(value.trim())) + .catch(() => Number.NaN) + if (!Number.isSafeInteger(pid) || pid <= 0) return age < 5000 + try { + process.kill(pid, 0) + return true + } catch (err) { + return err instanceof Error && "code" in err && err.code === "EPERM" + } +} + +async function release(handle: fs.FileHandle): Promise { + const owner = await handle.stat() + const current = await fs.stat(lock).catch(() => undefined) + await handle.close() + if (!current || owner.dev !== current.dev || owner.ino !== current.ino) return + await fs.rm(lock, { force: true }) +} + +async function listed(file: string): Promise { + const files: unknown = await Bun.file(file) + .json() + .catch(() => []) + if (!Array.isArray(files)) return [] + return files.filter((item): item is string => typeof item === "string").map((item) => path.basename(item)) +} + +async function clean(): Promise { + const files = await fs.readdir(root, { withFileTypes: true }) + await Promise.all( + files + .filter((file) => file.isDirectory() && file.name.startsWith(".world-daemon-")) + .map((file) => fs.rm(path.join(root, file.name), { recursive: true, force: true })), + ) +} diff --git a/packages/kilo-world/script/daemon.ts b/packages/kilo-world/script/daemon.ts new file mode 100644 index 00000000000..65e87ee25ae --- /dev/null +++ b/packages/kilo-world/script/daemon.ts @@ -0,0 +1,105 @@ +import path from "node:path" +import fs from "node:fs/promises" +import os from "node:os" +import { spawn } from "node:child_process" +import { ENTRY, MANIFEST } from "../src/daemon/build" + +type Artifact = Blob & { path?: string; kind?: string } + +export namespace WorldDaemon { + export const filename = ENTRY + export const manifest = MANIFEST + + export type Bundle = { entry: Artifact; files: Artifact[] } + + export async function bundle(): Promise { + const entry = path.resolve(import.meta.dirname, "../src/daemon/entry.ts") + const result = await Bun.build({ + entrypoints: [entry], + target: "node", + format: "cjs", + minify: true, + external: ["chromium-bidi", "electron"], + }) + if (!result.success) { + const details = result.logs.map((item) => String(item)).join("\n") + throw new Error(`Could not bundle kilo-world daemon:\n${details}`) + } + const files = result.outputs as Artifact[] + const output = + files.find((item) => item.kind === "entry-point") ?? + files.find((item) => item.path?.endsWith("/entry.js") || item.path?.endsWith("\\entry.js")) ?? + files[0] + if (!output) throw new Error("kilo-world daemon bundle produced no outputs") + return { entry: output, files } + } + + export async function copy(bundle: Bundle, dir: string): Promise { + await Bun.write(path.join(dir, filename), bundle.entry) + const names = new Set([filename]) + for (const file of bundle.files) { + if (file === bundle.entry || !file.path) continue + const name = path.basename(file.path) + names.add(name) + await Bun.write(path.join(dir, name), file) + } + await Bun.write(path.join(dir, manifest), `${JSON.stringify([...names], null, 2)}\n`) + return path.join(dir, filename) + } + + export async function smoke(file: string, dir?: string): Promise { + const node = Bun.which("node") + if (!node) throw new Error("Node is required to smoke-test the kilo-world daemon") + const root = dir ?? (await fs.mkdtemp(path.join(os.tmpdir(), "kilo-world-build-"))) + await fs.mkdir(root, { recursive: true }) + const session = `smoke-${process.pid}` + const handshake = path.join(root, `daemon-${session}.json`) + const child = spawn(node, [file, `--session=${session}`, "--idle=15000"], { + env: { + ...process.env, + KILO_WORLD_HOME: root, + KILO_WORLD_DAEMON_SILENT: "1", + KILO_WORLD_PARENT_PID: String(process.pid), + }, + stdio: ["ignore", "ignore", "inherit"], + windowsHide: true, + }) + try { + for (const _ of Array.from({ length: 100 })) { + if (await Bun.file(handshake).exists()) break + if (child.exitCode !== null) throw new Error(`kilo-world daemon exited ${child.exitCode} during smoke test`) + await Bun.sleep(50) + } + if (!(await Bun.file(handshake).exists())) throw new Error("kilo-world daemon smoke test timed out") + const data: unknown = JSON.parse(await Bun.file(handshake).text()) + if (!record(data) || typeof data.url !== "string" || typeof data.token !== "string") { + throw new Error("kilo-world daemon wrote an invalid smoke-test handshake") + } + const status = await fetch(`${data.url}/call`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: "smoke", verb: "__status__", args: [], auth: data.token }), + }).then((response) => response.json()) + if (!record(status) || !record(status.envelope) || status.envelope.runtime !== "node") { + throw new Error("kilo-world daemon smoke test did not run under Node") + } + await fetch(`${data.url}/call`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: "stop", verb: "__shutdown__", args: [], auth: data.token }), + }) + for (const _ of Array.from({ length: 100 })) { + if (child.exitCode !== null) break + await Bun.sleep(50) + } + if (child.exitCode === null) throw new Error("kilo-world daemon did not stop after smoke test") + } finally { + if (child.exitCode === null) child.kill() + await fs.rm(root, { recursive: true, force: true }) + } + } +} + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/packages/kilo-world/src/client.ts b/packages/kilo-world/src/client.ts new file mode 100644 index 00000000000..8e9a83038da --- /dev/null +++ b/packages/kilo-world/src/client.ts @@ -0,0 +1,6 @@ +export { defaultConfig, ensureHome, getConfig, setConfig } from "./config" +export { DaemonClient } from "./daemon/client" +export { parseScript } from "./script-parser" +export { resolvePath } from "./path" +export type { Action, RunOptions, RunResult, WorldConfig } from "./types" +export * from "./types" diff --git a/packages/kilo-world/src/commands/browser/click.ts b/packages/kilo-world/src/commands/browser/click.ts new file mode 100644 index 00000000000..592905f8205 --- /dev/null +++ b/packages/kilo-world/src/commands/browser/click.ts @@ -0,0 +1,25 @@ +import { Runner } from "../../core/browser/runner" +import { Launch } from "../../core/browser/launch" +import { Refs } from "../../core/browser/refs" + +export namespace Click { + export type Input = { + session?: string + ref?: string + selector?: string + timeoutMs?: number + } + + export async function run(input: Input): Promise<{ ref?: string; selector?: string }> { + const session = input.session ?? "default" + const live = await Runner.attach(session) + const page = Runner.activePage(live) + await Refs.use(page, session, input.ref, input.selector, (target) => + target.click({ timeout: input.timeoutMs ?? Launch.TIMEOUT_MS_DEFAULT }), + ) + return { + ...(input.ref ? { ref: input.ref } : {}), + ...(input.selector ? { selector: input.selector } : {}), + } + } +} diff --git a/packages/kilo-world/src/commands/browser/close.ts b/packages/kilo-world/src/commands/browser/close.ts new file mode 100644 index 00000000000..3ccf339ec2a --- /dev/null +++ b/packages/kilo-world/src/commands/browser/close.ts @@ -0,0 +1,9 @@ +import { Runner } from "../../core/browser/runner" + +export namespace Close { + export async function run(session?: string): Promise<{ closed: boolean }> { + const name = session ?? "default" + const existed = await Runner.close(name) + return { closed: existed } + } +} diff --git a/packages/kilo-world/src/commands/browser/cookies.ts b/packages/kilo-world/src/commands/browser/cookies.ts new file mode 100644 index 00000000000..64425e21acf --- /dev/null +++ b/packages/kilo-world/src/commands/browser/cookies.ts @@ -0,0 +1,79 @@ +import type { BrowserContext, Cookie } from "playwright" +import { Runner } from "../../core/browser/runner" +import type { CookieEntry } from "../../types" + +type AddCookieInput = Parameters[0][number] + +export namespace Cookies { + function matchesDomain(cookie: Cookie, domain: string): boolean { + return cookie.domain === domain || cookie.domain.endsWith(`.${domain}`) + } + + function fromPlaywright(c: Cookie): CookieEntry { + const out: CookieEntry = { name: c.name, value: c.value, domain: c.domain, path: c.path } + if (typeof c.expires === "number") out.expires = c.expires + if (typeof c.httpOnly === "boolean") out.httpOnly = c.httpOnly + if (typeof c.secure === "boolean") out.secure = c.secure + if (c.sameSite === "Strict" || c.sameSite === "Lax" || c.sameSite === "None") { + out.sameSite = c.sameSite + } + return out + } + + export async function get(input: { + session?: string + domain: string + }): Promise<{ cookies: CookieEntry[]; count: number }> { + const name = input.session ?? "default" + const live = await Runner.attach(name) + const all = await live.context.cookies() + const filtered = all.filter((cookie: Cookie) => matchesDomain(cookie, input.domain)) + const entries = filtered.map(fromPlaywright) + return { cookies: entries, count: entries.length } + } + + export async function set(input: { + session?: string + name: string + value: string + domain: string + path?: string + }): Promise<{ cookie: CookieEntry }> { + const name = input.session ?? "default" + const live = await Runner.attach(name) + const cookie: AddCookieInput = { + name: input.name, + value: input.value, + domain: input.domain, + path: input.path ?? "/", + } + await live.context.addCookies([cookie]) + const path = input.path ?? "/" + const cookies = await live.context.cookies() + const stored = + cookies.find((item) => item.name === input.name && item.domain === input.domain && item.path === path) ?? + cookies.find((item) => item.name === input.name && matchesDomain(item, input.domain)) + if (!stored) throw new Error(`cookie ${input.name} was not stored for ${input.domain}`) + return { cookie: fromPlaywright(stored) } + } + + export async function clear(input: { session?: string; domain?: string }): Promise<{ cleared: number }> { + const name = input.session ?? "default" + const live = await Runner.attach(name) + const before = (await live.context.cookies()).length + if (input.domain) { + const matched = (await live.context.cookies()).filter((cookie) => matchesDomain(cookie, input.domain!)) + for (const cookie of matched) { + await live.context.clearCookies({ + name: cookie.name, + domain: cookie.domain, + path: cookie.path, + }) + } + } else { + await live.context.clearCookies() + } + const after = (await live.context.cookies()).length + return { cleared: before - after } + } +} diff --git a/packages/kilo-world/src/commands/browser/drag.ts b/packages/kilo-world/src/commands/browser/drag.ts new file mode 100644 index 00000000000..ac9b5d857e2 --- /dev/null +++ b/packages/kilo-world/src/commands/browser/drag.ts @@ -0,0 +1,42 @@ +import { Runner } from "../../core/browser/runner" +import { Refs } from "../../core/browser/refs" + +export namespace Drag { + export type Input = { + session?: string + from?: string + to?: string + } + + const REF_RE = /^e\d+$/ + + async function point( + page: import("playwright").Page, + session: string, + raw: string, + ): Promise<{ x: number; y: number }> { + const ref = REF_RE.test(raw) ? raw : undefined + const selector = ref ? undefined : raw + return Refs.use(page, session, ref, selector, async (target) => { + const box = await target.boundingBox() + if (!box) throw new Error(`no bounding box for ${raw}`) + return { x: box.x + box.width / 2, y: box.y + box.height / 2 } + }) + } + + export async function run(input: Input): Promise<{ from: { x: number; y: number }; to: { x: number; y: number } }> { + const session = input.session ?? "default" + const live = await Runner.attach(session) + const page = Runner.activePage(live) + const fromRaw = input.from + const toRaw = input.to + if (!fromRaw || !toRaw) throw new Error("--from and --to are required") + const from = await point(page, session, fromRaw) + const to = await point(page, session, toRaw) + await page.mouse.move(from.x, from.y) + await page.mouse.down() + await page.mouse.move(to.x, to.y, { steps: 10 }) + await page.mouse.up() + return { from, to } + } +} diff --git a/packages/kilo-world/src/commands/browser/evaluate.ts b/packages/kilo-world/src/commands/browser/evaluate.ts new file mode 100644 index 00000000000..76e7c8f66ff --- /dev/null +++ b/packages/kilo-world/src/commands/browser/evaluate.ts @@ -0,0 +1,27 @@ +import { Runner } from "../../core/browser/runner" + +export namespace Evaluate { + export type Input = { + session?: string + js: string + } + + function safeStringify(value: unknown): unknown { + if (value === undefined) return null + if (typeof value === "bigint") return value.toString() + try { + return JSON.parse(JSON.stringify(value)) + } catch { + if (typeof value === "string") return value + return "[unserializable value]" + } + } + + export async function run(input: Input): Promise<{ result: unknown }> { + const session = input.session ?? "default" + const live = await Runner.attach(session) + const page = Runner.activePage(live) + const result = await page.evaluate(input.js) + return { result: safeStringify(result) } + } +} diff --git a/packages/kilo-world/src/commands/browser/fill.ts b/packages/kilo-world/src/commands/browser/fill.ts new file mode 100644 index 00000000000..7e52268a0b2 --- /dev/null +++ b/packages/kilo-world/src/commands/browser/fill.ts @@ -0,0 +1,22 @@ +import { Runner } from "../../core/browser/runner" +import { Refs } from "../../core/browser/refs" + +export namespace Fill { + export type Input = { + session?: string + value: string + ref?: string + selector?: string + force?: boolean + } + + export async function run(input: Input): Promise<{ length: number }> { + const session = input.session ?? "default" + const live = await Runner.attach(session) + const page = Runner.activePage(live) + await Refs.use(page, session, input.ref, input.selector, (target) => + target.fill(input.value, input.force ? { force: true } : undefined), + ) + return { length: input.value.length } + } +} diff --git a/packages/kilo-world/src/commands/browser/hover.ts b/packages/kilo-world/src/commands/browser/hover.ts new file mode 100644 index 00000000000..845afdbd60d --- /dev/null +++ b/packages/kilo-world/src/commands/browser/hover.ts @@ -0,0 +1,21 @@ +import { Runner } from "../../core/browser/runner" +import { Refs } from "../../core/browser/refs" + +export namespace Hover { + export type Input = { + session?: string + ref?: string + selector?: string + } + + export async function run(input: Input): Promise<{ ref?: string; selector?: string }> { + const session = input.session ?? "default" + const live = await Runner.attach(session) + const page = Runner.activePage(live) + await Refs.use(page, session, input.ref, input.selector, (target) => target.hover()) + return { + ...(input.ref ? { ref: input.ref } : {}), + ...(input.selector ? { selector: input.selector } : {}), + } + } +} diff --git a/packages/kilo-world/src/commands/browser/navigate.ts b/packages/kilo-world/src/commands/browser/navigate.ts new file mode 100644 index 00000000000..c9af3e7272a --- /dev/null +++ b/packages/kilo-world/src/commands/browser/navigate.ts @@ -0,0 +1,27 @@ +import { Runner } from "../../core/browser/runner" +import { Launch } from "../../core/browser/launch" +import { Refs } from "../../core/browser/refs" + +export namespace Navigate { + export type Input = { + session?: string + url: string + wait?: string + timeoutMs?: number + } + + export async function run(input: Input): Promise<{ url: string; finalUrl: string; status: number | null }> { + const session = input.session ?? "default" + const live = await Runner.attach(session, input.timeoutMs) + const page = Runner.activePage(live) + const timeout = input.timeoutMs ?? Launch.TIMEOUT_MS_DEFAULT + Refs.reset(session) + const response = await page.goto(input.url, { waitUntil: "load", timeout }) + if (input.wait) { + await page.waitForSelector(input.wait, { timeout }) + } + const url = page.url() + Runner.touch(session, url) + return { url: input.url, finalUrl: url, status: response?.status() ?? null } + } +} diff --git a/packages/kilo-world/src/commands/browser/press-key.ts b/packages/kilo-world/src/commands/browser/press-key.ts new file mode 100644 index 00000000000..caf2812bc89 --- /dev/null +++ b/packages/kilo-world/src/commands/browser/press-key.ts @@ -0,0 +1,24 @@ +import { Runner } from "../../core/browser/runner" + +export namespace PressKey { + export type Input = { + session?: string + chord: string + } + + export async function run(input: Input): Promise<{ chord: string; keys: number }> { + const session = input.session ?? "default" + const live = await Runner.attach(session) + const page = Runner.activePage(live) + const plus = input.chord.endsWith("+") + const base = plus ? input.chord.slice(0, -1) : input.chord + const keys = base + .split("+") + .map((part) => part.trim()) + .filter(Boolean) + if (plus) keys.push("+") + if (keys.length === 0) throw new Error("chord cannot be empty") + await page.keyboard.press(keys.map((key) => (key === "+" ? "Shift+Equal" : key)).join("+")) + return { chord: input.chord, keys: keys.length } + } +} diff --git a/packages/kilo-world/src/commands/browser/screenshot.ts b/packages/kilo-world/src/commands/browser/screenshot.ts new file mode 100644 index 00000000000..bc0262c9098 --- /dev/null +++ b/packages/kilo-world/src/commands/browser/screenshot.ts @@ -0,0 +1,47 @@ +import { dirname } from "node:path" +import { mkdirSync, writeFileSync } from "node:fs" +import { Launch } from "../../core/browser/launch" +import { Runner } from "../../core/browser/runner" + +export namespace Screenshot { + export type Format = "png" | "jpeg" + + export type Input = { + session?: string + out: string + full?: boolean + waitMs?: number + type?: Format + quality?: number + } + + export async function run(input: Input): Promise<{ out: string; bytes: number; mime: string }> { + const session = input.session ?? "default" + const live = await Runner.attach(session) + const page = Runner.activePage(live) + mkdirSync(dirname(input.out), { recursive: true }) + const wait = input.waitMs ?? Launch.SCREENSHOT_PAINT_WAIT_MS_DEFAULT + if (wait > 0) await page.waitForTimeout(wait) + const type: Format = input.type ?? "jpeg" + const quality = clampQuality(input.quality) + const buffer = await page.screenshot({ + fullPage: input.full ?? false, + type, + timeout: Launch.SCREENSHOT_TIMEOUT_MS_DEFAULT, + ...(type === "jpeg" ? { quality } : {}), + }) + writeFileSync(input.out, buffer) + return { + out: input.out, + bytes: buffer.byteLength, + mime: `image/${type}`, + } + } +} + +function clampQuality(value: number | undefined): number { + if (typeof value !== "number" || !Number.isFinite(value)) return 80 + if (value < 50) return 50 + if (value > 100) return 100 + return Math.round(value) +} diff --git a/packages/kilo-world/src/commands/browser/scroll.ts b/packages/kilo-world/src/commands/browser/scroll.ts new file mode 100644 index 00000000000..7390da8c667 --- /dev/null +++ b/packages/kilo-world/src/commands/browser/scroll.ts @@ -0,0 +1,23 @@ +import { Runner } from "../../core/browser/runner" +import { Refs } from "../../core/browser/refs" + +export namespace Scroll { + export type Input = { + session?: string + ref?: string + selector?: string + dx: number + dy: number + } + + export async function run(input: Input): Promise<{ dx: number; dy: number }> { + const session = input.session ?? "default" + const live = await Runner.attach(session) + const page = Runner.activePage(live) + if (input.ref || input.selector) { + await Refs.use(page, session, input.ref, input.selector, (target) => target.scrollIntoViewIfNeeded()) + } + await page.mouse.wheel(input.dx, input.dy) + return { dx: input.dx, dy: input.dy } + } +} diff --git a/packages/kilo-world/src/commands/browser/snapshot.ts b/packages/kilo-world/src/commands/browser/snapshot.ts new file mode 100644 index 00000000000..49a168fcb50 --- /dev/null +++ b/packages/kilo-world/src/commands/browser/snapshot.ts @@ -0,0 +1,11 @@ +import { Runner } from "../../core/browser/runner" +import { Refs } from "../../core/browser/refs" + +export namespace Snapshot { + export async function run(session?: string): Promise<{ snapshot: string; refs: import("../../types").RefEntry[] }> { + const name = session ?? "default" + const live = await Runner.attach(name) + const page = Runner.activePage(live) + return Refs.capture(name, page) + } +} diff --git a/packages/kilo-world/src/commands/browser/status.ts b/packages/kilo-world/src/commands/browser/status.ts new file mode 100644 index 00000000000..665071eb0ab --- /dev/null +++ b/packages/kilo-world/src/commands/browser/status.ts @@ -0,0 +1,26 @@ +import { Runner } from "../../core/browser/runner" +import type { BrowserStatus } from "../../types" +import { getConfig } from "../../config" + +export namespace Status { + export async function run(): Promise { + const cfg = getConfig() + const probe = await Runner.probeChromium() + const version = Runner.version() + const display = + process.platform === "win32" || process.platform === "darwin" + ? process.platform + : (process.env["DISPLAY"] ?? process.env["WAYLAND_DISPLAY"] ?? process.env["MIR_SOCKET"]) + return { + sessions: Runner.listSessions(), + capability: { + headless: cfg.browser.headless, + ...(display ? { display } : {}), + chromiumReady: probe.state === "available", + ...(version ? { chromiumVersion: version } : {}), + installation: probe, + }, + chromiumPid: null, + } + } +} diff --git a/packages/kilo-world/src/commands/browser/tabs.ts b/packages/kilo-world/src/commands/browser/tabs.ts new file mode 100644 index 00000000000..bd9a1e744d7 --- /dev/null +++ b/packages/kilo-world/src/commands/browser/tabs.ts @@ -0,0 +1,69 @@ +import { Runner } from "../../core/browser/runner" +import { Refs } from "../../core/browser/refs" +import type { TabInfo } from "../../types" + +export namespace Tabs { + export async function list(session?: string): Promise { + const name = session ?? "default" + const live = await Runner.attach(name) + const pages = live.context.pages() + const out: TabInfo[] = [] + for (let index = 0; index < pages.length; index++) { + const page = pages[index] + const url = page.url() + let title: string | undefined + try { + title = await page.title() + } catch { + title = undefined + } + out.push({ index, url, ...(title ? { title } : {}), active: page === live.active }) + } + return out + } + + export async function open(input: { session?: string; url?: string }): Promise<{ index: number; url: string }> { + const name = input.session ?? "default" + const url = input.url ?? "about:blank" + const live = await Runner.attach(name) + const page = await live.context.newPage() + live.active = page + Refs.reset(name) + await page.goto(url, { waitUntil: "load" }) + Runner.touch(name, page.url()) + return { index: live.context.pages().indexOf(page), url: page.url() } + } + + export async function select(input: { session?: string; index: number }): Promise<{ index: number; url: string }> { + const name = input.session ?? "default" + const live = await Runner.attach(name) + const pages = live.context.pages() + if (input.index < 0 || input.index >= pages.length) { + throw new Error(`tab index out of range: ${input.index}`) + } + const page = pages[input.index] + live.active = page + Refs.reset(name) + Runner.touch(name, page.url()) + return { index: input.index, url: page.url() } + } + + export async function close(input: { + session?: string + index?: number + }): Promise<{ closed: number; remaining: number }> { + const name = input.session ?? "default" + const live = await Runner.attach(name) + const pages = live.context.pages() + const target = input.index ?? pages.indexOf(Runner.activePage(live)) + if (target < 0 || target >= pages.length) throw new Error(`tab index out of range: ${target}`) + const page = pages[target] + await page.close() + const remaining = live.context.pages() + if (page === live.active) + live.active = remaining[Math.min(target, remaining.length - 1)] ?? (await live.context.newPage()) + Refs.reset(name) + Runner.touch(name, live.active.url()) + return { closed: target, remaining: live.context.pages().length } + } +} diff --git a/packages/kilo-world/src/commands/browser/type.ts b/packages/kilo-world/src/commands/browser/type.ts new file mode 100644 index 00000000000..f40a44197b0 --- /dev/null +++ b/packages/kilo-world/src/commands/browser/type.ts @@ -0,0 +1,27 @@ +import { Runner } from "../../core/browser/runner" +import { Refs } from "../../core/browser/refs" + +export namespace Type { + export type Input = { + session?: string + text: string + ref?: string + selector?: string + delay?: number + } + + export async function run(input: Input): Promise<{ typed: number }> { + const session = input.session ?? "default" + const live = await Runner.attach(session) + const page = Runner.activePage(live) + if (input.ref || input.selector) { + await Refs.use(page, session, input.ref, input.selector, async (target) => { + await target.fill("") + await target.type(input.text, input.delay ? { delay: input.delay } : undefined) + }) + } else { + await page.keyboard.type(input.text, input.delay ? { delay: input.delay } : undefined) + } + return { typed: input.text.length } + } +} diff --git a/packages/kilo-world/src/commands/browser/wait-for.ts b/packages/kilo-world/src/commands/browser/wait-for.ts new file mode 100644 index 00000000000..41a89a081a7 --- /dev/null +++ b/packages/kilo-world/src/commands/browser/wait-for.ts @@ -0,0 +1,32 @@ +import { Runner } from "../../core/browser/runner" +import { Launch } from "../../core/browser/launch" + +export namespace WaitFor { + export type Input = { + session?: string + selector?: string + text?: string + url?: string + timeoutMs?: number + } + + export async function run(input: Input): Promise<{ matched: boolean }> { + const session = input.session ?? "default" + const live = await Runner.attach(session) + const page = Runner.activePage(live) + const timeout = input.timeoutMs ?? Launch.TIMEOUT_MS_DEFAULT + if (input.selector) { + await page.waitForSelector(input.selector, { timeout }) + return { matched: true } + } + if (input.text) { + await page.getByText(input.text).first().waitFor({ timeout }) + return { matched: true } + } + if (input.url) { + await page.waitForURL(input.url, { timeout }) + return { matched: true } + } + throw new Error("must provide one of --selector, --text, or --url") + } +} diff --git a/packages/kilo-world/src/config.ts b/packages/kilo-world/src/config.ts new file mode 100644 index 00000000000..0a6b06fd4ef --- /dev/null +++ b/packages/kilo-world/src/config.ts @@ -0,0 +1,58 @@ +import { existsSync, mkdirSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" +import type { WorldConfig, WorldConfigPatch } from "./types" + +const DEFAULT_HOME = join(process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state"), "kilo-world") + +let cached: WorldConfig | null = null + +export function defaultConfig(): WorldConfig { + const display = process.env["DISPLAY"] + const wayland = process.env["WAYLAND_DISPLAY"] + const desktop = process.platform === "darwin" || process.platform === "win32" + const displayServer = desktop || Boolean(display || wayland) + const headless = !displayServer || process.env["KILO_WORLD_HEADED"] !== "1" + return { + browser: { + headless, + antiDetect: process.env["KILO_WORLD_ANTI_DETECT"] === "1", + timeoutMs: 30_000, + viewport: { width: 1280, height: 720 }, + args: [], + ...(process.env["KILO_WORLD_CHROMIUM"] ? { executablePath: process.env["KILO_WORLD_CHROMIUM"] } : {}), + }, + home: process.env["KILO_WORLD_HOME"] ?? DEFAULT_HOME, + } +} + +export function getConfig(): WorldConfig { + if (!cached) cached = defaultConfig() + return cached +} + +export function setConfig(patch: WorldConfigPatch): WorldConfig { + const next: WorldConfig = { + ...getConfig(), + ...patch, + browser: { ...getConfig().browser, ...patch.browser }, + } + if (!Number.isFinite(next.browser.timeoutMs) || next.browser.timeoutMs <= 0) { + throw new Error("browser timeout must be a positive finite number") + } + if ( + !Number.isFinite(next.browser.viewport.width) || + next.browser.viewport.width <= 0 || + !Number.isFinite(next.browser.viewport.height) || + next.browser.viewport.height <= 0 + ) { + throw new Error("browser viewport dimensions must be positive finite numbers") + } + cached = next + return next +} + +export function ensureHome(home: string): string { + if (!existsSync(home)) mkdirSync(home, { recursive: true, mode: 0o700 }) + return home +} diff --git a/packages/kilo-world/src/core/browser/chrome.ts b/packages/kilo-world/src/core/browser/chrome.ts new file mode 100644 index 00000000000..5a14a9cd60d --- /dev/null +++ b/packages/kilo-world/src/core/browser/chrome.ts @@ -0,0 +1,50 @@ +import { existsSync } from "node:fs" + +const CANDIDATES: Record = { + darwin: [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + `${process.env["HOME"]}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`, + ], + win32: [ + "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", + `${process.env["LOCALAPPDATA"]}\\Google\\Chrome\\Application\\chrome.exe`, + `${process.env["PROGRAMFILES"]}\\Google\\Chrome\\Application\\chrome.exe`, + `${process.env["PROGRAMFILES(X86)"]}\\Google\\Chrome\\Application\\chrome.exe`, + "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe", + "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe", + `${process.env["LOCALAPPDATA"]}\\Microsoft\\Edge\\Application\\msedge.exe`, + `${process.env["LOCALAPPDATA"]}\\BraveSoftware\\Brave-Browser\\Application\\brave.exe`, + ], + linux: [ + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/snap/bin/chromium", + "/usr/bin/google-chrome-beta", + "/usr/bin/google-chrome-canary", + "/usr/bin/microsoft-edge", + "/usr/bin/microsoft-edge-stable", + "/usr/bin/brave-browser", + ], + aix: [], + android: [], + cygwin: [], + freebsd: ["/usr/local/bin/chromium", "/usr/bin/chromium", "/usr/local/bin/google-chrome"], + haiku: [], + netbsd: [], + openbsd: [], + sunos: [], +} + +export function findSystemChrome(): string | undefined { + const os = process.platform + const paths = CANDIDATES[os as NodeJS.Platform] ?? [] + for (const path of paths) { + if (path && existsSync(path)) return path + } + return undefined +} diff --git a/packages/kilo-world/src/core/browser/detect.ts b/packages/kilo-world/src/core/browser/detect.ts new file mode 100644 index 00000000000..c3ec9788a2d --- /dev/null +++ b/packages/kilo-world/src/core/browser/detect.ts @@ -0,0 +1,22 @@ +export const ANTI_DETECT = ` +(() => { + Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); + const origQuery = window.navigator.permissions && window.navigator.permissions.query; + if (origQuery) { + window.navigator.permissions.query = (params) => { + if (params && params.name === 'notifications') return Promise.resolve({ state: Notification.permission, onchange: null }); + return origQuery(params); + }; + } + Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'], configurable: true }); + const plugins = [ + { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' }, + { name: 'Chrome PDF Viewer', filename: 'mhnbgjgfghfpdbnlgfkgmcbpfgjjdhhl', description: '' }, + { name: 'Native Client', filename: 'internal-nacl-plugin', description: '' } + ]; + Object.defineProperty(navigator, 'plugins', { get: () => plugins, configurable: true }); + const nativeChrome = window.chrome; + const hasChrome = !!(nativeChrome && (nativeChrome.runtime || nativeChrome.csi)); + Object.defineProperty(window, 'chrome', { get: () => (hasChrome ? nativeChrome : { runtime: {}, csi: () => {}, loadTimes: () => ({}) }), configurable: true }); +})(); +` diff --git a/packages/kilo-world/src/core/browser/launch.ts b/packages/kilo-world/src/core/browser/launch.ts new file mode 100644 index 00000000000..0a328fe00bd --- /dev/null +++ b/packages/kilo-world/src/core/browser/launch.ts @@ -0,0 +1,48 @@ +import type { WorldConfig } from "../../types" +import { findSystemChrome } from "./chrome" + +export namespace Launch { + export type Options = { + headless: boolean + antiDetect: boolean + timeoutMs: number + viewport: { width: number; height: number } + executablePath?: string + args: string[] + } + + export function fromConfig(cfg: WorldConfig): Options { + const exe = cfg.browser.executablePath ?? (cfg.browser.useSystemChrome ? findSystemChrome() : undefined) + const out: Options = { + headless: cfg.browser.headless, + antiDetect: cfg.browser.antiDetect, + timeoutMs: cfg.browser.timeoutMs, + viewport: cfg.browser.viewport, + args: [...cfg.browser.args], + } + if (exe) out.executablePath = exe + return out + } + + export function launchArgs(opts: Options): string[] { + const args = [...opts.args] + args.push(`--window-size=${opts.viewport.width},${opts.viewport.height}`) + return Array.from(new Set(args)) + } + + export function hide(headless: boolean, platform: NodeJS.Platform = process.platform): boolean { + return platform === "win32" && headless + } + + export function channel(opts: Pick): "chromium" | undefined { + return opts.headless && !opts.executablePath ? "chromium" : undefined + } + + export function contextOptions(opts: Options): { viewport: { width: number; height: number } } { + return { viewport: opts.viewport } + } + + export const TIMEOUT_MS_DEFAULT = 30_000 + export const SCREENSHOT_TIMEOUT_MS_DEFAULT = 10_000 + export const SCREENSHOT_PAINT_WAIT_MS_DEFAULT = 750 +} diff --git a/packages/kilo-world/src/core/browser/refs.ts b/packages/kilo-world/src/core/browser/refs.ts new file mode 100644 index 00000000000..f6767bcc190 --- /dev/null +++ b/packages/kilo-world/src/core/browser/refs.ts @@ -0,0 +1,243 @@ +import type { ElementHandle, Page } from "playwright" +import type { RefEntry, Snapshot } from "../../types" + +type WalkedNode = { + ref: string + role: string | null + name: string +} + +type WalkResult = { + nodes: WalkedNode[] +} + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function walked(value: unknown): value is WalkedNode { + if (!record(value)) return false + return ( + typeof value.ref === "string" && + (typeof value.role === "string" || value.role === null) && + typeof value.name === "string" + ) +} + +function result(value: unknown): value is WalkResult { + return record(value) && Array.isArray(value.nodes) && value.nodes.every(walked) +} + +const WALK_SCRIPT = ` +(() => { + const out = { nodes: [], counter: { value: 1 } }; + const visited = new WeakSet(); + + function clear(root) { + for (const el of Array.from(root.querySelectorAll('[data-kilo-ref]'))) el.removeAttribute('data-kilo-ref'); + for (const el of Array.from(root.querySelectorAll('*'))) { + if (el.shadowRoot) clear(el.shadowRoot); + } + } + + function roleOf(el) { + const r = el.getAttribute('role'); + if (r) return r; + const t = el.tagName.toLowerCase(); + if (t === 'button') return 'button'; + if (t === 'a') return 'link'; + if (t === 'select') return 'combobox'; + if (t === 'textarea') return 'textbox'; + if (t === 'input') { + const type = (el.type || '').toLowerCase(); + if (type === 'checkbox') return 'checkbox'; + if (type === 'radio') return 'radio'; + if (type === 'button' || type === 'submit' || type === 'reset') return 'button'; + return 'textbox'; + } + if (/^h[1-6]$/.test(t)) return 'heading'; + return null; + } + + function nameOf(el) { + // Compute the accessible name the way Playwright does for role=name=. + // Priority: aria-labelledby > aria-label > label[for=id] > placeholder > name > text. + const labelledBy = el.getAttribute('aria-labelledby'); + if (labelledBy) { + const ids = labelledBy.split(/\\s+/); + const parts = ids.map((id) => { const ref = document.getElementById(id); return ref ? (ref.textContent || '').trim() : ''; }).filter(Boolean); + if (parts.length > 0) return parts.join(' ').slice(0, 100); + } + if (el.getAttribute('aria-label')) return el.getAttribute('aria-label'); + if (el.id) { + const lbl = document.querySelector('label[for="' + el.id.replace(/"/g, '\\\\"') + '"]'); + if (lbl) { + const txt = (lbl.textContent || '').replace(/\\*+/g, '').replace(/\\s+/g, ' ').trim(); + if (txt) return txt.slice(0, 100); + } + } + if (el.getAttribute('placeholder')) return el.getAttribute('placeholder'); + if (el.getAttribute('name')) return el.getAttribute('name'); + const inner = (el.innerText || el.textContent || '').trim(); + return inner.slice(0, 100); + } + + function isInteractive(el) { + const tag = el.tagName.toLowerCase(); + if (['input', 'button', 'select', 'textarea', 'a'].includes(tag)) return true; + if (el.getAttribute('role')) return true; + if (el.getAttribute('onclick')) return true; + if (el.getAttribute('tabindex')) return true; + if (el.getAttribute('contenteditable') === 'true') return true; + return false; + } + + function isHidden(el) { + const style = getComputedStyle(el); + return el.hidden || el.getAttribute('aria-hidden') === 'true' || style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse'; + } + + function walk(root) { + const kids = Array.from(root.children); + for (const el of kids) { + if (visited.has(el)) continue; + visited.add(el); + const tag = el.tagName.toLowerCase(); + if (['script', 'style', 'noscript', 'meta', 'link', 'head', 'title'].includes(tag)) continue; + if (isHidden(el)) continue; + if (isInteractive(el) || (el.textContent || '').trim().length > 0) { + const role = roleOf(el); + const name = (nameOf(el) || '').trim(); + if (role || (el.tagName.toLowerCase() === 'a' && name)) { + const ref = 'e' + out.counter.value++; + el.setAttribute('data-kilo-ref', ref); + out.nodes.push({ + ref, + role, + name, + }); + } + } + const sr = el.shadowRoot; + if (sr) { + walk(sr); + } + walk(el); + } + } + + clear(document); + walk(document.body); + return out; +})(); +` + +export namespace Refs { + export function stash(session: string, snapshot: Snapshot): void { + CACHE.set(session, snapshot) + } + + export function lookup(session: string): Snapshot | undefined { + return CACHE.get(session) + } + + export function reset(session: string): void { + CACHE.delete(session) + } + + export function clear(): void { + CACHE.clear() + } + + export function resolve(session: string, ref: string): { selector: string; entry: RefEntry } { + const snap = CACHE.get(session) + if (!snap) throw new Error(`no snapshot cached for session "${session}" — run snapshot first`) + const entry = snap.refs.find((r) => r.ref === ref) + if (!entry) throw new Error(`ref "${ref}" not in current snapshot`) + if (!entry.selector) throw new Error(`ref "${ref}" has no resolvable selector`) + return { selector: entry.selector, entry } + } + + /** + * Resolve a ref or selector to a Playwright ElementHandle. + * Selectors may include the `>>` shadow-piercing operator + * (e.g. `#shadow-host >> #shadow-pin`); refs always use the + * selector captured at snapshot time, which is also a `>>` chain + * when the element lives in a shadow root. + */ + export async function refOrSelector( + page: Page, + session: string, + ref: string | undefined, + selector: string | undefined, + ): Promise { + if (ref) { + const resolved = resolve(session, ref) + return resolveLocator(page, resolved.selector) + } + if (selector) return resolveLocator(page, selector) + throw new Error("must provide either --ref or --selector") + } + + export async function use( + page: Page, + session: string, + ref: string | undefined, + selector: string | undefined, + fn: (target: ElementHandle) => Promise, + ): Promise { + const target = await refOrSelector(page, session, ref, selector) + if (!target) throw new Error(`no element found for ${ref ?? selector}`) + try { + return await fn(target) + } finally { + await target.dispose() + } + } + + export async function capture(session: string, page: Page): Promise { + const value: unknown = await page.evaluate(WALK_SCRIPT) + if (!result(value)) throw new Error("browser snapshot returned an invalid result") + const refs: RefEntry[] = [] + for (const node of value.nodes) { + const ref = node.ref + const entry: RefEntry = { + ref, + role: node.role ?? "", + name: node.name, + depth: 0, + } + const sel = buildSelector(node) + if (sel) entry.selector = sel + refs.push(entry) + } + const snapshot = renderTextTree(refs) + const out: Snapshot = { snapshot, refs } + stash(session, out) + return out + } +} + +function buildSelector(node: WalkedNode): string { + return `[data-kilo-ref="${node.ref}"]` +} + +function renderTextTree(refs: RefEntry[]): string { + return refs.map((r: RefEntry) => `- [ref=${r.ref}] [${r.role}] ${JSON.stringify(r.name)}`).join("\n") +} + +function resolveLocator(page: Page, selector: string): Promise { + // page.$() only supports CSS selectors; role selectors require locator(). + // We always go through locator() to support both. + if (selector.includes(">>")) { + const parts = selector.split(">>").map((s) => s.trim()) + let loc = page.locator(parts[0]).first() + for (let i = 1; i < parts.length; i++) { + loc = loc.locator(parts[i]) + } + return loc.elementHandle() + } + return page.locator(selector).first().elementHandle() +} + +const CACHE = new Map() diff --git a/packages/kilo-world/src/core/browser/runner.ts b/packages/kilo-world/src/core/browser/runner.ts new file mode 100644 index 00000000000..321b53d9486 --- /dev/null +++ b/packages/kilo-world/src/core/browser/runner.ts @@ -0,0 +1,213 @@ +import { existsSync, mkdirSync, rmSync } from "node:fs" +import childProcess, { type ChildProcess, type SpawnOptions, type SpawnSyncOptions } from "node:child_process" +import { join } from "node:path" +import { chromium, type Browser, type BrowserContext, type LaunchOptions, type Page } from "playwright" +import { ensureHome, getConfig, setConfig } from "../../config" +import { Launch } from "./launch" +import { ANTI_DETECT } from "./detect" +import { Refs } from "./refs" +import { findSystemChrome } from "./chrome" +import type { BrowserCapability, SessionInfo, WorldConfigPatch } from "../../types" + +type Live = { + name: string + context: BrowserContext + browser: Browser + active: Page + home: string +} + +let activeBrowser: Browser | null = null +let pending: Promise | null = null +const activeContexts: Map = new Map() +const sessions: Map = new Map() + +function home(): string { + return ensureHome(getConfig().home) +} + +function folder(home: string, name: string): string { + const key = Buffer.from(name).toString("base64url") || "default" + return join(home, "contexts", key) +} + +function track(name: string, url?: string): void { + const now = Date.now() + const existing = sessions.get(name) + sessions.set(name, { + name, + createdAt: existing?.createdAt ?? now, + lastUsedAt: now, + ...(url ? { lastUrl: url } : {}), + }) +} + +function buildLaunchOptions(): LaunchOptions { + const opts = Launch.fromConfig(getConfig()) + const out: LaunchOptions = { + headless: opts.headless, + timeout: opts.timeoutMs, + args: Launch.launchArgs(opts), + } + if (!opts.executablePath && !existsSync(chromium.executablePath())) { + opts.executablePath = findSystemChrome() + } + if (opts.executablePath) { + out.executablePath = opts.executablePath + } else out.channel = Launch.channel(opts) + return out +} + +async function launch(timeout?: number): Promise { + const opts = buildLaunchOptions() + if (timeout !== undefined) opts.timeout = timeout + if (!Launch.hide(opts.headless === true)) return chromium.launch(opts) + + // Playwright's browser process launcher omits windowsHide. Unified headless + // Chromium uses the full browser executable, but creates no visible browser + // window, so hiding its console window is safe. Never apply this to headed + // Chrome because windowsHide would also hide the browser window. + type Spawn = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess + const api = childProcess as unknown as { spawn: Spawn; spawnSync: typeof childProcess.spawnSync } + const spawn = api.spawn + const sync = api.spawnSync + api.spawn = (command, args, options) => spawn(command, args, { ...options, windowsHide: true }) + api.spawnSync = ((command: string, args?: readonly string[] | SpawnSyncOptions, options?: SpawnSyncOptions) => { + if (Array.isArray(args)) return sync(command, args, { ...options, windowsHide: true }) + const opts = args as SpawnSyncOptions | undefined + return sync(command, { ...opts, windowsHide: true }) + }) as typeof sync + try { + return await chromium.launch(opts) + } finally { + api.spawn = spawn + api.spawnSync = sync + } +} + +export namespace Runner { + export async function configure(cfg: WorldConfigPatch): Promise { + const current = Launch.fromConfig(getConfig()) + const next = Launch.fromConfig(setConfig(cfg)) + if (JSON.stringify(current) === JSON.stringify(next)) return + await shutdown() + } + + export function version(): string | undefined { + return activeBrowser?.isConnected() ? activeBrowser.version() : undefined + } + export function listSessions(): SessionInfo[] { + return Array.from(sessions.values()).sort((a, b) => a.name.localeCompare(b.name)) + } + + export async function ensureBrowser(timeout?: number): Promise { + if (activeBrowser && activeBrowser.isConnected()) return activeBrowser + if (pending) return pending + const task = launch(timeout).then((browser) => { + activeBrowser = browser + activeContexts.clear() + browser.on("disconnected", () => { + if (activeBrowser === browser) { + activeBrowser = null + activeContexts.clear() + Refs.clear() + } + }) + return browser + }) + const current = task.finally(() => { + if (pending === current) pending = null + }) + pending = current + return current + } + + export async function attach(name: string, timeout?: number): Promise { + const existing = activeContexts.get(name) + if (existing && existing.browser.isConnected()) { + const page = existing.active.isClosed() ? existing.context.pages()[0] : existing.active + if (page) { + existing.active = page + track(name, page.url()) + return existing + } + const fresh = await existing.context.newPage().catch(() => undefined) + if (fresh) { + existing.active = fresh + track(name, fresh.url()) + return existing + } + activeContexts.delete(name) + Refs.reset(name) + await existing.context.close().catch(() => undefined) + } + const browser = await ensureBrowser(timeout) + const opts = Launch.fromConfig(getConfig()) + const ctx = await browser.newContext(Launch.contextOptions(opts)) + const page = await (async () => { + if (opts.antiDetect) await ctx.addInitScript(ANTI_DETECT) + return ctx.newPage() + })().catch(async (err) => { + await ctx.close().catch(() => undefined) + throw err + }) + const live: Live = { + name, + context: ctx, + browser, + active: page, + home: home(), + } + activeContexts.set(name, live) + mkdirSync(folder(live.home, name), { recursive: true }) + track(name) + return live + } + + export async function close(name: string): Promise { + const live = activeContexts.get(name) + if (!live) return false + activeContexts.delete(name) + sessions.delete(name) + Refs.reset(name) + await live.context.close() + rmSync(folder(live.home, name), { recursive: true, force: true }) + return true + } + + export async function shutdown(): Promise { + const launch = pending + if (launch) await launch.catch(() => undefined) + await Promise.allSettled(Array.from(activeContexts.values(), (live) => live.context.close())) + activeContexts.clear() + sessions.clear() + Refs.clear() + if (activeBrowser) { + await activeBrowser.close() + activeBrowser = null + } + } + + export function probeChromium(): Promise { + const bundled = chromium.executablePath() + const executable = + getConfig().browser.executablePath ?? (existsSync(bundled) ? bundled : findSystemChrome()) ?? bundled + if (existsSync(executable)) return Promise.resolve({ state: "available", message: executable }) + return Promise.resolve({ + state: "missing", + message: `Chromium executable not found at ${executable}. Install with \`npx playwright install chromium\`.`, + }) + } + + export function activePage(live: Live): Page { + if (!live.active.isClosed()) return live.active + const page = live.context.pages()[0] + if (!page) throw new Error(`session ${live.name} has no active page`) + live.active = page + return page + } + + export function touch(name: string, url?: string): void { + track(name, url) + } +} diff --git a/packages/kilo-world/src/daemon/build.ts b/packages/kilo-world/src/daemon/build.ts new file mode 100644 index 00000000000..dd0c7210628 --- /dev/null +++ b/packages/kilo-world/src/daemon/build.ts @@ -0,0 +1,108 @@ +import { createHash } from "node:crypto" +import { access, readFile, readdir } from "node:fs/promises" +import { basename, dirname, join, relative, resolve } from "node:path" + +export const STAMP = "world-daemon.source" +export const LOCK = ".world-daemon.lock" +export const ENTRY = "world-daemon.cjs" +export const MANIFEST = "world-daemon.assets.json" +export const LOCK_TIMEOUT_MS = 90_000 +export const BUILD_TIMEOUT_MS = LOCK_TIMEOUT_MS + 60_000 + +export async function fingerprint(root: string): Promise { + const base = resolve(root) + const files = [join(base, "package.json"), join(base, "script", "build-daemon.ts"), join(base, "script", "daemon.ts")] + const walk = async (dir: string): Promise => { + const items = await readdir(dir, { withFileTypes: true }) + return ( + await Promise.all( + items.map(async (item) => (item.isDirectory() ? walk(join(dir, item.name)) : [join(dir, item.name)])), + ) + ).flat() + } + files.push(...(await walk(join(base, "src")))) + const hash = createHash("sha256") + for (const file of files.sort()) { + hash.update(relative(base, file)) + hash.update("\0") + hash.update(await readFile(file)) + hash.update("\0") + } + hash.update("playwright\0") + hash.update(await dependency(base)) + return hash.digest("hex") +} + +async function dependency(root: string): Promise { + const pkg: unknown = JSON.parse(await readFile(join(root, "package.json"), "utf8")) + const spec = record(pkg) && record(pkg.dependencies) ? pkg.dependencies.playwright : undefined + if (typeof spec !== "string") throw new Error("@kilocode/world must declare playwright as a dependency") + for (const dir of parents(root)) { + const text = await readFile(join(dir, "bun.lock"), "utf8").catch(() => undefined) + if (!text) continue + const start = text.indexOf(' "packages/kilo-world": {') + const end = text.indexOf('\n "packages/', start + 1) + const workspace = start >= 0 ? text.slice(start, end >= 0 ? end : undefined) : "" + if (!workspace.includes(`"playwright": ${JSON.stringify(spec)}`)) { + throw new Error("bun.lock does not resolve @kilocode/world's playwright dependency") + } + const resolved = ["playwright", "playwright-core"].map((name) => { + const prefix = ` ${JSON.stringify(name)}: [` + const entry = text.split("\n").find((line) => line.startsWith(prefix)) + if (!entry) throw new Error(`bun.lock does not contain a resolved ${name} package`) + return entry.trim() + }) + const manifest: unknown = JSON.parse(await readFile(join(dir, "package.json"), "utf8")) + const patched = + record(manifest) && record(manifest.patchedDependencies) + ? Object.entries(manifest.patchedDependencies).filter(([name]) => /^(playwright|playwright-core)@/.test(name)) + : [] + const patches = await Promise.all( + patched.map(async ([name, file]) => { + if (typeof file !== "string") throw new Error("bun.lock contains an invalid patch path") + return [name, file, await readFile(join(dir, file), "utf8")] + }), + ) + return JSON.stringify({ spec, resolved, patches }) + } + throw new Error("could not resolve playwright from bun.lock") +} + +function parents(root: string): string[] { + const dirs = [root] + while (true) { + const current = dirs.at(-1)! + const parent = dirname(current) + if (parent === current) break + dirs.push(parent) + } + return dirs +} + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +export async function fresh(dir: string, key: string, entry: string, manifest: string): Promise { + const saved = await readFile(join(dir, STAMP), "utf8") + .then((value) => value.trim()) + .catch(() => "") + if (saved !== key) return false + const files: unknown = await readFile(join(dir, manifest), "utf8") + .then((value): unknown => JSON.parse(value)) + .catch(() => []) + if (!Array.isArray(files) || !files.every((item) => typeof item === "string" && basename(item) === item)) { + return false + } + if (!files.includes(entry) || !files.includes(STAMP)) return false + return ( + await Promise.all( + files.map((file) => + access(join(dir, file)).then( + () => true, + () => false, + ), + ), + ) + ).every(Boolean) +} diff --git a/packages/kilo-world/src/daemon/client.ts b/packages/kilo-world/src/daemon/client.ts new file mode 100644 index 00000000000..ba281e7eca0 --- /dev/null +++ b/packages/kilo-world/src/daemon/client.ts @@ -0,0 +1,540 @@ +import { closeSync, constants, existsSync, openSync, readFileSync } from "node:fs" +import { spawn, type ChildProcess } from "node:child_process" +import { fileURLToPath } from "node:url" +import { dirname, join } from "node:path" +import { getConfig } from "../config" +import type { Action, DaemonConfig, RunOptions, RunResult, WorldConfig } from "../types" +import { parseScript as parseScriptFn } from "../script-parser" +import { DaemonServer } from "./server" +import { isHandshake, isResponse, type DaemonRequest, type DaemonResponse } from "./protocol" +import { BUILD_TIMEOUT_MS, ENTRY, fingerprint, fresh, MANIFEST } from "./build" + +const SCRIPT_HEAD_TIMEOUT_MS = 30_000 +const BUILD_OUTPUT_MAX = 16_384 +const BUILD_FAILURE_COOLDOWN_MS = 30_000 +const starts = new Map>() +let building: { key: string; task: Promise } | null = null +let failed: { key: string; err: Error; at: number } | null = null + +function root(): string { + const here = dirname(fileURLToPath(import.meta.url)) + return join(here, "..", "..") +} + +function paths(): string[] { + const override = process.env["KILO_WORLD_DAEMON_PATH"] + const argv = process.argv[1] + return [ + override, + join(dirname(process.execPath), ENTRY), + argv ? join(dirname(argv), ENTRY) : undefined, + join(root(), "dist", ENTRY), + ].filter((item): item is string => Boolean(item)) +} + +async function entry(opts: DaemonClient.CallOptions): Promise { + const override = process.env["KILO_WORLD_DAEMON_PATH"] + if (override && !existsSync(override)) throw new Error(`KILO_WORLD_DAEMON_PATH does not exist: ${override}`) + const local = join(root(), "dist", ENTRY) + const current = paths().find(existsSync) + if (current && current !== local) return current + const script = join(root(), "script", "build-daemon.ts") + if (existsSync(script) && typeof Bun !== "undefined") { + const key = await fingerprint(root()) + if (await fresh(dirname(local), key, ENTRY, MANIFEST)) return local + if (failed?.key === key && Date.now() - failed.at < BUILD_FAILURE_COOLDOWN_MS) throw failed.err + if (failed?.key === key) failed = null + if (!building || building.key !== key) { + const task = build(script) + .then(() => { + if (failed?.key === key) failed = null + }) + .catch((err: unknown) => { + const error = err instanceof Error ? err : new Error(String(err)) + failed = { key, err: error, at: Date.now() } + throw error + }) + .finally(() => { + if (building?.task === task) building = null + }) + building = { key, task } + } + await wait(building.task, opts.signal) + } + const file = paths().find(existsSync) + if (file) return file + throw new Error( + `kilo-world Node daemon not found; tried:\n${paths() + .map((item) => ` - ${item}`) + .join("\n")}`, + ) +} + +async function build(script: string): Promise { + const bin = Bun.which("bun") ?? process.execPath + const child = spawn(bin, [script], { + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }) + const state = { output: "", timeout: false } + const append = (data: Buffer | string) => { + state.output = `${state.output}${data.toString()}`.slice(-BUILD_OUTPUT_MAX) + } + child.stdout?.on("data", append) + child.stderr?.on("data", append) + const timer = setTimeout(() => { + state.timeout = true + kill(child) + }, BUILD_TIMEOUT_MS) + const code = await new Promise((resolve, reject) => { + child.once("error", reject) + child.once("close", resolve) + }).finally(() => clearTimeout(timer)) + if (code === 0 && !state.timeout) return + const reason = state.timeout ? `timed out after ${BUILD_TIMEOUT_MS}ms` : `exited ${code ?? "unknown"}` + const details = state.output.trim() + throw new Error(`failed to build kilo-world Node daemon: ${reason}${details ? `\n${details}` : ""}`) +} + +function wait(task: Promise, signal?: AbortSignal): Promise { + if (!signal) return task + signal.throwIfAborted() + return new Promise((resolve, reject) => { + const abort = () => reject(new Error("daemon build aborted")) + signal.addEventListener("abort", abort, { once: true }) + task.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort)) + }) +} + +function runtime(): string { + const override = process.env["KILO_WORLD_NODE"] + if (override) return override + const name = process.platform === "win32" ? "node.exe" : "node" + const dir = dirname(process.execPath) + const bundled = [join(dir, "node-runtime", name), join(dir, name)].find(existsSync) + if (bundled) return bundled + if (process.versions.electron) return process.execPath + if (typeof Bun !== "undefined") return Bun.which("node") ?? name + if (process.release.name === "node") return process.execPath + return name +} + +export namespace DaemonClient { + export type CallOptions = { + timeoutMs?: number + silent?: boolean + signal?: AbortSignal + } + + export function isRunning(sessionID: string): boolean { + return DaemonServer.isRunning(sessionID) + } + + export function handshake(sessionID: string): { + pid: number + startedAt: number + url: string + token: string + } | null { + const path = DaemonServer.handshakePath(sessionID) + if (!existsSync(path)) return null + try { + const data: unknown = JSON.parse(readFileSync(path, "utf8")) + if (!isHandshake(data)) return null + return { pid: data.pid, startedAt: data.startedAt ?? 0, url: data.url, token: data.token } + } catch { + return null + } + } + + export async function ensureRunning(sessionID: string, opts: CallOptions & { idleMs?: number } = {}): Promise { + if (await ping(sessionID)) return + const current = starts.get(sessionID) + if (current) return current + const pending = launch(sessionID, opts).finally(() => { + if (starts.get(sessionID) === pending) starts.delete(sessionID) + }) + starts.set(sessionID, pending) + return pending + } + + async function launch(sessionID: string, opts: CallOptions & { idleMs?: number }): Promise { + const file = await entry(opts) + const bin = runtime() + if (process.env["KILO_WORLD_NODE"] && /[\\/]/.test(bin) && !existsSync(bin)) { + throw new Error(`KILO_WORLD_NODE does not exist: ${bin}`) + } + const logFd = openSync( + DaemonServer.logPath(sessionID), + constants.O_APPEND | constants.O_CREAT | constants.O_WRONLY, + 0o600, + ) + const cfg = getConfig() + const env: NodeJS.ProcessEnv = { + ...process.env, + KILO_WORLD_DAEMON_SESSION: sessionID, + KILO_WORLD_PARENT_PID: String(process.pid), + KILO_WORLD_HOME: cfg.home, + ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: "1" } : {}), + ...(cfg.browser.executablePath ? { KILO_WORLD_CHROMIUM: cfg.browser.executablePath } : {}), + ...(opts.idleMs !== undefined ? { KILO_WORLD_DAEMON_IDLE_MS: String(opts.idleMs) } : {}), + } + const args = [file, `--session=${sessionID}`, ...(opts.idleMs !== undefined ? [`--idle=${opts.idleMs}`] : [])] + const state: { err?: Error } = {} + const child = (() => { + try { + return spawn(bin, args, { + detached: true, + stdio: ["ignore", logFd, logFd], + env, + windowsHide: true, + }) + } finally { + closeSync(logFd) + } + })() + child.once("error", (err) => { + state.err = err + }) + child.unref() + const start = Date.now() + while (Date.now() - start < SCRIPT_HEAD_TIMEOUT_MS) { + if (opts.signal?.aborted) { + kill(child) + throw new Error("daemon startup aborted") + } + if (state.err) { + kill(child) + throw new Error( + `failed to start kilo-world daemon with ${bin}: ${state.err.message}. Install Node or set KILO_WORLD_NODE to its executable.`, + ) + } + if (await ping(sessionID)) return + await new Promise((r) => setTimeout(r, 50)) + } + kill(child) + // Surface the daemon log path so the user can read why startup failed. + // The log is appended (openSync with "a") so any stderr/stdout from the + // dying daemon process is captured there even when windowsHide is on. + throw new Error( + `kilo-world daemon for session ${sessionID} failed to start within ${SCRIPT_HEAD_TIMEOUT_MS}ms. ` + + `Inspect the daemon log for the failure cause: ${DaemonServer.logPath(sessionID)}`, + ) + } + + async function ping(sessionID: string): Promise { + if (!isRunning(sessionID)) return false + const hs = handshake(sessionID) + if (!hs) return false + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 500) + return fetch(`${hs.url}/call`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: randomId(), verb: "__ping__", args: [], auth: hs.token }), + signal: controller.signal, + }) + .then((res) => res.ok) + .catch(() => false) + .finally(() => clearTimeout(timer)) + } + + export async function call(sessionID: string, req: DaemonRequest, opts: CallOptions = {}): Promise { + await ensureRunning(sessionID, opts) + return send(sessionID, req, opts) + } + + export async function callScript( + sessionID: string, + script: string, + opts: CallOptions = {}, + ): Promise { + const segments = parseScript(script) + const out: DaemonResponse[] = [] + for (const seg of segments) { + out.push(await call(sessionID, { id: randomId(), verb: seg.verb, args: seg.args }, opts)) + if (!out.at(-1)?.ok) break + } + return out + } + + export async function stop(sessionID: string, opts: CallOptions = {}): Promise { + if (!isRunning(sessionID)) return false + try { + const resp = await call( + sessionID, + { id: randomId(), verb: "__shutdown__", args: [] }, + { ...opts, timeoutMs: opts.timeoutMs ?? 5000, silent: true }, + ) + return resp.ok + } catch { + return false + } + } + + export function parseScript(text: string): Action[] { + return parseScriptFn(text) + } + + function randomId(): string { + return Math.random().toString(36).slice(2, 10) + } + + function send(sessionID: string, req: DaemonRequest, opts: CallOptions): Promise { + const hs = DaemonServer.handshake(sessionID) + if (!hs) throw new Error(`daemon handshake missing for session ${sessionID}`) + // Tag every request with the daemon's auth token. The server returns 401 + // on mismatch so an unauthorized peer can't drive the browser. + const authed: DaemonRequest = { ...req, paths: req.paths ?? [], auth: hs.token } + const controller = new AbortController() + const timeout = opts.timeoutMs ?? 60_000 + const state = { timeout: false } + const abort = () => controller.abort() + if (opts.signal?.aborted) controller.abort() + opts.signal?.addEventListener("abort", abort, { once: true }) + const timer = setTimeout(() => { + state.timeout = true + controller.abort() + }, timeout) + return fetch(`${hs.url}/call`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(authed), + signal: controller.signal, + }) + .then(async (res) => { + if (!res.ok) { + if (res.status === 401) throw new Error(`daemon rejected request: unauthorized`) + const text = await res.text().catch(() => "") + throw new Error(`daemon HTTP ${res.status}: ${text.slice(0, 200)}`) + } + const data: unknown = await res.json() + if (!isResponse(data)) throw new Error("daemon returned an invalid response") + return data + }) + .finally(() => { + clearTimeout(timer) + opts.signal?.removeEventListener("abort", abort) + }) + .catch((err: unknown) => { + if (err instanceof Error && err.name === "AbortError") { + if (state.timeout) throw new Error(`daemon call timed out after ${timeout}ms`) + throw new Error("daemon call aborted") + } + throw err + }) + } + + export type StartResult = { + started: boolean + idleMs: number + idleMsRemaining: number + pid?: number + } + + /** + * Manually start (or re-configure) the per-session browser daemon. + * `idleMs` of 0 means "never time out" — the daemon stays alive until + * explicitly stopped via `stop()` or a `daemon.stop` verb. + */ + export async function startDaemon(sessionID: string, opts: { idleMs?: number } = {}): Promise { + const idleMs = normalizeIdleMs(opts.idleMs) + const wasRunning = isRunning(sessionID) + if (wasRunning) { + const resp = await call( + sessionID, + { id: randomId(), verb: "__set_idle__", args: [String(idleMs)] }, + { silent: true, timeoutMs: 5000 }, + ) + const data = (resp.envelope ?? {}) as { + idleTimeoutMs?: number + idleTimeoutRemainingMs?: number + } + const hs = handshake(sessionID) + return { + started: false, + idleMs: data.idleTimeoutMs ?? idleMs, + idleMsRemaining: data.idleTimeoutRemainingMs ?? idleMs, + ...(hs ? { pid: hs.pid } : {}), + } + } + await ensureRunning(sessionID, { idleMs, silent: true }) + const status = await statusOf(sessionID) + return { + started: true, + idleMs: status.idleMs, + idleMsRemaining: status.idleMsRemaining, + ...(status.pid !== undefined ? { pid: status.pid } : {}), + } + } + + export type Status = { + running: boolean + pid?: number + sessionID?: string + runtime?: "node" | "bun" + runtimeVersion?: string + idleMs: number + idleMsRemaining: number + } + + export async function statusOf(sessionID: string): Promise { + if (!isRunning(sessionID)) { + return { running: false, idleMs: 0, idleMsRemaining: 0 } + } + try { + const resp = await call( + sessionID, + { id: randomId(), verb: "__status__", args: [] }, + { silent: true, timeoutMs: 5000 }, + ) + const data = (resp.envelope ?? {}) as { + pid?: number + sessionID?: string + runtime?: "node" | "bun" + runtimeVersion?: string + idleTimeoutMs?: number + idleTimeoutRemainingMs?: number + } + return { + running: true, + ...(data.pid !== undefined ? { pid: data.pid } : {}), + ...(data.sessionID ? { sessionID: data.sessionID } : {}), + ...(data.runtime ? { runtime: data.runtime } : {}), + ...(data.runtimeVersion ? { runtimeVersion: data.runtimeVersion } : {}), + idleMs: data.idleTimeoutMs ?? 0, + idleMsRemaining: data.idleTimeoutRemainingMs ?? 0, + } + } catch { + return { + running: false, + idleMs: 0, + idleMsRemaining: 0, + } + } + } + + function normalizeIdleMs(value: number | undefined): number { + if (value === undefined) return 5 * 60_000 + const n = Math.floor(value) + if (!Number.isFinite(n)) throw new Error("idle timeout must be a finite number") + if (n < 0) throw new Error("idle timeout cannot be negative") + if (n === 0) return 0 + return n + } + + export async function setIdle(sessionID: string, idleMs: number): Promise { + return startDaemon(sessionID, { idleMs: normalizeIdleMs(idleMs) }) + } + + export async function runViaSession(sessionID: string, script: string, opts: RunOptions = {}): Promise { + const segments = parseScript(script) + const startedAt = Date.now() + const results: RunResult["results"] = [] + + for (const seg of segments) { + if (opts.signal?.aborted) throw new Error("world script aborted") + if (seg.verb === "daemon.start") { + const idle = flagString(seg, "--idle") + const data = await startDaemon(sessionID, { idleMs: idle === undefined ? 5 * 60_000 : Number(idle) }) + results.push({ ok: true, verb: seg.verb, args: seg.args, data, durationMs: 0 }) + continue + } + const response = await call( + sessionID, + { + id: randomId(), + verb: seg.verb, + args: seg.args, + ...(opts.directory ? { directory: opts.directory } : {}), + ...(opts.paths ? { paths: opts.paths } : {}), + ...(opts.config ? { config: daemonConfig(opts.config) } : {}), + }, + { silent: true, timeoutMs: opts.timeoutMs, signal: opts.signal }, + ) + const result = responseResult(response, seg) + results.push(result) + if (!result.ok) break + } + + return { ok: results.every((r) => r.ok), durationMs: Date.now() - startedAt, results } + } +} + +function kill(child: ChildProcess): void { + if (child.exitCode !== null || child.pid === undefined) return + if (process.platform === "win32") { + child.kill() + return + } + try { + process.kill(-child.pid, "SIGTERM") + } catch (err) { + if (!child.kill() && err instanceof Error) process.stderr.write(`failed to stop world daemon: ${err.message}\n`) + } +} + +function daemonConfig(cfg: WorldConfig): DaemonConfig { + return { + browser: { + headless: cfg.browser.headless, + antiDetect: cfg.browser.antiDetect, + timeoutMs: cfg.browser.timeoutMs, + viewport: cfg.browser.viewport, + useSystemChrome: cfg.browser.useSystemChrome ?? false, + }, + } +} + +function responseResult(response: DaemonResponse, action: Action): RunResult["results"][number] { + const env = response.envelope + const ok = response.ok && env.ok !== false + const data = record(env.data) ? env.data : undefined + const errors = Array.isArray(env.errors) ? env.errors.filter((item): item is string => typeof item === "string") : [] + const shot = screenshot(env.screenshot) + const refs = Array.isArray(data?.refs) ? data.refs.filter(isRef) : [] + return { + ok, + verb: typeof env.verb === "string" ? env.verb : action.verb, + args: action.args, + data: env.data, + ...(!ok ? { error: errors[0] ?? (typeof env.error === "string" ? env.error : response.message) ?? "unknown" } : {}), + durationMs: typeof env.durationMs === "number" ? env.durationMs : 0, + ...(shot + ? { + screenshot: { + path: shot.path, + bytes: shot.bytes, + mime: shot.mime ?? "image/png", + }, + } + : {}), + ...(env.verb === "snapshot" && refs.length > 0 ? { refs } : {}), + } +} + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function screenshot(value: unknown): { path: string; bytes: number; mime?: string } | undefined { + if (!record(value) || typeof value.path !== "string" || typeof value.bytes !== "number") return undefined + if (value.mime !== undefined && typeof value.mime !== "string") return undefined + return { path: value.path, bytes: value.bytes, ...(value.mime ? { mime: value.mime } : {}) } +} + +function isRef(value: unknown): value is { ref: string; role: string; name: string; selector?: string } { + if (!record(value)) return false + if (typeof value.ref !== "string" || typeof value.role !== "string" || typeof value.name !== "string") return false + return value.selector === undefined || typeof value.selector === "string" +} + +function flagString(action: { verb: string; args: string[] }, name: string): string | undefined { + for (let i = 0; i < action.args.length; i++) { + const t = action.args[i] + if (t === name) return action.args[i + 1] + if (t?.startsWith(`${name}=`)) return t.slice(name.length + 1) + } + return undefined +} diff --git a/packages/kilo-world/src/daemon/dispatch.ts b/packages/kilo-world/src/daemon/dispatch.ts new file mode 100644 index 00000000000..da910f2810c --- /dev/null +++ b/packages/kilo-world/src/daemon/dispatch.ts @@ -0,0 +1,344 @@ +import type { Action, ActionResult, RunOptions } from "../types" +import { existsSync, readFileSync } from "node:fs" +import { DaemonServer } from "./server" +import { Status } from "../commands/browser/status" +import { Navigate } from "../commands/browser/navigate" +import { Snapshot } from "../commands/browser/snapshot" +import { Click } from "../commands/browser/click" +import { Type } from "../commands/browser/type" +import { Fill } from "../commands/browser/fill" +import { PressKey } from "../commands/browser/press-key" +import { Hover } from "../commands/browser/hover" +import { Drag } from "../commands/browser/drag" +import { Scroll } from "../commands/browser/scroll" +import { Screenshot } from "../commands/browser/screenshot" +import { Evaluate } from "../commands/browser/evaluate" +import { WaitFor } from "../commands/browser/wait-for" +import { Tabs } from "../commands/browser/tabs" +import { Cookies } from "../commands/browser/cookies" +import { Close } from "../commands/browser/close" +import { Runner } from "../core/browser/runner" +import { resolvePath } from "../path" + +// Keep the command graph explicit so Bun includes Playwright and every command +// in the Kilo executable that is reused for daemon mode. +export async function dispatch(action: Action, opts: RunOptions = {}): Promise { + opts.signal?.throwIfAborted() + const task = execute(action).then((result) => { + opts.signal?.throwIfAborted() + return result + }) + if (!opts.signal) return task + const signal = opts.signal + const state: { reject?: (err: Error) => void } = {} + const stop = () => { + state.reject?.(new Error("world action aborted")) + } + const abort = new Promise((_, reject) => { + state.reject = reject + signal.addEventListener("abort", stop, { once: true }) + if (signal.aborted) stop() + }) + return Promise.race([task, abort]).finally(() => signal.removeEventListener("abort", stop)) +} + +async function execute(action: Action): Promise { + const startedAt = Date.now() + try { + if (flagString(action, "--session") !== undefined) throw new Error("--session is reserved for Kilo") + if (action.config) await Runner.configure(action.config) + const verb = action.verb + if (verb === "status") { + return ok(action, startedAt, await Status.run()) + } + if (verb === "daemon.status") { + return { + ok: true, + verb, + args: action.args, + data: DaemonServer.status(), + durationMs: Date.now() - startedAt, + } + } + if (verb === "daemon.stop") { + return ok(action, startedAt, { stopping: true }) + } + if (verb === "daemon.list") { + const { ensureHome, getConfig } = await import("../config") + const home = ensureHome(getConfig().home) + const { readdirSync } = await import("node:fs") + const files = readdirSync(home).filter((f) => f.startsWith("daemon-") && f.endsWith(".pid")) + const sessions: Array<{ sessionID: string; pid: number; startedAt: number }> = [] + for (const f of files) { + const id = f.slice("daemon-".length, -".pid".length) + const hs = DaemonServer.handshake(id) + if (hs) sessions.push({ sessionID: hs.sessionID ?? id, pid: hs.pid, startedAt: hs.startedAt }) + } + return ok(action, startedAt, { sessions }) + } + if (verb === "navigate") { + const url = required(action, "--url") + const timeout = numberFlag(action, "--timeout") + return ok( + action, + startedAt, + await Navigate.run({ + url, + ...(flagString(action, "--wait") ? { wait: flagString(action, "--wait") } : {}), + ...(timeout !== undefined ? { timeoutMs: timeout } : {}), + }), + ) + } + if (verb === "snapshot") { + const data = await Snapshot.run() + return { ...ok(action, startedAt, data), refs: data.refs } + } + if (verb === "click") { + const timeout = numberFlag(action, "--timeout") + return ok( + action, + startedAt, + await Click.run({ + ...(flagString(action, "--ref") ? { ref: flagString(action, "--ref") } : {}), + ...(flagString(action, "--selector") ? { selector: flagString(action, "--selector") } : {}), + ...(timeout !== undefined ? { timeoutMs: timeout } : {}), + }), + ) + } + if (verb === "type") { + const text = required(action, "--text") + const delay = numberFlag(action, "--delay") + return ok( + action, + startedAt, + await Type.run({ + text, + ...(flagString(action, "--ref") ? { ref: flagString(action, "--ref") } : {}), + ...(flagString(action, "--selector") ? { selector: flagString(action, "--selector") } : {}), + ...(delay !== undefined ? { delay } : {}), + }), + ) + } + if (verb === "fill") { + const value = required(action, "--value") + return ok( + action, + startedAt, + await Fill.run({ + value, + ...(flagString(action, "--ref") ? { ref: flagString(action, "--ref") } : {}), + ...(flagString(action, "--selector") ? { selector: flagString(action, "--selector") } : {}), + ...(hasFlag(action, "--force") ? { force: true } : {}), + }), + ) + } + if (verb === "press-key") { + return ok( + action, + startedAt, + await PressKey.run({ + chord: required(action, "--chord"), + }), + ) + } + if (verb === "hover") { + return ok( + action, + startedAt, + await Hover.run({ + ...(flagString(action, "--ref") ? { ref: flagString(action, "--ref") } : {}), + ...(flagString(action, "--selector") ? { selector: flagString(action, "--selector") } : {}), + }), + ) + } + if (verb === "drag") { + return ok( + action, + startedAt, + await Drag.run({ + from: required(action, "--from"), + to: required(action, "--to"), + }), + ) + } + if (verb === "scroll") { + return ok( + action, + startedAt, + await Scroll.run({ + dx: numberFlag(action, "--dx") ?? 0, + dy: numberFlag(action, "--dy") ?? 0, + ...(flagString(action, "--ref") ? { ref: flagString(action, "--ref") } : {}), + ...(flagString(action, "--selector") ? { selector: flagString(action, "--selector") } : {}), + }), + ) + } + if (verb === "screenshot") { + const typeRaw = flagString(action, "--type") + const wait = numberFlag(action, "--wait") + const quality = numberFlag(action, "--quality") + const data = await Screenshot.run({ + out: authorized(action, required(action, "--out")), + ...(hasFlag(action, "--full") ? { full: true } : {}), + ...(wait !== undefined ? { waitMs: wait } : {}), + ...(typeRaw === "png" || typeRaw === "jpeg" ? { type: typeRaw } : {}), + ...(quality !== undefined ? { quality } : {}), + }) + return { + ok: true, + verb: action.verb, + args: action.args, + data, + durationMs: Date.now() - startedAt, + screenshot: { path: data.out, bytes: data.bytes, mime: data.mime }, + } + } + if (verb === "evaluate") { + const js = await resolveEvaluateJs(action) + return ok( + action, + startedAt, + await Evaluate.run({ + js, + }), + ) + } + if (verb === "wait-for") { + const timeout = numberFlag(action, "--timeout") + return ok( + action, + startedAt, + await WaitFor.run({ + ...(flagString(action, "--selector") ? { selector: flagString(action, "--selector") } : {}), + ...(flagString(action, "--text") ? { text: flagString(action, "--text") } : {}), + ...(flagString(action, "--url") ? { url: flagString(action, "--url") } : {}), + ...(timeout !== undefined ? { timeoutMs: timeout } : {}), + }), + ) + } + if (verb === "tabs") return ok(action, startedAt, await dispatchTabs(action)) + if (verb === "cookies") return ok(action, startedAt, await dispatchCookies(action)) + if (verb === "close") { + return ok(action, startedAt, await Close.run()) + } + if (verb === "shutdown") { + await Runner.shutdown() + return ok(action, startedAt, { shutdown: "browser" }) + } + throw new Error(`unknown verb: ${verb}`) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { + ok: false, + verb: action.verb, + args: action.args, + error: message, + durationMs: Date.now() - startedAt, + } + } +} + +async function dispatchTabs(action: Action): Promise { + const sub = action.args[0] ?? "list" + if (sub === "list") return Tabs.list() + if (sub === "open") { + const url = flagString(action, "--url") + return Tabs.open({ + ...(url ? { url } : {}), + }) + } + if (sub === "select") { + return Tabs.select({ + index: requiredNumber(action, "--index"), + }) + } + if (sub === "close") { + const idx = flagString(action, "--index") + return Tabs.close({ + ...(idx !== undefined ? { index: requiredNumber(action, "--index") } : {}), + }) + } + throw new Error(`unknown tabs subcommand: ${sub}`) +} + +async function dispatchCookies(action: Action): Promise { + const sub = action.args[0] ?? "get" + if (sub === "get") { + return Cookies.get({ domain: required(action, "--domain") }) + } + if (sub === "set") { + return Cookies.set({ + name: required(action, "--name"), + value: required(action, "--value"), + domain: required(action, "--domain"), + ...(flagString(action, "--path") ? { path: flagString(action, "--path") } : {}), + }) + } + if (sub === "clear") { + return Cookies.clear({ + ...(flagString(action, "--domain") ? { domain: flagString(action, "--domain") } : {}), + }) + } + throw new Error(`unknown cookies subcommand: ${sub}`) +} + +function ok(action: Action, startedAt: number, data: unknown): ActionResult { + return { + ok: true, + verb: action.verb, + args: action.args, + data, + durationMs: Date.now() - startedAt, + } +} + +async function resolveEvaluateJs(action: Action): Promise { + const inline = flagString(action, "--js") + if (inline !== undefined) return inline + const file = flagString(action, "--js-file") + if (file === undefined) throw new Error("evaluate requires --js or --js-file ") + const target = authorized(action, file) + if (!existsSync(target)) throw new Error(`--js-file not found: ${target}`) + return readFileSync(target, "utf8") +} + +function authorized(action: Action, value: string): string { + const target = resolvePath(value, action.directory) + if (action.paths === undefined) return target + const allowed = action.paths.some((item) => resolvePath(item, action.directory) === target) + if (!allowed) throw new Error(`path was not authorized: ${target}`) + return target +} + +function flagString(action: Action, name: string): string | undefined { + for (let i = 0; i < action.args.length; i++) { + const t = action.args[i] + if (t === name) return action.args[i + 1] + if (t?.startsWith(`${name}=`)) return t.slice(name.length + 1) + } + return undefined +} + +function hasFlag(action: Action, name: string): boolean { + return action.args.includes(name) +} + +function required(action: Action, name: string): string { + const v = flagString(action, name) + if (v === undefined) throw new Error(`missing required flag: ${name}`) + return v +} + +function numberFlag(action: Action, name: string): number | undefined { + const value = flagString(action, name) + if (value === undefined) return undefined + const number = Number(value) + if (!Number.isFinite(number)) throw new Error(`${name} must be a finite number`) + return number +} + +function requiredNumber(action: Action, name: string): number { + const value = numberFlag(action, name) + if (value === undefined) throw new Error(`missing required flag: ${name}`) + return value +} diff --git a/packages/kilo-world/src/daemon/entry.ts b/packages/kilo-world/src/daemon/entry.ts new file mode 100644 index 00000000000..99148b96cc3 --- /dev/null +++ b/packages/kilo-world/src/daemon/entry.ts @@ -0,0 +1,7 @@ +import { start } from "./runtime" + +start().catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + process.stderr.write(`daemon fatal: ${message}\n`) + process.exit(1) +}) diff --git a/packages/kilo-world/src/daemon/protocol.ts b/packages/kilo-world/src/daemon/protocol.ts new file mode 100644 index 00000000000..12e8c6ec223 --- /dev/null +++ b/packages/kilo-world/src/daemon/protocol.ts @@ -0,0 +1,104 @@ +import type { DaemonConfig } from "../types" + +export type DaemonRequest = { + id: string + verb: string + args: string[] + directory?: string + paths?: string[] + config?: DaemonConfig + /** + * Per-daemon auth token (32-byte hex, 64 chars). Required on every request. + * Generated by the daemon at startup and written to the handshake file + * alongside `url`. Without a valid token the daemon rejects the request — + * prevents any other local process on a multi-user system (or any other + * process running as your user) from driving the browser by POSTing to + * the daemon's HTTP endpoint. + */ + auth?: string +} + +export type DaemonResponse = { + id: string + ok: boolean + envelope: Record + message?: string +} + +export type DaemonLifecycleResponse = { + id: string + ok: boolean + message?: string +} + +export type DaemonHandshake = { + pid: number + version: string + startedAt: number + idleTimeoutMs: number + sessionID: string + /** + * Base URL of the per-session HTTP endpoint, e.g. `http://127.0.0.1:54321`. + * Single source of truth — clients POST to `${url}/call` and need not + * reconstruct host/port themselves. + */ + url: string + /** + * Per-daemon auth token. Clients must echo this on every request. + * See `DaemonRequest.auth`. + */ + token: string +} + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +export function isHandshake(value: unknown): value is DaemonHandshake { + if (!record(value)) return false + return ( + typeof value.pid === "number" && + typeof value.version === "string" && + typeof value.startedAt === "number" && + typeof value.idleTimeoutMs === "number" && + typeof value.sessionID === "string" && + typeof value.url === "string" && + typeof value.token === "string" + ) +} + +export function isRequest(value: unknown): value is DaemonRequest { + if (!record(value)) return false + if (typeof value.id !== "string" || typeof value.verb !== "string") return false + if (!Array.isArray(value.args) || !value.args.every((arg) => typeof arg === "string")) return false + if (value.directory !== undefined && typeof value.directory !== "string") return false + if ( + value.paths !== undefined && + (!Array.isArray(value.paths) || !value.paths.every((item) => typeof item === "string")) + ) + return false + if (value.auth !== undefined && typeof value.auth !== "string") return false + return value.config === undefined || config(value.config) +} + +function config(value: unknown): value is DaemonConfig { + if (!record(value)) return false + if (Object.keys(value).some((key) => key !== "browser")) return false + if (value.browser === undefined) return true + if (!record(value.browser)) return false + const keys = ["headless", "antiDetect", "timeoutMs", "viewport", "useSystemChrome"] + if (Object.keys(value.browser).some((key) => !keys.includes(key))) return false + if (value.browser.headless !== undefined && typeof value.browser.headless !== "boolean") return false + if (value.browser.antiDetect !== undefined && typeof value.browser.antiDetect !== "boolean") return false + if (value.browser.useSystemChrome !== undefined && typeof value.browser.useSystemChrome !== "boolean") return false + if (value.browser.timeoutMs !== undefined && typeof value.browser.timeoutMs !== "number") return false + if (value.browser.viewport === undefined) return true + if (!record(value.browser.viewport)) return false + return typeof value.browser.viewport.width === "number" && typeof value.browser.viewport.height === "number" +} + +export function isResponse(value: unknown): value is DaemonResponse { + if (!record(value)) return false + if (typeof value.id !== "string" || typeof value.ok !== "boolean" || !record(value.envelope)) return false + return value.message === undefined || typeof value.message === "string" +} diff --git a/packages/kilo-world/src/daemon/runtime.ts b/packages/kilo-world/src/daemon/runtime.ts new file mode 100644 index 00000000000..3691048ec49 --- /dev/null +++ b/packages/kilo-world/src/daemon/runtime.ts @@ -0,0 +1,22 @@ +import { Runner } from "../core/browser/runner" +import { dispatch } from "./dispatch" +import { DaemonServer, setDispatch, setShutdown } from "./server" + +export async function start(): Promise { + const sessionArg = process.argv.find((arg) => arg.startsWith("--session=")) + const idleArg = process.argv.find((arg) => arg.startsWith("--idle=")) + const session = sessionArg?.slice("--session=".length) ?? "default" + if (!session) throw new Error("--session= is required") + const env = process.env["KILO_WORLD_DAEMON_IDLE_MS"] + const raw = idleArg?.slice("--idle=".length) ?? env + const idle = raw === undefined ? undefined : Number(raw) + if (idle !== undefined && !Number.isFinite(idle)) throw new Error("idle timeout must be a finite number") + setDispatch(dispatch) + setShutdown(() => Runner.shutdown()) + await DaemonServer.start({ + sessionID: session, + silent: process.env["KILO_WORLD_DAEMON_SILENT"] === "1", + ...(Number.isFinite(idle) ? { idleTimeoutMs: idle } : {}), + }) + await new Promise(() => {}) +} diff --git a/packages/kilo-world/src/daemon/server.ts b/packages/kilo-world/src/daemon/server.ts new file mode 100644 index 00000000000..1aca05d0729 --- /dev/null +++ b/packages/kilo-world/src/daemon/server.ts @@ -0,0 +1,493 @@ +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs" +import { randomBytes, timingSafeEqual } from "node:crypto" +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http" +import { join } from "node:path" +import { ensureHome, getConfig } from "../config" +import type { RunOptions } from "../types" +import { isHandshake, isRequest, type DaemonHandshake, type DaemonRequest, type DaemonResponse } from "./protocol" + +export type DispatchFn = (req: DaemonRequest, opts?: RunOptions) => Promise> + +let installedDispatch: DispatchFn | null = null +let installedShutdown: (() => Promise) | null = null +let currentServer: Server | null = null + +export function setDispatch(fn: DispatchFn): void { + installedDispatch = fn +} + +export function setShutdown(fn: () => Promise): void { + installedShutdown = fn +} + +const IDLE_TIMEOUT_MS_DEFAULT = 5 * 60_000 +const SERVER_CLOSE_TIMEOUT_MS = 2000 +const BROWSER_CLOSE_TIMEOUT_MS = 5000 + +const NEVER_TIMEOUT = 0 + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function authorized(input: string, expected: string): boolean { + const left = Buffer.from(input) + const right = Buffer.from(expected) + return left.length === right.length && timingSafeEqual(left, right) +} + +function remove(file: string): void { + try { + unlinkSync(file) + } catch (err) { + if (err instanceof Error && "code" in err && err.code === "ENOENT") return + throw err + } +} + +export namespace DaemonServer { + export type Options = { + sessionID: string + idleTimeoutMs?: number + silent?: boolean + } + + function safeSession(id: string): string { + return id.replace(/[^a-zA-Z0-9_.-]/g, "_").slice(0, 96) || "default" + } + + export function pidPath(sessionID: string): string { + return join(ensureHome(getConfig().home), `daemon-${safeSession(sessionID)}.pid`) + } + + export function handshakePath(sessionID: string): string { + return join(ensureHome(getConfig().home), `daemon-${safeSession(sessionID)}.json`) + } + + export function logPath(sessionID: string): string { + return join(ensureHome(getConfig().home), `daemon-${safeSession(sessionID)}.log`) + } + + export function handshake(sessionID: string): { + pid: number + startedAt: number + sessionID?: string + url: string + token: string + } | null { + const path = handshakePath(sessionID) + if (!existsSync(path)) return null + try { + const data: unknown = JSON.parse(readFileSync(path, "utf8")) + if (!isHandshake(data)) return null + return { + pid: data.pid, + startedAt: data.startedAt ?? 0, + ...(data.sessionID ? { sessionID: data.sessionID } : {}), + url: data.url, + token: data.token, + } + } catch { + return null + } + } + + function log(line: string, silent: boolean): void { + if (!silent) process.stderr.write(`[kilo-world daemon] ${line}\n`) + } + + export function isRunning(sessionID: string): boolean { + const pidFile = pidPath(sessionID) + if (!existsSync(pidFile)) return false + const pid = (() => { + try { + return Number(readFileSync(pidFile, "utf8").trim()) + } catch { + return Number.NaN + } + })() + if (!Number.isFinite(pid)) return false + const data = handshake(sessionID) + if (!data || data.pid !== pid) return false + try { + process.kill(pid, 0) + return true + } catch (err) { + if (err instanceof Error && "code" in err && err.code === "EPERM") return true + return false + } + } + + export function clear(sessionID: string, owner?: number): void { + if (owner !== undefined) { + const handshake = DaemonServer.handshake(sessionID) + if (handshake && handshake.pid !== owner) return + } + for (const path of [pidPath(sessionID), handshakePath(sessionID)]) { + remove(path) + } + } + + function claim(sessionID: string): void { + const file = pidPath(sessionID) + try { + writeFileSync(file, String(process.pid), { flag: "wx", mode: 0o600 }) + } catch (err) { + if (!(err instanceof Error) || !("code" in err) || err.code !== "EEXIST") throw err + const pid = (() => { + try { + return Number(readFileSync(file, "utf8").trim()) + } catch { + return Number.NaN + } + })() + if (Number.isFinite(pid)) { + try { + process.kill(pid, 0) + throw new Error(`kilo-world daemon for session ${sessionID} is already starting (pid ${pid})`) + } catch (cause) { + if (!(cause instanceof Error) || !("code" in cause)) throw cause + if (cause.code === "EPERM") { + throw new Error(`kilo-world daemon for session ${sessionID} is already starting (pid ${pid})`) + } + if (cause.code !== "ESRCH") throw cause + } + } + remove(file) + writeFileSync(file, String(process.pid), { flag: "wx", mode: 0o600 }) + } + remove(handshakePath(sessionID)) + } + + let shutdownTimer: NodeJS.Timeout | null = null + let lastActivityAt = Date.now() + let currentIdleTimeoutMs = IDLE_TIMEOUT_MS_DEFAULT + let activeRequests = 0 + let stopping: Promise | null = null + let watcher: NodeJS.Timeout | null = null + + function currentRemainingMs(): number { + if (currentIdleTimeoutMs === NEVER_TIMEOUT) return 0 + if (activeRequests > 0) return currentIdleTimeoutMs + return Math.max(0, currentIdleTimeoutMs - (Date.now() - lastActivityAt)) + } + + export function status() { + return { + running: true, + pid: process.pid, + runtime: typeof Bun === "undefined" ? "node" : "bun", + runtimeVersion: process.version, + sessionID: process.env["KILO_WORLD_DAEMON_SESSION"] ?? currentSessionIDForActivity, + uptimeMs: process.uptime() * 1000, + idleTimeoutMs: currentIdleTimeoutMs, + idleTimeoutRemainingMs: currentRemainingMs(), + } + } + + async function shutdown(sessionID: string, silent: boolean): Promise { + if (stopping) return stopping + stopping = (async () => { + try { + if (shutdownTimer) clearTimeout(shutdownTimer) + if (watcher) clearInterval(watcher) + const server = currentServer + currentServer = null + if (server) { + server.closeAllConnections() + await Promise.race([ + new Promise((resolve) => server.close(() => resolve())), + delay(SERVER_CLOSE_TIMEOUT_MS), + ]) + } + await Promise.race([ + installedShutdown?.().catch((err) => log(`browser shutdown failed: ${String(err)}`, silent)) ?? + Promise.resolve(), + delay(BROWSER_CLOSE_TIMEOUT_MS), + ]) + } finally { + try { + clear(sessionID, process.pid) + } catch (err) { + log(`state cleanup failed: ${String(err)}`, silent) + } + process.exit(0) + } + })() + return stopping + } + + function scheduleShutdown(sessionID: string, delayMs: number, silent: boolean): void { + if (shutdownTimer) clearTimeout(shutdownTimer) + if (delayMs === NEVER_TIMEOUT) { + log(`idle timer disabled for session ${sessionID} (never times out)`, silent) + return + } + log(`idle timer reset for session ${sessionID}: ${delayMs}ms`, silent) + lastActivityAt = Date.now() + shutdownTimer = setTimeout(() => { + log(`idle timeout for session ${sessionID}, shutting down`, silent) + void shutdown(sessionID, silent) + }, delayMs) + } + + function beginActivity(): void { + activeRequests++ + if (shutdownTimer) clearTimeout(shutdownTimer) + } + + function endActivity(): void { + activeRequests = Math.max(0, activeRequests - 1) + lastActivityAt = Date.now() + if (activeRequests > 0 || currentIdleTimeoutMs === NEVER_TIMEOUT || stopping) return + scheduleShutdown(currentSessionIDForActivity, currentIdleTimeoutMs, true) + } + + let currentSessionIDForActivity = "default" + + function applyIdleMs( + sessionID: string, + idleMs: number, + silent: boolean, + ): { idleTimeoutMs: number; idleTimeoutRemainingMs: number } { + if (!Number.isFinite(idleMs)) throw new Error("idle timeout must be a finite number") + if (idleMs < 0) throw new Error("idle timeout cannot be negative") + const normalized = Math.floor(idleMs) + if (normalized === NEVER_TIMEOUT) { + currentIdleTimeoutMs = NEVER_TIMEOUT + if (shutdownTimer) clearTimeout(shutdownTimer) + log(`idle timeout for session ${sessionID} set to never`, silent) + } else { + currentIdleTimeoutMs = normalized + scheduleShutdown(sessionID, normalized, silent) + } + return { idleTimeoutMs: currentIdleTimeoutMs, idleTimeoutRemainingMs: currentRemainingMs() } + } + + // Per-daemon auth token. Generated at `start()` and written to the handshake + // file. Every inbound request must include it; otherwise the daemon returns + // 401. This stops any other local process on a multi-user machine (or any + // process running as the same user) from driving the browser by POSTing to + // the daemon's HTTP endpoint. + let currentAuthToken: string | null = null + + function readJsonBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + let total = 0 + const limit = 16 * 1024 * 1024 + req.on("data", (chunk: Buffer) => { + total += chunk.length + if (total > limit) { + reject(new Error(`request body exceeds ${limit} bytes`)) + req.destroy() + return + } + chunks.push(chunk) + }) + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))) + req.on("error", reject) + }) + } + + function writeJson(res: ServerResponse, status: number, body: unknown): void { + const payload = `${JSON.stringify(body)}\n` + res.writeHead(status, { + "content-type": "application/json", + "content-length": Buffer.byteLength(payload), + }) + res.end(payload) + } + + async function handleCall(req: IncomingMessage, res: ServerResponse, sessionID: string): Promise { + let raw: string + try { + raw = await readJsonBody(req) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + const resp: DaemonResponse = { id: "parse", ok: false, envelope: {}, message: `bad body: ${message}` } + writeJson(res, 400, resp) + return + } + let value: unknown + try { + value = JSON.parse(raw) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + const resp: DaemonResponse = { id: "parse", ok: false, envelope: {}, message: `bad json: ${message}` } + writeJson(res, 400, resp) + return + } + if (!isRequest(value)) { + writeJson(res, 400, { id: "parse", ok: false, envelope: {}, message: "invalid request" }) + return + } + const action = value + if (!currentAuthToken || !action.auth || !authorized(action.auth, currentAuthToken)) { + writeJson(res, 401, { id: action.id ?? "auth", ok: false, envelope: {}, message: "unauthorized" }) + return + } + + beginActivity() + const controller = new AbortController() + req.once("aborted", () => controller.abort()) + res.once("finish", endActivity) + res.once("close", () => { + if (res.writableFinished) return + controller.abort() + endActivity() + }) + + if (action.verb === "__ping__") { + writeJson(res, 200, { id: action.id, ok: true, envelope: {}, message: "pong" }) + return + } + if (action.verb === "__shutdown__") { + const resp: DaemonResponse = { id: action.id, ok: true, envelope: {}, message: "shutting down" } + writeJson(res, 200, resp) + setImmediate(() => { + void shutdown(sessionID, true) + }) + return + } + if (action.verb === "__status__") { + const resp: DaemonResponse = { + id: action.id, + ok: true, + envelope: { + ok: true, + ...status(), + }, + } + writeJson(res, 200, resp) + return + } + if (action.verb === "__set_idle__") { + const idleMs = Number(action.args[0] ?? IDLE_TIMEOUT_MS_DEFAULT) + const out = applyIdleMs(sessionID, idleMs, true) + writeJson(res, 200, { id: action.id, ok: true, envelope: { ok: true, ...out } }) + return + } + try { + if (!installedDispatch) + throw new Error("kilo-world daemon: dispatch not installed (entry.ts forgot to call setDispatch)") + const envelope = await installedDispatch(action, { signal: controller.signal }) + const resp: DaemonResponse = { + id: action.id, + ok: envelope.ok !== false, + envelope, + } + writeJson(res, 200, resp) + if (action.verb === "daemon.stop") setImmediate(() => void shutdown(sessionID, true)) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + const resp: DaemonResponse = { + id: action.id, + ok: false, + envelope: { + ok: false, + tool: "browser", + verb: action.verb, + duration_ms: 0, + warnings: [], + errors: [message], + reason: message.length > 80 ? message.slice(0, 77) + "..." : message, + }, + } + writeJson(res, 200, resp) + } + } + + export async function start(opts: Options): Promise { + const rawIdle = opts.idleTimeoutMs ?? IDLE_TIMEOUT_MS_DEFAULT + if (!Number.isFinite(rawIdle)) throw new Error("idle timeout must be a finite number") + if (rawIdle < 0) throw new Error("idle timeout cannot be negative") + const idleTimeoutMs = Math.floor(rawIdle) + const silent = opts.silent ?? false + const sessionID = opts.sessionID + currentSessionIDForActivity = sessionID + currentIdleTimeoutMs = idleTimeoutMs + lastActivityAt = Date.now() + mkdirSync(ensureHome(getConfig().home), { recursive: true }) + if (isRunning(sessionID)) { + throw new Error(`kilo-world daemon for session ${sessionID} is already running`) + } + claim(sessionID) + currentAuthToken = randomBytes(32).toString("hex") + const server = createServer((req, res) => { + if (req.method === "POST" && req.url === "/call") { + void handleCall(req, res, sessionID).catch((err) => { + const message = err instanceof Error ? err.message : String(err) + try { + writeJson(res, 500, { id: "internal", ok: false, envelope: {}, message }) + } catch (writeErr) { + res.destroy(writeErr instanceof Error ? writeErr : undefined) + } + }) + return + } + writeJson(res, 404, { + id: "not-found", + ok: false, + envelope: {}, + message: `no route for ${req.method} ${req.url}`, + }) + }) + currentServer = server + const host = "127.0.0.1" + const port = await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen({ host, port: 0, exclusive: false }, () => { + server.off("error", reject) + const addr = server.address() + if (addr && typeof addr === "object") resolve(addr.port) + else reject(new Error("server bound but no address returned")) + }) + }).catch((err) => { + currentServer = null + clear(sessionID, process.pid) + throw err + }) + const url = `http://${host}:${port}` + const handshake: DaemonHandshake = { + pid: process.pid, + version: "0.1.0", + startedAt: Date.now(), + idleTimeoutMs, + sessionID, + url, + token: currentAuthToken, + } + writeFileSync(handshakePath(sessionID), JSON.stringify(handshake, null, 2), { mode: 0o600 }) + log( + `listening for session ${sessionID} at ${url} (pid=${process.pid}, idle=${ + idleTimeoutMs === NEVER_TIMEOUT ? "never" : `${idleTimeoutMs}ms` + })`, + silent, + ) + if (idleTimeoutMs !== NEVER_TIMEOUT) { + scheduleShutdown(sessionID, idleTimeoutMs, silent) + } + process.on("SIGTERM", () => { + log("SIGTERM, shutting down", silent) + void shutdown(sessionID, silent) + }) + process.on("SIGINT", () => { + log("SIGINT, shutting down", silent) + void shutdown(sessionID, silent) + }) + process.on("exit", () => clear(sessionID, process.pid)) + const parent = Number(process.env["KILO_WORLD_PARENT_PID"]) + if (Number.isFinite(parent) && parent > 0) { + watcher = setInterval(() => { + try { + process.kill(parent, 0) + } catch { + void shutdown(sessionID, silent) + } + }, 1000) + watcher.unref() + } + return server + } +} diff --git a/packages/kilo-world/src/index.ts b/packages/kilo-world/src/index.ts new file mode 100644 index 00000000000..61b240c3516 --- /dev/null +++ b/packages/kilo-world/src/index.ts @@ -0,0 +1,64 @@ +import { defaultConfig, ensureHome, getConfig, setConfig } from "./config" +import { DaemonClient } from "./daemon/client" +import { dispatch } from "./daemon/dispatch" +import { parseScript as parseScriptFn } from "./script-parser" +import type { Action, RunOptions, RunResult, WorldConfig, WorldConfigPatch } from "./types" + +export { defaultConfig, ensureHome, getConfig, setConfig } +export { DaemonClient } +export { parseScript } from "./script-parser" +export { resolvePath } from "./path" +export type { Action, RunOptions, RunResult, WorldConfig } +export * from "./types" + +export namespace World { + export function configure(patch: WorldConfigPatch): WorldConfig { + return setConfig(patch) + } + + export function currentConfig(): WorldConfig { + return getConfig() + } + + export const parseScript = (text: string): Action[] => parseScriptFn(text) + + export async function run(script: string, opts: RunOptions = {}): Promise { + const actions = parseScript(script) + const startedAt = Date.now() + const results: RunResult["results"] = [] + let allOk = true + for (const action of actions) { + opts.signal?.throwIfAborted() + const result = await dispatch(action, opts) + results.push(result) + if (!result.ok) { + allOk = false + break + } + } + return { ok: allOk, durationMs: Date.now() - startedAt, results } + } + + export async function runForSession(sessionID: string, script: string, opts: RunOptions = {}): Promise { + return DaemonClient.runViaSession(sessionID, script, opts) + } + + export async function startDaemon( + sessionID: string, + opts: { idleMs?: number } = {}, + ): Promise { + return DaemonClient.startDaemon(sessionID, opts) + } + + export async function setDaemonIdle(sessionID: string, idleMs: number): Promise { + return DaemonClient.setIdle(sessionID, idleMs) + } + + export async function daemonStatus(sessionID: string): Promise { + return DaemonClient.statusOf(sessionID) + } + + export async function stopDaemon(sessionID: string): Promise { + return DaemonClient.stop(sessionID) + } +} diff --git a/packages/kilo-world/src/path.ts b/packages/kilo-world/src/path.ts new file mode 100644 index 00000000000..4262379a855 --- /dev/null +++ b/packages/kilo-world/src/path.ts @@ -0,0 +1,18 @@ +import { homedir } from "node:os" +import path from "node:path" + +export function resolvePath(file: string, dir?: string): string { + const home = file === "~" ? homedir() : /^[~][\\/]/.test(file) ? path.join(homedir(), file.slice(2)) : file + const expanded = home.replace(/%([^%]+)%|\$\{([^}]+)\}|\$([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, win, brace, unix) => { + const key = win ?? brace ?? unix + const value = process.env[key] ?? (win ? env(key) : undefined) + return value ?? _ + }) + if (path.isAbsolute(expanded) || !dir) return path.normalize(expanded) + return path.resolve(dir, expanded) +} + +function env(key: string): string | undefined { + const name = Object.keys(process.env).find((item) => item.toLowerCase() === key.toLowerCase()) + return name ? process.env[name] : undefined +} diff --git a/packages/kilo-world/src/script-parser.ts b/packages/kilo-world/src/script-parser.ts new file mode 100644 index 00000000000..e209637b427 --- /dev/null +++ b/packages/kilo-world/src/script-parser.ts @@ -0,0 +1,97 @@ +export type Action = { + verb: string + args: string[] +} + +/** + * Parse a ;-separated script of browser actions. + * + * The parser is quote-aware: `;` inside `'…'`, `"…"`, or `` `…` `` is preserved + * as a regular character. Inside quoted strings, a backslash only escapes + * the active quote character or another backslash, so ordinary Windows path + * separators are preserved. Unquoted whitespace separates tokens. + * + * JavaScript passed to `evaluate --js` must be quoted when it contains + * whitespace or semicolons. `--js-file` accepts one path token. + */ +export function parseScript(text: string): Action[] { + const actions: Action[] = [] + let verb: string | null = null + let args: string[] = [] + let current = "" + let quote: '"' | "'" | "`" | null = null + let escape = false + let started = false + + const flushToken = () => { + if (!started) return + if (verb === null) { + verb = current + } else { + args.push(current) + } + current = "" + started = false + } + const flushVerb = () => { + flushToken() + if (verb !== null) { + actions.push({ verb, args }) + verb = null + args = [] + } + } + + for (let i = 0; i < text.length; i++) { + const ch = text[i] + if (escape) { + current += ch + escape = false + started = true + continue + } + if (quote) { + if (ch === "\\") { + const next = text[i + 1] + if (next === quote || next === "\\") { + escape = true + started = true + continue + } + current += ch + started = true + continue + } + if (ch === quote) { + // closing quote — do not include it in the token + quote = null + continue + } + current += ch + started = true + continue + } + if (ch === '"' || ch === "'" || ch === "`") { + // opening quote — do not include the quote char in the token + quote = ch + started = true + continue + } + if (ch === ";") { + flushToken() + flushVerb() + continue + } + if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") { + flushToken() + continue + } + current += ch + started = true + } + if (escape) throw new Error("unterminated escape in script") + if (quote !== null) throw new Error("unterminated quote in script") + flushToken() + flushVerb() + return actions +} diff --git a/packages/kilo-world/src/types.ts b/packages/kilo-world/src/types.ts new file mode 100644 index 00000000000..66a8f85ec66 --- /dev/null +++ b/packages/kilo-world/src/types.ts @@ -0,0 +1,122 @@ +export type Envelope = { + ok: boolean + tool: "browser" | "config" | "computer" + verb: string + session?: string + duration_ms: number + data?: T + warnings: string[] + errors: string[] + reason?: string +} + +export type BrowserCapability = { + headless: boolean + display?: string + chromiumReady: boolean + chromiumVersion?: string + installation: { + state: "available" | "missing" + message?: string + } +} + +export type SessionInfo = { + name: string + createdAt: number + lastUsedAt: number + lastUrl?: string +} + +export type BrowserStatus = { + sessions: SessionInfo[] + capability: BrowserCapability + chromiumPid: number | null +} + +export type TabInfo = { + index: number + url: string + title?: string + active: boolean +} + +export type CookieEntry = { + name: string + value: string + domain: string + path: string + expires?: number + httpOnly?: boolean + secure?: boolean + sameSite?: "Strict" | "Lax" | "None" +} + +export type RefEntry = { + ref: string + role: string + name: string + selector?: string + depth: number +} + +export type Snapshot = { + snapshot: string + refs: RefEntry[] +} + +export type BrowserConfig = { + headless: boolean + antiDetect: boolean + timeoutMs: number + viewport: { width: number; height: number } + executablePath?: string + useSystemChrome?: boolean + args: string[] +} + +export type WorldConfig = { + browser: BrowserConfig + home: string +} + +export type WorldConfigPatch = Omit, "browser"> & { + browser?: Partial +} + +export type DaemonConfig = { + browser?: Pick +} + +export type Action = { + verb: string + args: string[] + directory?: string + paths?: string[] + config?: DaemonConfig +} + +export type RunOptions = { + signal?: AbortSignal + timeoutMs?: number + directory?: string + paths?: string[] + config?: WorldConfig +} + +export type RunResult = { + ok: boolean + durationMs: number + results: ActionResult[] +} + +export type ActionResult = { + ok: boolean + verb: string + args: string[] + data?: unknown + error?: string + durationMs: number + screenshot?: { path: string; bytes: number; mime: string } + refs?: Array<{ ref: string; role: string; name: string; selector?: string }> +} diff --git a/packages/kilo-world/test/build.test.ts b/packages/kilo-world/test/build.test.ts new file mode 100644 index 00000000000..326fc9b2d59 --- /dev/null +++ b/packages/kilo-world/test/build.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test" +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { fingerprint } from "../src/daemon/build" + +test("fingerprints relative package roots", async () => { + const root = join(import.meta.dirname, "..") + expect(await fingerprint(".")).toBe(await fingerprint(root)) +}) + +test("invalidates only for relevant dependency lock changes", async () => { + const dir = await mkdtemp(join(tmpdir(), "kilo-world-fingerprint-")) + const root = join(dir, "packages", "kilo-world") + const lock = (version: string, other: string) => `{ + "workspaces": { + "packages/kilo-world": { + "name": "@kilocode/world", + "dependencies": { + "playwright": "catalog:", + }, + }, + "packages/other": { + "name": "other", + }, + }, + "packages": { + "other": ["other@${other}", "", {}, "other-integrity"], + "playwright": ["playwright@${version}", "", { "dependencies": { "playwright-core": "${version}" } }, "playwright-integrity"], + "playwright-core": ["playwright-core@${version}", "", {}, "playwright-core-integrity"], + } +}` + try { + await Promise.all([mkdir(join(root, "script"), { recursive: true }), mkdir(join(root, "src"), { recursive: true })]) + await Promise.all([ + writeFile(join(dir, "package.json"), JSON.stringify({ workspaces: { catalog: { playwright: "1.57.0" } } })), + writeFile(join(root, "package.json"), JSON.stringify({ dependencies: { playwright: "catalog:" } })), + writeFile(join(root, "script", "build-daemon.ts"), ""), + writeFile(join(root, "script", "daemon.ts"), ""), + writeFile(join(root, "src", "index.ts"), ""), + writeFile(join(dir, "bun.lock"), lock("1.57.0", "1.0.0")), + ]) + const first = await fingerprint(root) + await writeFile(join(dir, "bun.lock"), lock("1.57.0", "2.0.0")) + expect(await fingerprint(root)).toBe(first) + await writeFile(join(dir, "bun.lock"), lock("1.58.0", "2.0.0")) + expect(await fingerprint(root)).not.toBe(first) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) diff --git a/packages/kilo-world/test/daemon.test.ts b/packages/kilo-world/test/daemon.test.ts new file mode 100644 index 00000000000..59d9991bc63 --- /dev/null +++ b/packages/kilo-world/test/daemon.test.ts @@ -0,0 +1,307 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test" +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { chromium } from "playwright" +import { DaemonClient, World } from "../src" +import { DaemonServer } from "../src/daemon/server" +import { WorldDaemon } from "../script/daemon" + +const session = `test-${process.pid}` +const peer = `${session}-peer` +const home = mkdtempSync(join(tmpdir(), "kilo-world-test-")) +const daemon = join(home, WorldDaemon.filename) +const config = World.currentConfig() +const available = existsSync(chromium.executablePath()) +const env = { + daemon: process.env["KILO_WORLD_DAEMON_PATH"], + node: process.env["KILO_WORLD_NODE"], +} + +beforeAll(async () => { + await WorldDaemon.copy(await WorldDaemon.bundle(), home) + process.env["KILO_WORLD_DAEMON_PATH"] = daemon + process.env["KILO_WORLD_NODE"] = Bun.which("node") ?? "node" + World.configure({ home }) +}) + +afterAll(async () => { + await Promise.all([DaemonClient.stop(session), DaemonClient.stop(peer)]) + await Promise.all([ + waitFor(session, () => !DaemonServer.isRunning(session)), + waitFor(peer, () => !DaemonServer.isRunning(peer)), + ]) + if (env.daemon === undefined) delete process.env["KILO_WORLD_DAEMON_PATH"] + else process.env["KILO_WORLD_DAEMON_PATH"] = env.daemon + if (env.node === undefined) delete process.env["KILO_WORLD_NODE"] + else process.env["KILO_WORLD_NODE"] = env.node + World.configure(config) + rmSync(home, { recursive: true, force: true }) +}) + +describe("world daemon", () => { + test("records every packaged daemon output", () => { + const files: unknown = JSON.parse(readFileSync(join(home, WorldDaemon.manifest), "utf8")) + expect(files).toBeArray() + expect(files).toContain(WorldDaemon.filename) + }) + + test("reports invalid daemon and runtime overrides", async () => { + process.env["KILO_WORLD_DAEMON_PATH"] = join(home, "missing.js") + await expect(DaemonClient.ensureRunning(`${session}-missing`)).rejects.toThrow( + "KILO_WORLD_DAEMON_PATH does not exist", + ) + process.env["KILO_WORLD_DAEMON_PATH"] = daemon + + process.env["KILO_WORLD_NODE"] = join(home, "missing-node") + await expect(DaemonClient.ensureRunning(`${session}-runtime`)).rejects.toThrow("KILO_WORLD_NODE does not exist") + process.env["KILO_WORLD_NODE"] = Bun.which("node") ?? "node" + }) + + test("deduplicates concurrent startup and serves authenticated requests", async () => { + await Promise.all([ + DaemonClient.ensureRunning(session, { idleMs: 0 }), + DaemonClient.ensureRunning(session, { idleMs: 0 }), + DaemonClient.ensureRunning(session, { idleMs: 0 }), + ]) + const handshake = DaemonClient.handshake(session) + expect(handshake).not.toBeNull() + expect(DaemonServer.isRunning(session)).toBe(true) + + const denied = await fetch(`${handshake!.url}/call`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: "denied", verb: "__status__", args: [], auth: "wrong" }), + }) + expect(denied.status).toBe(401) + + const unsafe = await fetch(`${handshake!.url}/call`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: "unsafe", + verb: "status", + args: [], + auth: handshake!.token, + config: { browser: { executablePath: "/tmp/browser" } }, + }), + }) + expect(unsafe.status).toBe(400) + + if (process.platform !== "win32") { + expect(statSync(DaemonServer.pidPath(session)).mode & 0o777).toBe(0o600) + expect(statSync(DaemonServer.handshakePath(session)).mode & 0o777).toBe(0o600) + } + }) + + test("rejects negative idle timeouts", async () => { + await expect(World.setDaemonIdle(session, -1)).rejects.toThrow("cannot be negative") + }) + + test("rejects filesystem paths that were not approved by the caller", async () => { + const file = join(home, "evaluate.js") + writeFileSync(file, "document.title") + const result = await World.runForSession(session, `evaluate --js-file ${JSON.stringify(file)}`) + expect(result.ok).toBe(false) + expect(result.results[0]?.error).toContain("path was not authorized") + }) + + test("reports runtime status without launching Chromium", async () => { + await World.setDaemonIdle(session, 0) + const result = await World.runForSession(session, "daemon.status ; status", { + config: World.currentConfig(), + }) + expect(result.ok).toBe(true) + expect(result.results).toHaveLength(2) + expect(result.results[0]?.data).toMatchObject({ + running: true, + runtime: "node", + idleTimeoutMs: 0, + idleTimeoutRemainingMs: 0, + }) + expect(result.results[1]?.data).toMatchObject({ + capability: expect.not.objectContaining({ chromiumVersion: expect.anything() }), + }) + expect(await World.daemonStatus(session)).toMatchObject({ runtime: "node", runtimeVersion: expect.any(String) }) + }) + + test.skipIf(!available)("isolates browser state and processes between sessions", async () => { + const [first, second] = await Promise.all([ + World.runForSession( + session, + 'navigate --url "data:text/html,First" ; evaluate --js "document.title"', + ), + World.runForSession( + peer, + 'navigate --url "data:text/html,Second" ; evaluate --js "document.title"', + ), + ]) + expect(first.ok).toBe(true) + expect(second.ok).toBe(true) + expect(first.results[1]?.data).toEqual({ result: "First" }) + expect(second.results[1]?.data).toEqual({ result: "Second" }) + expect(DaemonClient.handshake(session)?.pid).not.toBe(DaemonClient.handshake(peer)?.pid) + + const [firstState, secondState] = await Promise.all([ + World.runForSession(session, 'evaluate --js "document.title"'), + World.runForSession(peer, 'evaluate --js "document.title"'), + ]) + expect(firstState.results[0]?.data).toEqual({ result: "First" }) + expect(secondState.results[0]?.data).toEqual({ result: "Second" }) + + const file = join(home, "daemon-browser.png") + const shot = await World.runForSession(session, `screenshot --out ${JSON.stringify(file)} --full --type png`, { + paths: [file], + }) + expect(shot.ok).toBe(true) + expect(shot.results[0]?.screenshot).toMatchObject({ path: file, mime: "image/png" }) + expect(statSync(file).size).toBeGreaterThan(0) + + expect(await DaemonClient.stop(peer)).toBe(true) + await waitFor(peer, () => !DaemonServer.isRunning(peer)) + }) + + test.skipIf(!available)("preserves runner guarantees under Node", async () => { + const dir = join(import.meta.dirname, "..", "node_modules") + mkdirSync(dir, { recursive: true }) + const root = mkdtempSync(join(dir, ".kilo-world-runner-")) + try { + const result = await Bun.build({ + entrypoints: [join(import.meta.dirname, "runner-node.ts")], + target: "node", + format: "esm", + external: ["chromium-bidi", "electron", "playwright"], + }) + expect(result.success).toBe(true) + const output = result.outputs[0] + if (!output) throw new Error("runner Node test bundle produced no output") + const file = join(root, "runner-node.mjs") + await Bun.write(file, output) + const child = Bun.spawn({ + cmd: [process.env["KILO_WORLD_NODE"]!, file], + env: { ...process.env, KILO_WORLD_HOME: join(home, "runner") }, + stdout: "pipe", + stderr: "pipe", + }) + try { + const [code, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + expect(code, `${stdout}\n${stderr}`).toBe(0) + } finally { + if (child.exitCode === null) child.kill() + } + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + test.skipIf(!available)("assigns distinct refs to elements with the same accessible name", async () => { + const result = await World.runForSession( + session, + 'navigate --url "data:text/html," ; snapshot ; click --ref e2 ; evaluate --js "document.activeElement.id"', + ) + expect(result.ok).toBe(true) + expect(result.results[1]?.refs?.map((entry) => entry.ref)).toEqual(["e1", "e2"]) + expect(result.results[3]?.data).toEqual({ result: "two" }) + }) + + test.skipIf(!available)("preserves sensitive-looking accessible names in snapshots", async () => { + const result = await World.runForSession( + session, + 'navigate --url "data:text/html," ; snapshot', + ) + expect(result.ok).toBe(true) + expect(result.results[1]?.refs).toEqual([expect.objectContaining({ role: "textbox", name: "Security PIN" })]) + expect(result.results[1]?.data).toEqual( + expect.objectContaining({ snapshot: expect.stringContaining('"Security PIN"') }), + ) + }) + + test.skipIf(!available)("installs anti-detection before documents load", async () => { + const current = World.currentConfig() + try { + const result = await World.runForSession( + session, + 'navigate --url "data:text/html,Detection" ; evaluate --js "navigator.webdriver"', + { config: { ...current, browser: { ...current.browser, antiDetect: true } } }, + ) + expect(result.ok).toBe(true) + expect(result.results[1]?.data).toEqual({ result: null }) + } finally { + await World.runForSession(session, "status", { config: current }) + } + }) + + test.skipIf(!available)("preserves key casing and supports the plus key", async () => { + const result = await World.runForSession(session, 'press-key --chord "A" ; press-key --chord "+"') + expect(result.ok).toBe(true) + expect(result.results.map((item) => item.data)).toEqual([ + { chord: "A", keys: 1 }, + { chord: "+", keys: 1 }, + ]) + }) + + test.skipIf(!available)("tracks the active page while opening and closing tabs", async () => { + const result = await World.runForSession( + session, + 'tabs open --url "data:text/html,Second" ; tabs list ; tabs close ; tabs list', + ) + expect(result.ok).toBe(true) + expect(result.results[1]?.data).toEqual([ + expect.objectContaining({ index: 0, active: false }), + expect.objectContaining({ index: 1, title: "Second", active: true }), + ]) + expect(result.results[3]?.data).toEqual([expect.objectContaining({ index: 0, active: true })]) + }) + + test.skipIf(!available)("opens a blank tab when no URL is provided", async () => { + const result = await World.runForSession(session, "tabs open") + expect(result.ok).toBe(true) + expect(result.results[0]?.data).toEqual(expect.objectContaining({ url: "about:blank" })) + }) + + test.skipIf(!available)("invalidates refs after navigation", async () => { + await World.runForSession(session, 'navigate --url "data:text/html," ; snapshot') + const result = await World.runForSession( + session, + 'navigate --url "data:text/html," ; click --ref e1', + ) + expect(result.ok).toBe(false) + expect(result.results[1]?.error).toContain("run snapshot first") + }) + + test.skipIf(!available)("cancels an action already running in Chromium", async () => { + await World.runForSession(session, 'navigate --url "data:text/html,Cancel"') + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 100) + const error = await World.runForSession(session, 'evaluate --js "new Promise(() => {})"', { + signal: controller.signal, + }).then( + () => undefined, + (err: unknown) => err, + ) + expect(error).toBeInstanceOf(Error) + expect(error).toHaveProperty("message", expect.stringMatching(/aborted/)) + clearTimeout(timer) + }) + + test("stops cleanly and removes private state files", async () => { + expect(await DaemonClient.stop(session)).toBe(true) + await waitFor(session, () => !DaemonServer.isRunning(session)) + await expect(Bun.file(DaemonServer.pidPath(session)).exists()).resolves.toBe(false) + await expect(Bun.file(DaemonServer.handshakePath(session)).exists()).resolves.toBe(false) + }) +}) + +async function waitFor(id: string, check: () => boolean) { + const start = Date.now() + while (Date.now() - start < 5000) { + if (check()) return + await Bun.sleep(25) + } + const pid = readFileSync(DaemonServer.pidPath(id), "utf8") + throw new Error(`daemon did not stop (pid ${pid})`) +} diff --git a/packages/kilo-world/test/launch.test.ts b/packages/kilo-world/test/launch.test.ts new file mode 100644 index 00000000000..0401346474e --- /dev/null +++ b/packages/kilo-world/test/launch.test.ts @@ -0,0 +1,64 @@ +import { afterAll, describe, expect, test } from "bun:test" +import { Launch } from "../src/core/browser/launch" +import { findSystemChrome } from "../src/core/browser/chrome" +import type { WorldConfig } from "../src/types" + +afterAll(() => {}) + +function cfg(patch: Partial): WorldConfig { + return { + browser: { + headless: true, + antiDetect: false, + timeoutMs: 30_000, + viewport: { width: 1280, height: 720 }, + args: [], + ...patch, + }, + home: "/tmp/world-home", + } +} + +describe("Launch.fromConfig", () => { + test("uses executablePath when explicitly set", () => { + const out = Launch.fromConfig(cfg({ executablePath: "/custom/chrome" })) + expect(out.executablePath).toBe("/custom/chrome") + }) + + test("explicit executablePath wins over useSystemChrome", () => { + const detected = findSystemChrome() + const out = Launch.fromConfig(cfg({ executablePath: "/explicit/chrome", useSystemChrome: true })) + expect(out.executablePath).toBe("/explicit/chrome") + if (detected) expect(out.executablePath).not.toBe(detected) + }) + + test("useSystemChrome resolves to detected chrome when present", () => { + const detected = findSystemChrome() + if (!detected) return + const out = Launch.fromConfig(cfg({ useSystemChrome: true })) + expect(out.executablePath).toBe(detected) + }) + + test("useSystemChrome leaves executablePath unset when chrome is missing", () => { + if (findSystemChrome()) return + const out = Launch.fromConfig(cfg({ useSystemChrome: true })) + expect(out.executablePath).toBeUndefined() + }) + + test("does not set executablePath by default", () => { + const out = Launch.fromConfig(cfg({})) + expect(out.executablePath).toBeUndefined() + }) + + test("hides only headless Chromium launches on Windows", () => { + expect(Launch.hide(true, "win32")).toBe(true) + expect(Launch.hide(false, "win32")).toBe(false) + expect(Launch.hide(true, "linux")).toBe(false) + }) + + test("uses unified headless Chromium without overriding explicit executables", () => { + expect(Launch.channel(Launch.fromConfig(cfg({ headless: true })))).toBe("chromium") + expect(Launch.channel(Launch.fromConfig(cfg({ headless: false })))).toBeUndefined() + expect(Launch.channel(Launch.fromConfig(cfg({ headless: true, executablePath: "/custom/chrome" })))).toBeUndefined() + }) +}) diff --git a/packages/kilo-world/test/parse-script.test.ts b/packages/kilo-world/test/parse-script.test.ts new file mode 100644 index 00000000000..978cf60b622 --- /dev/null +++ b/packages/kilo-world/test/parse-script.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "bun:test" +import { homedir } from "node:os" +import path from "node:path" +import { World, parseScript, resolvePath } from "../src/index" + +describe("parseScript", () => { + test("expands home-relative paths", () => { + expect(resolvePath("~/screenshots/page.png", "/workspace")).toBe(path.join(homedir(), "screenshots/page.png")) + expect(resolvePath("relative/page.png", "/workspace")).toBe(path.resolve("/workspace/relative/page.png")) + }) + + test("expands environment variables in path syntax from either platform", () => { + const key = "KILO_WORLD_TEST_PATH" + const before = process.env[key] + process.env[key] = path.join(homedir(), "world-output") + try { + const expected = path.join(homedir(), "world-output", "page.png") + expect(resolvePath(`%${key.toLowerCase()}%/page.png`, "/workspace")).toBe(expected) + expect(resolvePath(`$${key}/page.png`, "/workspace")).toBe(expected) + expect(resolvePath(`\${${key}}/page.png`, "/workspace")).toBe(expected) + } finally { + if (before === undefined) delete process.env[key] + else process.env[key] = before + } + }) + + test("preserves undefined environment variables as literal path text", () => { + expect(resolvePath("%KILO_WORLD_UNDEFINED_PATH%/page.png", "/workspace")).toBe( + path.resolve("/workspace/%KILO_WORLD_UNDEFINED_PATH%/page.png"), + ) + }) + + test("rejects agent-controlled session selection", async () => { + const result = await World.run("status --session another-session") + expect(result.ok).toBe(false) + expect(result.results[0]?.error).toBe("--session is reserved for Kilo") + }) + + test("splits on ; and trims", () => { + expect(parseScript("navigate --url a ; click --ref e1 ; screenshot --out /tmp/x.png")).toEqual([ + { verb: "navigate", args: ["--url", "a"] }, + { verb: "click", args: ["--ref", "e1"] }, + { verb: "screenshot", args: ["--out", "/tmp/x.png"] }, + ]) + }) + + test("respects single and double quotes", () => { + expect(parseScript(`navigate --url "https://example.com/path?q=1" ; type --text 'hello world'`)).toEqual([ + { verb: "navigate", args: ["--url", "https://example.com/path?q=1"] }, + { verb: "type", args: ["--text", "hello world"] }, + ]) + }) + + test("skips empty segments", () => { + expect(parseScript(" ;;navigate --url a;; ")).toEqual([{ verb: "navigate", args: ["--url", "a"] }]) + }) + + test("rejects unterminated quote", () => { + expect(() => parseScript(`navigate --url "https://example.com`)).toThrow(/unterminated quote/) + }) + + test("rejects an unterminated quote after a trailing literal backslash", () => { + expect(() => parseScript(`type --text "value\\`)).toThrow(/unterminated quote/) + }) + + test("preserves empty quoted arguments", () => { + expect(parseScript(`fill --ref e1 --value ""`)).toEqual([{ verb: "fill", args: ["--ref", "e1", "--value", ""] }]) + }) + + test("decodes escaped quotes and backslashes", () => { + expect(parseScript(`screenshot --out "C:\\\\screenshots\\\\page.png" ; type --text "say \\"hello\\""`)).toEqual([ + { verb: "screenshot", args: ["--out", "C:\\screenshots\\page.png"] }, + { verb: "type", args: ["--text", 'say "hello"'] }, + ]) + }) + + test("preserves ordinary backslashes in quoted Windows paths", () => { + const script = String.raw`screenshot --out "C:\Users\Aarav\Downloads\page.png"` + expect(parseScript(script)).toEqual([ + { verb: "screenshot", args: ["--out", String.raw`C:\Users\Aarav\Downloads\page.png`] }, + ]) + }) + + test("preserves ; inside single quotes", () => { + const actions = parseScript(`evaluate --js 'var x = 1; return x; done'`) + expect(actions).toEqual([{ verb: "evaluate", args: ["--js", "var x = 1; return x; done"] }]) + }) + + test("preserves ; inside double quotes", () => { + const actions = parseScript(`evaluate --js "var y = 2; return y; done"`) + expect(actions).toEqual([{ verb: "evaluate", args: ["--js", "var y = 2; return y; done"] }]) + }) + + test("preserves ; across multiple verbs when JS is quoted", () => { + const actions = parseScript(`status ; evaluate --js 'a;b;c' ; navigate --url https://example.com`) + expect(actions).toEqual([ + { verb: "status", args: [] }, + { verb: "evaluate", args: ["--js", "a;b;c"] }, + { verb: "navigate", args: ["--url", "https://example.com"] }, + ]) + }) + + test("supports --js-file flag", () => { + const actions = parseScript("evaluate --js-file /tmp/fill.js") + expect(actions).toEqual([{ verb: "evaluate", args: ["--js-file", "/tmp/fill.js"] }]) + }) + + test("supports backtick template literals with semicolons", () => { + const actions = parseScript("evaluate --js `var z = 3; ; done`") + expect(actions).toEqual([{ verb: "evaluate", args: ["--js", "var z = 3; ; done"] }]) + }) +}) + +describe("World.parseScript alias", () => { + test("World.parseScript produces the same output as the canonical parser", () => { + const input = "status ; evaluate --js 'a;b;c' ; navigate --url https://example.com" + expect(World.parseScript(input)).toEqual(parseScript(input)) + }) +}) + +describe("World.configure", () => { + test("returns the default config and accepts patches", () => { + const before = World.currentConfig() + expect(before.browser.timeoutMs).toBe(30_000) + const next = World.configure({ browser: { ...before.browser, headless: false, timeoutMs: 12345 } }) + expect(next.browser.headless).toBe(false) + expect(next.browser.timeoutMs).toBe(12345) + expect(World.currentConfig()).toEqual(next) + World.configure(before) + }) +}) + +describe("World.run", () => { + test("stops after the first failed action", async () => { + const result = await World.run("unknown ; status") + expect(result.ok).toBe(false) + expect(result.results).toHaveLength(1) + expect(result.results[0]?.verb).toBe("unknown") + }) + + test("honors a signal aborted before execution", async () => { + const controller = new AbortController() + controller.abort() + await expect(World.run("status", { signal: controller.signal })).rejects.toThrow() + }) +}) diff --git a/packages/kilo-world/test/runner-node.ts b/packages/kilo-world/test/runner-node.ts new file mode 100644 index 00000000000..3b3e9c7b816 --- /dev/null +++ b/packages/kilo-world/test/runner-node.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict" +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { World } from "../src" +import { Runner } from "../src/core/browser/runner" + +const config = World.currentConfig() +const root = mkdtempSync(join(tmpdir(), "kilo-world-runner-")) + +try { + await Runner.configure({ ...config, home: join(root, "state") }) + + const browsers = await Promise.all([Runner.ensureBrowser(), Runner.ensureBrowser(), Runner.ensureBrowser()]) + assert.strictEqual(browsers[1], browsers[0]) + assert.strictEqual(browsers[2], browsers[0]) + + const first = await Runner.ensureBrowser() + await Runner.configure({ browser: { ...config.browser, args: [...config.browser.args, "--disable-notifications"] } }) + const second = await Runner.ensureBrowser() + assert.equal(first.isConnected(), false) + assert.notStrictEqual(second, first) + await Runner.configure({ browser: config.browser }) + + const victim = join(root, "victim") + const marker = join(victim, "marker") + mkdirSync(victim) + writeFileSync(marker, "keep") + await Runner.configure({ ...config, home: join(root, "state") }) + await Runner.attach("../../victim") + await Runner.close("../../victim") + assert.equal(existsSync(marker), true) +} finally { + await Runner.shutdown() + await Runner.configure(config) + rmSync(root, { recursive: true, force: true }) +} diff --git a/packages/kilo-world/tsconfig.json b/packages/kilo-world/tsconfig.json new file mode 100644 index 00000000000..a495f567aaa --- /dev/null +++ b/packages/kilo-world/tsconfig.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["bun"], + "noUncheckedIndexedAccess": false + }, + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["node_modules"] +} From ca6f4680d7f1448780dde72456230df285d7759f Mon Sep 17 00:00:00 2001 From: Aarav Sharma Date: Fri, 31 Jul 2026 20:38:00 -0600 Subject: [PATCH 2/2] feat(cli): add native world browser tool --- .changeset/world-tool.md | 6 + bun.lock | 1 + packages/core/src/v1/config/config.ts | 33 ++ packages/kilo-world/src/client.ts | 2 +- packages/kilo-world/src/config.ts | 14 +- packages/kilo-world/src/daemon/client.ts | 2 + packages/kilo-world/src/daemon/protocol.ts | 8 +- packages/kilo-world/src/index.ts | 4 +- packages/kilo-world/src/types.ts | 5 +- packages/kilo-world/test/daemon.test.ts | 2 +- packages/opencode/package.json | 1 + packages/opencode/script/build.ts | 5 + .../opencode/src/kilocode/plugins/internal.ts | 2 + .../src/kilocode/plugins/world-palette.tsx | 115 ++++++ .../src/kilocode/sandbox/network-tools.ts | 1 + .../opencode/src/kilocode/tool/registry.ts | 48 ++- .../src/kilocode/tool/world-config.ts | 36 ++ .../src/kilocode/tool/world-script.ts | 45 +++ packages/opencode/src/kilocode/tool/world.ts | 336 ++++++++++++++++++ .../test/kilocode/chart-tool-gating.test.ts | 1 + ...l-registry-indexing-import-failure.test.ts | 1 + .../kilocode/tool-registry-indexing.test.ts | 4 + ...l-registry-semantic-import-failure.test.ts | 1 + .../test/kilocode/tool/send-file.test.ts | 1 + .../test/kilocode/world-config.test.ts | 44 +++ .../test/kilocode/world-script.test.ts | 57 +++ packages/sdk/js/src/v2/gen/types.gen.ts | 15 + packages/sdk/openapi.json | 112 ++++++ 28 files changed, 888 insertions(+), 14 deletions(-) create mode 100644 .changeset/world-tool.md create mode 100644 packages/opencode/src/kilocode/plugins/world-palette.tsx create mode 100644 packages/opencode/src/kilocode/tool/world-config.ts create mode 100644 packages/opencode/src/kilocode/tool/world-script.ts create mode 100644 packages/opencode/src/kilocode/tool/world.ts create mode 100644 packages/opencode/test/kilocode/world-config.test.ts create mode 100644 packages/opencode/test/kilocode/world-script.test.ts diff --git a/.changeset/world-tool.md b/.changeset/world-tool.md new file mode 100644 index 00000000000..c7a439ac01e --- /dev/null +++ b/.changeset/world-tool.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": minor +"@kilocode/world": minor +--- + +Add the built-in `world` tool for browser automation, letting Kilo navigate sites, click, type, and capture screenshots from the CLI. It runs in a persistent, isolated browser session with per-action permissions, CLI controls for headed browsing and system Chrome, and the required daemon bundled alongside the CLI. diff --git a/bun.lock b/bun.lock index bcb86ee4d73..2200ccf2593 100644 --- a/bun.lock +++ b/bun.lock @@ -552,6 +552,7 @@ "@kilocode/plugin-atomic-chat": "workspace:*", "@kilocode/sandbox": "workspace:*", "@kilocode/sdk": "workspace:*", + "@kilocode/world": "workspace:*", "@lydell/node-pty": "catalog:", "@modelcontextprotocol/sdk": "1.29.0", "@morphllm/morphsdk": "0.2.166", diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 77f5d55160d..d63b340226c 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -252,6 +252,35 @@ export const Info = Schema.Struct({ description: "Thresholds for truncating tool output. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.", }), + // kilocode_change start + world: Schema.optional( + Schema.Struct({ + browser: Schema.optional( + Schema.Struct({ + headless: Schema.optional(Schema.Boolean), + anti_detect: Schema.optional(Schema.Boolean), + timeout_ms: Schema.optional(Schema.Number), + viewport: Schema.optional( + Schema.Struct({ + width: Schema.Number, + height: Schema.Number, + }), + ), + executable_path: Schema.optional(Schema.String).annotate({ + description: "Browser executable path. The world tool honors this setting only from global config.", + }), + use_system_chrome: Schema.optional(Schema.Boolean).annotate({ + description: + "Use the system-installed Google Chrome instead of the bundled Chromium. Falls back to bundled Chromium when Chrome is not found.", + }), + args: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ + description: "Additional browser arguments. The world tool honors this setting only from global config.", + }), + }), + ), + }), + ).annotate({ description: "Browser runtime settings for the world tool" }), + // kilocode_change end compaction: Schema.optional( Schema.Struct({ auto: Schema.optional(Schema.Boolean).annotate({ @@ -300,6 +329,10 @@ export const Info = Schema.Struct({ openTelemetry: Schema.Boolean.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(true))).annotate({ description: "Enable telemetry. Set to false to opt-out.", }), + world_browser: Schema.optional(Schema.Boolean).annotate({ + description: + "Enable the World browser tool. The browser runs headless by default and returns an inline image after every visual action. Disable to hide browser capabilities from the agent.", + }), // kilocode_change end primary_tools: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "Tools that should only be available to primary agents.", diff --git a/packages/kilo-world/src/client.ts b/packages/kilo-world/src/client.ts index 8e9a83038da..4dccad9d6b5 100644 --- a/packages/kilo-world/src/client.ts +++ b/packages/kilo-world/src/client.ts @@ -1,4 +1,4 @@ -export { defaultConfig, ensureHome, getConfig, setConfig } from "./config" +export { defaultConfig, ensureHome, getConfig, hasDisplay, setConfig } from "./config" export { DaemonClient } from "./daemon/client" export { parseScript } from "./script-parser" export { resolvePath } from "./path" diff --git a/packages/kilo-world/src/config.ts b/packages/kilo-world/src/config.ts index 0a6b06fd4ef..e972313db1b 100644 --- a/packages/kilo-world/src/config.ts +++ b/packages/kilo-world/src/config.ts @@ -7,12 +7,16 @@ const DEFAULT_HOME = join(process.env["XDG_STATE_HOME"] ?? join(homedir(), ".loc let cached: WorldConfig | null = null +export function hasDisplay(): boolean { + return ( + process.platform === "darwin" || + process.platform === "win32" || + Boolean(process.env["DISPLAY"] || process.env["WAYLAND_DISPLAY"]) + ) +} + export function defaultConfig(): WorldConfig { - const display = process.env["DISPLAY"] - const wayland = process.env["WAYLAND_DISPLAY"] - const desktop = process.platform === "darwin" || process.platform === "win32" - const displayServer = desktop || Boolean(display || wayland) - const headless = !displayServer || process.env["KILO_WORLD_HEADED"] !== "1" + const headless = !hasDisplay() || process.env["KILO_WORLD_HEADED"] !== "1" return { browser: { headless, diff --git a/packages/kilo-world/src/daemon/client.ts b/packages/kilo-world/src/daemon/client.ts index ba281e7eca0..0a1f8161aaa 100644 --- a/packages/kilo-world/src/daemon/client.ts +++ b/packages/kilo-world/src/daemon/client.ts @@ -482,7 +482,9 @@ function daemonConfig(cfg: WorldConfig): DaemonConfig { antiDetect: cfg.browser.antiDetect, timeoutMs: cfg.browser.timeoutMs, viewport: cfg.browser.viewport, + executablePath: cfg.browser.executablePath, useSystemChrome: cfg.browser.useSystemChrome ?? false, + args: [...cfg.browser.args], }, } } diff --git a/packages/kilo-world/src/daemon/protocol.ts b/packages/kilo-world/src/daemon/protocol.ts index 12e8c6ec223..843ee080316 100644 --- a/packages/kilo-world/src/daemon/protocol.ts +++ b/packages/kilo-world/src/daemon/protocol.ts @@ -86,11 +86,17 @@ function config(value: unknown): value is DaemonConfig { if (Object.keys(value).some((key) => key !== "browser")) return false if (value.browser === undefined) return true if (!record(value.browser)) return false - const keys = ["headless", "antiDetect", "timeoutMs", "viewport", "useSystemChrome"] + const keys = ["headless", "antiDetect", "timeoutMs", "viewport", "executablePath", "useSystemChrome", "args"] if (Object.keys(value.browser).some((key) => !keys.includes(key))) return false if (value.browser.headless !== undefined && typeof value.browser.headless !== "boolean") return false if (value.browser.antiDetect !== undefined && typeof value.browser.antiDetect !== "boolean") return false if (value.browser.useSystemChrome !== undefined && typeof value.browser.useSystemChrome !== "boolean") return false + if (value.browser.executablePath !== undefined && typeof value.browser.executablePath !== "string") return false + if ( + value.browser.args !== undefined && + (!Array.isArray(value.browser.args) || !value.browser.args.every((item) => typeof item === "string")) + ) + return false if (value.browser.timeoutMs !== undefined && typeof value.browser.timeoutMs !== "number") return false if (value.browser.viewport === undefined) return true if (!record(value.browser.viewport)) return false diff --git a/packages/kilo-world/src/index.ts b/packages/kilo-world/src/index.ts index 61b240c3516..77c1dc6f98c 100644 --- a/packages/kilo-world/src/index.ts +++ b/packages/kilo-world/src/index.ts @@ -1,10 +1,10 @@ -import { defaultConfig, ensureHome, getConfig, setConfig } from "./config" +import { defaultConfig, ensureHome, getConfig, hasDisplay, setConfig } from "./config" import { DaemonClient } from "./daemon/client" import { dispatch } from "./daemon/dispatch" import { parseScript as parseScriptFn } from "./script-parser" import type { Action, RunOptions, RunResult, WorldConfig, WorldConfigPatch } from "./types" -export { defaultConfig, ensureHome, getConfig, setConfig } +export { defaultConfig, ensureHome, getConfig, hasDisplay, setConfig } export { DaemonClient } export { parseScript } from "./script-parser" export { resolvePath } from "./path" diff --git a/packages/kilo-world/src/types.ts b/packages/kilo-world/src/types.ts index 66a8f85ec66..c327566537b 100644 --- a/packages/kilo-world/src/types.ts +++ b/packages/kilo-world/src/types.ts @@ -85,7 +85,10 @@ export type WorldConfigPatch = Omit, "browser"> & { } export type DaemonConfig = { - browser?: Pick + browser?: Pick< + BrowserConfig, + "headless" | "antiDetect" | "timeoutMs" | "viewport" | "executablePath" | "useSystemChrome" | "args" + > } export type Action = { diff --git a/packages/kilo-world/test/daemon.test.ts b/packages/kilo-world/test/daemon.test.ts index 59d9991bc63..f15e00bec76 100644 --- a/packages/kilo-world/test/daemon.test.ts +++ b/packages/kilo-world/test/daemon.test.ts @@ -83,7 +83,7 @@ describe("world daemon", () => { verb: "status", args: [], auth: handshake!.token, - config: { browser: { executablePath: "/tmp/browser" } }, + config: { browser: { unknown: true } }, }), }) expect(unsafe.status).toBe(400) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index ea7b37fb09d..f27dd1b6687 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -104,6 +104,7 @@ "@kilocode/plugin-atomic-chat": "workspace:*", "@kilocode/sandbox": "workspace:*", "@kilocode/sdk": "workspace:*", + "@kilocode/world": "workspace:*", "@lydell/node-pty": "catalog:", "@modelcontextprotocol/sdk": "1.29.0", "@morphllm/morphsdk": "0.2.166", diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 38e43fdcc98..06dc32eebc9 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -24,6 +24,7 @@ import { stageBubblewrap } from "./kilocode/bubblewrap" import { LanceDBRuntime } from "../src/kilocode/lancedb" import { KiloSandboxWorker } from "./kilocode/kilo-sandbox-worker" import { KiloSandboxNetwork } from "./kilocode/kilo-sandbox-network" +import { WorldDaemon } from "../../kilo-world/script/daemon" // kilocode_change end const singleFlag = process.argv.includes("--single") @@ -222,6 +223,7 @@ await $`rm -rf dist` const kiloConsoleDist = await buildKiloConsole() const kiloSandboxWorker = await KiloSandboxWorker.bundle() const kiloSandboxNetwork = await KiloSandboxNetwork.bundle() +const worldDaemon = await WorldDaemon.bundle() // kilocode_change end const binaries: Record = {} @@ -326,6 +328,7 @@ for (const item of targets) { await copyTreeSitterWasms(path.resolve(dir, `dist/${name}/bin`)) await copyKiloConsole(kiloConsoleDist, path.resolve(dir, `dist/${name}/bin`)) await KiloSandboxWorker.copy(kiloSandboxWorker, path.resolve(dir, `dist/${name}/bin`)) + await WorldDaemon.copy(worldDaemon, path.resolve(dir, `dist/${name}/bin`)) if (item.os === "linux") { await KiloSandboxNetwork.copy(kiloSandboxNetwork, path.resolve(dir, `dist/${name}/bin`), item.arch) } @@ -363,6 +366,8 @@ for (const item of targets) { console.log("Models snapshot smoke test passed") await KiloSandboxWorker.smoke(binaryPath) console.log("Kilo sandbox mutation worker smoke test passed") + await WorldDaemon.smoke(path.resolve(dir, `dist/${name}/bin/${WorldDaemon.filename}`)) + console.log("Kilo world Node daemon smoke test passed") // kilocode_change end // kilocode_change start } catch (e) { diff --git a/packages/opencode/src/kilocode/plugins/internal.ts b/packages/opencode/src/kilocode/plugins/internal.ts index 9d788771abd..41d3e7b08cb 100644 --- a/packages/opencode/src/kilocode/plugins/internal.ts +++ b/packages/opencode/src/kilocode/plugins/internal.ts @@ -16,6 +16,7 @@ import Remote from "@/kilocode/plugins/remote" import Reload from "@/kilocode/plugins/reload" import SessionSwitcher from "@/kilocode/plugins/session-switcher" import SessionV2Debug from "@/kilocode/plugins/session-v2-debug" +import WorldPalette from "@/kilocode/plugins/world-palette" import type { RuntimeFlags } from "@/effect/runtime-flags" const plugins = [ @@ -34,6 +35,7 @@ const plugins = [ Sandbox, Remote, Reload, + WorldPalette, ] satisfies BuiltinTuiPlugin[] export function withKiloTuiPlugins( diff --git a/packages/opencode/src/kilocode/plugins/world-palette.tsx b/packages/opencode/src/kilocode/plugins/world-palette.tsx new file mode 100644 index 00000000000..b8303ae4b0b --- /dev/null +++ b/packages/opencode/src/kilocode/plugins/world-palette.tsx @@ -0,0 +1,115 @@ +import type { Config } from "@kilocode/sdk/v2" +import type { TuiPlugin } from "@kilocode/plugin/tui" +import { defaultConfig, hasDisplay } from "@kilocode/world/client" +import type { InternalTuiPlugin } from "@/plugin/tui/internal" +import { errorMessage } from "@/util/error" + +const id = "internal:kilo-world-palette" + +type Option = { + name: string + slash: string + fallback: boolean + read: (cfg: Config) => boolean | undefined + patch: (value: boolean) => Config + title: string + toast: (value: boolean) => string + error: string + hidden?: boolean + check?: (value: boolean) => string | undefined +} + +const tui: TuiPlugin = async (api) => { + const command = (opt: Option) => ({ + namespace: "palette", + name: opt.name, + slashName: opt.slash, + category: "System", + get hidden() { + return opt.hidden && api.state.config.experimental?.world_browser === false + }, + title: opt.title, + async run() { + try { + const response = await api.client.global.config.get({}) + if (response.error) { + api.ui.toast({ + message: `${opt.error}: ${errorMessage(response.error)}`, + variant: "error", + duration: 5000, + }) + return + } + const current = opt.read(response.data ?? {}) ?? opt.fallback + const next = !current + const issue = opt.check?.(next) + if (issue) { + api.ui.toast({ message: issue, variant: "error", duration: 5000 }) + return + } + const result = await api.client.global.config.update({ config: opt.patch(next) }) + if (result.error) { + api.ui.toast({ + message: `${opt.error}: ${errorMessage(result.error)}`, + variant: "error", + duration: 5000, + }) + return + } + api.ui.toast({ message: opt.toast(next), variant: "success" }) + api.ui.dialog.clear() + } catch (err) { + api.ui.toast({ message: `${opt.error}: ${errorMessage(err)}`, variant: "error", duration: 5000 }) + } + }, + }) + + api.keymap.registerLayer({ + commands: [ + command({ + name: "world.toggle", + slash: "world", + fallback: true, + read: (cfg) => cfg.experimental?.world_browser, + patch: (value) => ({ experimental: { world_browser: value } }), + title: "Toggle Global World (Browser Use)", + toast: (value) => `Global World preference set to ${value ? "enabled" : "disabled"}`, + error: "Failed to toggle World", + }), + command({ + name: "world.headless", + slash: "world-headless", + fallback: defaultConfig().browser.headless, + read: (cfg) => cfg.world?.browser?.headless, + patch: (value) => ({ world: { browser: { headless: value } } }), + title: "Toggle Global World Headless Mode", + toast: (value) => + `Global World browser preference set to ${value ? "headless" : "a visible window"}; project configuration takes precedence when present`, + error: "Failed to toggle World browser mode", + hidden: true, + check: (value) => + !value && !hasDisplay() ? "Cannot show the World browser because no display server is available" : undefined, + }), + command({ + name: "world.chrome", + slash: "world-chrome", + fallback: false, + read: (cfg) => cfg.world?.browser?.use_system_chrome, + patch: (value) => ({ world: { browser: { use_system_chrome: value } } }), + title: "Toggle Global World Browser Source", + toast: (value) => + `Global World browser preference set to ${value ? "prefer system Chrome when installed" : "bundled Chromium"}; project configuration takes precedence when present`, + error: "Failed to change World browser", + hidden: true, + }), + ], + bindings: [], + }) +} + +const plugin: InternalTuiPlugin = { + id, + tui, +} + +export default plugin diff --git a/packages/opencode/src/kilocode/sandbox/network-tools.ts b/packages/opencode/src/kilocode/sandbox/network-tools.ts index ad2caa9dda4..7bde68bbd6a 100644 --- a/packages/opencode/src/kilocode/sandbox/network-tools.ts +++ b/packages/opencode/src/kilocode/sandbox/network-tools.ts @@ -16,4 +16,5 @@ export const host = [ { id: "interactive_terminal", file: "kilocode/tool/interactive-terminal.ts" }, { id: "notebook_execute", file: "kilocode/tool/notebook-host.ts" }, { id: "background_process", file: "kilocode/tool/background-process.ts" }, + { id: "world", file: "kilocode/tool/world.ts" }, ] as const diff --git a/packages/opencode/src/kilocode/tool/registry.ts b/packages/opencode/src/kilocode/tool/registry.ts index bb86481a6d1..8efd0c011c2 100644 --- a/packages/opencode/src/kilocode/tool/registry.ts +++ b/packages/opencode/src/kilocode/tool/registry.ts @@ -11,6 +11,7 @@ import { MemoryRecallTool } from "./memory-recall" import { MemorySaveTool } from "./memory-save" import { NotifyUserTool } from "./notify-user" import { SendFileTool } from "./send-file" +import { WorldTool } from "./world" import * as Tool from "../../tool/tool" import { Flag } from "@opencode-ai/core/flag/flag" import { Effect } from "effect" @@ -84,14 +85,44 @@ export namespace KiloToolRegistry { const sessions = yield* KiloSessions.Service const notify = yield* NotifyUserTool.pipe(Effect.provideService(KiloSessions.Service, sessions)) const send = yield* SendFileTool + const world = yield* WorldTool if (!notebook) - return { codebase, recall, managerModels, memory, save, manager, process, chart, image, terminal, notify, send } + return { + codebase, + recall, + managerModels, + memory, + save, + manager, + process, + chart, + image, + terminal, + notify, + send, + world, + } const tools = yield* Effect.all({ notebookRead: NotebookReadTool, notebookEdit: NotebookEditTool, notebookExecute: NotebookExecuteTool, }).pipe(Effect.provideService(Notebook.Service, notebook)) - return { codebase, recall, managerModels, memory, save, manager, process, chart, image, terminal, notify, send, ...tools } + return { + codebase, + recall, + managerModels, + memory, + save, + manager, + process, + chart, + image, + terminal, + notify, + send, + world, + ...tools, + } }) } @@ -111,6 +142,7 @@ export namespace KiloToolRegistry { terminal?: Tool.Info notify: Tool.Info send: Tool.Info + world: Tool.Info notebookRead?: Tool.Info notebookEdit?: Tool.Info notebookExecute?: Tool.Info @@ -131,6 +163,7 @@ export namespace KiloToolRegistry { image: Tool.init(tools.image), notify: Tool.init(tools.notify), send: Tool.init(tools.send), + world: Tool.init(tools.world), }) const terminal = tools.terminal ? yield* Tool.init(tools.terminal) : undefined const notebooks = @@ -207,11 +240,19 @@ export namespace KiloToolRegistry { terminal?: Tool.Def notify: Tool.Def send: Tool.Def + world: Tool.Def notebookRead?: Tool.Def notebookEdit?: Tool.Def notebookExecute?: Tool.Def }, - cfg: { experimental?: { codebase_search?: boolean; image_generation?: boolean; native_notebook_tools?: boolean } }, + cfg: { + experimental?: { + codebase_search?: boolean + image_generation?: boolean + native_notebook_tools?: boolean + world_browser?: boolean + } + }, ): Tool.Def[] { return [ ...(cfg.experimental?.codebase_search === true ? [tools.codebase] : []), @@ -223,6 +264,7 @@ export namespace KiloToolRegistry { ...(Flag.KILO_CLIENT === "vscode" ? [tools.chart] : []), ...(Flag.KILO_CLIENT === "cli" || Flag.KILO_CLIENT === "vscode" ? [tools.process] : []), ...(Flag.KILO_CLIENT === "cli" && tools.terminal ? [tools.terminal] : []), + ...(Flag.KILO_CLIENT === "cli" && cfg.experimental?.world_browser !== false ? [tools.world] : []), ...(Flag.KILO_CLIENT === "vscode" ? [tools.managerModels, tools.manager] : []), ...(Flag.KILO_CLIENT === "vscode" && cfg.experimental?.native_notebook_tools === true && diff --git a/packages/opencode/src/kilocode/tool/world-config.ts b/packages/opencode/src/kilocode/tool/world-config.ts new file mode 100644 index 00000000000..e08ebe34f48 --- /dev/null +++ b/packages/opencode/src/kilocode/tool/world-config.ts @@ -0,0 +1,36 @@ +import { defaultConfig } from "@kilocode/world/client" +import type { WorldConfig } from "@kilocode/world/types" + +export type Input = { + world?: { + browser?: { + headless?: boolean + anti_detect?: boolean + timeout_ms?: number + viewport?: { width: number; height: number } + executable_path?: string + use_system_chrome?: boolean + args?: string[] + } + } +} + +export function resolve(cfg: Input, trusted: Input = {}): WorldConfig { + const base = defaultConfig() + const browser = cfg.world?.browser + const global = trusted.world?.browser + if (!browser && !global) return base + return { + browser: { + ...base.browser, + ...(browser?.headless !== undefined ? { headless: browser.headless } : {}), + ...(browser?.anti_detect !== undefined ? { antiDetect: browser.anti_detect } : {}), + ...(browser?.timeout_ms !== undefined ? { timeoutMs: browser.timeout_ms } : {}), + ...(browser?.viewport ? { viewport: browser.viewport } : {}), + ...(global?.args ? { args: [...global.args] } : {}), + ...(global?.executable_path ? { executablePath: global.executable_path } : {}), + ...(browser?.use_system_chrome !== undefined ? { useSystemChrome: browser.use_system_chrome } : {}), + }, + home: base.home, + } +} diff --git a/packages/opencode/src/kilocode/tool/world-script.ts b/packages/opencode/src/kilocode/tool/world-script.ts new file mode 100644 index 00000000000..976f2b39142 --- /dev/null +++ b/packages/opencode/src/kilocode/tool/world-script.ts @@ -0,0 +1,45 @@ +import { parseScript, resolvePath } from "@kilocode/world/client" +import type { Action } from "@kilocode/world/types" + +export type WorldScript = { + actions: Action[] + urls: string[] + reads: string[] + writes: string[] + evaluates: boolean +} + +export function inspect(script: string, dir: string): WorldScript { + const actions = parseScript(script) + const urls: string[] = [] + const reads: string[] = [] + const writes: string[] = [] + const evaluates = actions.some((action) => action.verb === "evaluate") + for (const action of actions) { + if (flag(action, "--session") !== undefined) throw new Error("--session is reserved for Kilo") + const url = flag(action, "--url") + if (url && (action.verb === "navigate" || (action.verb === "tabs" && action.args[0] === "open"))) { + urls.push(url) + } + const input = action.verb === "evaluate" ? flag(action, "--js-file") : undefined + if (input) reads.push(resolvePath(input, dir)) + const output = action.verb === "screenshot" ? flag(action, "--out") : undefined + if (output) writes.push(resolvePath(output, dir)) + } + return { + actions, + urls: [...new Set(urls)], + reads: [...new Set(reads)], + writes: [...new Set(writes)], + evaluates, + } +} + +function flag(action: Action, name: string): string | undefined { + for (let i = 0; i < action.args.length; i++) { + const value = action.args[i] + if (value === name) return action.args[i + 1] + if (value?.startsWith(`${name}=`)) return value.slice(name.length + 1) + } + return undefined +} diff --git a/packages/opencode/src/kilocode/tool/world.ts b/packages/opencode/src/kilocode/tool/world.ts new file mode 100644 index 00000000000..fd9764e641a --- /dev/null +++ b/packages/opencode/src/kilocode/tool/world.ts @@ -0,0 +1,336 @@ +// kilocode_change - new file +import { Effect, Schema } from "effect" +import * as path from "path" +import { pathToFileURL } from "node:url" +import { Tool } from "../../tool/tool" +import { Config } from "@/config/config" +import { InstanceState } from "@/effect/instance-state" +import { assertExternalDirectoryEffect } from "../../tool/external-directory" +import { DaemonClient } from "@kilocode/world/client" +import type { ActionResult, RunResult } from "@kilocode/world/types" +import * as Log from "@opencode-ai/core/util/log" +import { inspect } from "./world-script" +import type { WorldConfig } from "@kilocode/world/types" +import { resolve } from "./world-config" + +const log = Log.create({ service: "kilocode-tool-world" }) +const MAX_TIMEOUT = 10 * 60_000 +const MAX_INLINE_BYTES = 1_000_000 +const MAX_SCREENSHOTS = 20 +const MAX_FILES = 200 +const MAX_AGE = 7 * 24 * 60 * 60_000 + +const DESCRIPTION = `Drive a Chromium browser to render pages, run JavaScript, click through UI, and capture screenshots. State persists across calls in a per-session daemon. + +Proactively use this to verify UI changes (component, layout, style) without being asked by navigating to the page and confirming the result. + +Scripts are ;-separated verb calls. Prefer CSS selectors (\`#foo\`, \`input#bar\`) over role selectors — they pierce shadow DOM and survive across snapshots. + +Verb grammar: + Common: \`--ref \` from snapshot (preferred) or \`--selector \`. Values with spaces or \`;\` need quotes. + status - capability, sessions, Chromium installation state + navigate --url [--wait ] [--timeout ] + snapshot - DOM walk with stable [ref=eN] ids and CSS selectors; prefer over screenshot + click | type --text | fill --value [--force] | hover | scroll --dx N --dy N - each takes --ref or --selector + press-key --chord "" (e.g. "Control+a") + drag --from --to + wait-for --selector | --text | --url [--timeout ] + screenshot --out [--full] [--wait ] [--type png|jpeg] [--quality 50-100] + evaluate --js | --js-file + tabs list | open --url | select --index | close + cookies get --domain D | set --name N --value V --domain D | clear [--domain D] + shutdown - close browser; daemon stays alive + daemon.start --idle | daemon.status | daemon.stop + +Example: + world({ script: "navigate --url http://localhost:3000/settings ; snapshot ; screenshot --out /tmp/check.png" }) +` + +const Params = Schema.Struct({ + script: Schema.String.annotate({ + description: "A ;-separated sequence of browser actions. Example: `navigate --url https://example.com ; snapshot`.", + }), + timeout: Schema.optional(Schema.Number).annotate({ + description: "Overall timeout in milliseconds. Defaults to 60_000 and is capped at 600_000.", + }), +}) +export type Params = Schema.Schema.Type + +type Meta = { + title: string + ok: boolean + durationMs: number + actions: number + failedAt?: number + daemonStarted?: boolean +} + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function summarize(r: ActionResult): string { + if (!r.ok) return ` → error: ${r.error ?? "unknown"} (${r.durationMs}ms)` + const data = record(r.data) ? r.data : undefined + if (!data) return ` → ok (${r.durationMs}ms)` + if (r.verb === "navigate") { + const u = typeof data["finalUrl"] === "string" ? data["finalUrl"] : "" + return ` → ${u} (${r.durationMs}ms)` + } + if (r.verb === "snapshot") { + return ` → ${renderRefList(r.refs, 8)}` + } + if (r.verb === "screenshot") { + return ` → ${r.screenshot?.bytes ?? 0} bytes (${r.durationMs}ms)` + } + if (r.verb === "evaluate") { + return ` → ${JSON.stringify(data["result"])} (${r.durationMs}ms)` + } + return ` → ${JSON.stringify(data).slice(0, 200)} (${r.durationMs}ms)` +} + +function title(result: RunResult, failed?: ActionResult): string { + if (failed) return `world ${failed.verb} failed` + if (result.results.length === 1) return `world ${result.results[0].verb}` + return `world ${result.results.length} actions` +} + +function renderRefList( + refs: Array<{ ref: string; role: string; name: string; selector?: string }> | undefined, + limit: number, +): string { + if (!refs || refs.length === 0) return "0 refs" + const lines: string[] = [`${refs.length} refs:`] + for (const r of refs.slice(0, limit)) { + const name = (r.name ?? "").slice(0, 60).replace(/\s+/g, " ").trim() + const sel = r.selector ?? "" + lines.push(` ${r.ref} [${r.role}] sel=${sel}${name ? ` name=${JSON.stringify(name)}` : ""}`) + } + if (refs.length > limit) lines.push(` … (${refs.length - limit} more)`) + return lines.join("\n") +} + +async function attachmentForResult( + r: ActionResult, + directory: string, +): Promise<{ type: "file"; mime: string; url: string; filename: string } | undefined> { + if (!r.screenshot) return undefined + const abs = path.isAbsolute(r.screenshot.path) ? r.screenshot.path : path.join(directory, r.screenshot.path) + const mime = r.screenshot.mime ?? "image/png" + const data = await readInlineData(abs, mime) + if (data) return { type: "file", mime, url: data, filename: path.basename(abs) } + return { type: "file", mime, url: pathToFileURL(abs).href, filename: path.basename(abs) } +} + +async function formatResult( + result: RunResult, + directory: string, +): Promise<{ + output: string + attachments: Array<{ type: "file"; mime: string; url: string; filename: string }> +}> { + const lines: string[] = [] + for (const [i, r] of result.results.entries()) { + const summary = summarize(r) + lines.push(`[${i + 1}/${result.results.length}] ${r.verb}${summary.includes("\n") ? "\n" + summary : summary}`) + } + const shot = result.results.findLast((item) => item.screenshot) + const att = shot ? await attachmentForResult(shot, directory) : undefined + const attachments = att ? [att] : [] + return { output: lines.join("\n"), attachments } +} + +async function readInlineData(abs: string, mime: string = "image/png"): Promise { + try { + const file = Bun.file(abs) + if (!(await file.exists()) || file.size > MAX_INLINE_BYTES) return null + const buf = await file.bytes() + return `data:${mime};base64,${Buffer.from(buf).toString("base64")}` + } catch { + return null + } +} + +async function prune(dir: string, prefix: string): Promise { + const files: Array<{ path: string; modified: number }> = [] + try { + for await (const file of new Bun.Glob("*.jpg").scan({ cwd: dir, absolute: true })) { + files.push({ path: file, modified: Bun.file(file).lastModified }) + } + } catch (err) { + if (err instanceof Error && "code" in err && err.code === "ENOENT") return + throw err + } + files.sort((a, b) => b.modified - a.modified) + const now = Date.now() + const session = files.filter((item) => path.basename(item.path).startsWith(prefix)) + const remove = new Set([ + ...files.filter((item) => now - item.modified > MAX_AGE).map((item) => item.path), + ...files.slice(MAX_FILES - 1).map((item) => item.path), + ...session.slice(MAX_SCREENSHOTS - 1).map((item) => item.path), + ]) + await Promise.all( + [...remove].map((file) => + Bun.file(file) + .delete() + .catch((err) => { + if (err instanceof Error && "code" in err && err.code === "ENOENT") return + throw err + }), + ), + ) +} + +function screenshotPath(config: WorldConfig, session: string, call: string | undefined): string { + const name = `${prefix(session)}${call ?? Date.now()}`.replace(/[^a-zA-Z0-9_.-]/g, "_") + return path.join(config.home, "screenshots", `${name}.jpg`) +} + +function prefix(session: string): string { + return `${session.replace(/[^a-zA-Z0-9_.-]/g, "_")}-` +} + +export const WorldTool = Tool.define( + "world", + Effect.gen(function* () { + const configs = yield* Config.Service + return { + description: DESCRIPTION, + parameters: Params, + execute: (params: Params, ctx: Tool.Context) => + Effect.gen(function* () { + const inst = yield* InstanceState.context + const config = resolve(yield* configs.get(), yield* configs.getGlobal()) + const script = inspect(params.script, inst.directory) + + yield* ctx.ask({ + permission: "world", + patterns: ["browser"], + always: ["browser"], + metadata: { script: params.script }, + }) + if (script.evaluates) { + yield* ctx.ask({ + permission: "world", + patterns: ["evaluate"], + always: ["evaluate"], + metadata: { script: params.script }, + }) + } + for (const url of script.urls) { + if (!url.startsWith("http://") && !url.startsWith("https://")) { + throw new Error(`URL must start with http:// or https://: ${url}`) + } + yield* ctx.ask({ + permission: "webfetch", + patterns: [url], + always: ["*"], + metadata: { script: params.script }, + }) + } + for (const file of script.reads) { + yield* assertExternalDirectoryEffect(ctx, file) + yield* ctx.ask({ + permission: "read", + patterns: [path.relative(inst.worktree, file)], + always: ["*"], + metadata: { filepath: file }, + }) + } + for (const file of script.writes) { + yield* assertExternalDirectoryEffect(ctx, file) + yield* ctx.ask({ + permission: "edit", + patterns: [path.relative(inst.worktree, file)], + always: ["*"], + metadata: { filepath: file }, + }) + } + + const wasRunning = DaemonClient.isRunning(ctx.sessionID) + const requested = params.timeout ?? 60_000 + if (!Number.isFinite(requested) || requested <= 0) throw new Error("timeout must be a positive finite number") + const timeout = Math.min(requested, MAX_TIMEOUT) + const controller = new AbortController() + const onAbort = () => controller.abort() + ctx.abort.addEventListener("abort", onAbort, { once: true }) + if (ctx.abort.aborted) controller.abort() + const timer = setTimeout(() => controller.abort(), timeout) + const run = yield* Effect.promise(() => + DaemonClient.runViaSession(ctx.sessionID, params.script, { + signal: controller.signal, + timeoutMs: timeout, + directory: inst.directory, + paths: [...script.reads, ...script.writes], + config, + }).finally(() => { + clearTimeout(timer) + ctx.abort.removeEventListener("abort", onAbort) + }), + ) + + const failedIdx = run.results.findIndex((r) => !r.ok) + const failed = failedIdx >= 0 ? run.results[failedIdx] : undefined + const name = title(run, failed) + const meta: Meta = { + title: name, + ok: run.ok, + durationMs: run.durationMs, + actions: run.results.length, + daemonStarted: !wasRunning, + ...(failedIdx >= 0 ? { failedAt: failedIdx } : {}), + } + + const formatted = yield* Effect.promise(() => formatResult(run, inst.directory)) + + const lastResult = run.results[run.results.length - 1] + const visual = + lastResult && + !["status", "close", "shutdown", "daemon.start", "daemon.status", "daemon.stop"].includes(lastResult.verb) + if (run.ok && visual && !lastResult.screenshot) { + const out = screenshotPath(config, ctx.sessionID, ctx.callID) + const auto = yield* Effect.promise(async () => { + await prune(path.dirname(out), prefix(ctx.sessionID)) + return DaemonClient.runViaSession( + ctx.sessionID, + `screenshot --out ${JSON.stringify(out)} --type jpeg --quality 80`, + { + signal: ctx.abort, + timeoutMs: 15_000, + directory: inst.directory, + paths: [out], + config, + }, + ) + }).pipe( + Effect.catchCause((cause) => + Effect.sync(() => { + log.warn("auto-screenshot failed", { cause: String(cause) }) + return undefined + }), + ), + ) + if (auto) { + const shot = auto.results[0] + if (shot?.screenshot) { + const att = yield* Effect.promise(() => attachmentForResult(shot, inst.directory)) + if (att) { + formatted.attachments.splice(0, formatted.attachments.length, att) + formatted.output += `\n[auto-screenshot] ${shot.screenshot.bytes} bytes` + } + } + } + } + + return { + title: name, + output: run.ok + ? formatted.output + : `${formatted.output}\n\nScript failed at action ${failedIdx + 1}: ${failed?.error ?? "unknown"}`, + metadata: meta, + ...(formatted.attachments.length > 0 ? { attachments: formatted.attachments } : {}), + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/test/kilocode/chart-tool-gating.test.ts b/packages/opencode/test/kilocode/chart-tool-gating.test.ts index b6eb0862802..8665273607b 100644 --- a/packages/opencode/test/kilocode/chart-tool-gating.test.ts +++ b/packages/opencode/test/kilocode/chart-tool-gating.test.ts @@ -17,6 +17,7 @@ const tools = { image: stub("image"), notify: stub("notify"), send: stub("send_file"), + world: stub("world"), } function ids(client: string) { diff --git a/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts b/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts index 900be12330f..517be0b6338 100644 --- a/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-indexing-import-failure.test.ts @@ -46,6 +46,7 @@ function infos() { image: info("generate_image"), notify: info("notify_user"), send: info("send_file"), + world: info("world"), notebookRead: info("notebook_read"), notebookEdit: info("notebook_edit"), notebookExecute: info("notebook_execute"), diff --git a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts index db247c7c63a..b80175b601e 100644 --- a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts @@ -344,6 +344,7 @@ describe("kilocode tool registry indexing", () => { terminal: def("interactive_terminal"), notify: def("notify_user"), send: def("send_file"), + world: def("world"), notebookRead: def("notebook_read"), notebookEdit: def("notebook_edit"), notebookExecute: def("notebook_execute"), @@ -358,6 +359,7 @@ describe("kilocode tool registry indexing", () => { "recall", "background_process", "interactive_terminal", + "world", "notify_user", "send_file", ]) @@ -370,6 +372,7 @@ describe("kilocode tool registry indexing", () => { "recall", "background_process", "interactive_terminal", + "world", "notify_user", "send_file", ], @@ -387,6 +390,7 @@ describe("kilocode tool registry indexing", () => { "recall", "background_process", "interactive_terminal", + "world", "notify_user", "send_file", ]) diff --git a/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts b/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts index 16bffc77f3a..355e556030a 100644 --- a/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-semantic-import-failure.test.ts @@ -58,6 +58,7 @@ function infos() { image: info("generate_image"), notify: info("notify_user"), send: info("send_file"), + world: info("world"), notebookRead: info("notebook_read"), notebookEdit: info("notebook_edit"), notebookExecute: info("notebook_execute"), diff --git a/packages/opencode/test/kilocode/tool/send-file.test.ts b/packages/opencode/test/kilocode/tool/send-file.test.ts index 6afb16046c2..238a3d2a1ae 100644 --- a/packages/opencode/test/kilocode/tool/send-file.test.ts +++ b/packages/opencode/test/kilocode/tool/send-file.test.ts @@ -464,6 +464,7 @@ describe("send_file tool", () => { image: tool, notify: { id: "notify_user" } as Tool.Def, send: tool, + world: tool, }, {}, ) diff --git a/packages/opencode/test/kilocode/world-config.test.ts b/packages/opencode/test/kilocode/world-config.test.ts new file mode 100644 index 00000000000..5ec9c135863 --- /dev/null +++ b/packages/opencode/test/kilocode/world-config.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test" +import { defaultConfig } from "@kilocode/world/client" +import { resolve } from "../../src/kilocode/tool/world-config" + +describe("world tool config", () => { + test("does not inherit browser settings from a previous project", () => { + const base = defaultConfig() + const first = resolve({ + world: { + browser: { + headless: !base.browser.headless, + use_system_chrome: true, + args: ["--disable-notifications"], + }, + }, + }) + const second = resolve({}) + + expect(first.browser).toMatchObject({ + headless: !base.browser.headless, + useSystemChrome: true, + args: base.browser.args, + }) + expect(second).toEqual(base) + }) + + test("accepts executable settings only from global config", () => { + const project = { world: { browser: { executable_path: "/tmp/project", args: ["--project"] } } } + const global = { world: { browser: { executable_path: "/tmp/global", args: ["--global"] } } } + + expect(resolve(project).browser).toMatchObject({ args: [] }) + expect(resolve(project, global).browser).toMatchObject({ executablePath: "/tmp/global", args: ["--global"] }) + }) + + test("fills partial project settings from defaults", () => { + const base = defaultConfig() + const config = resolve({ world: { browser: { timeout_ms: 12_345 } } }) + + expect(config.browser).toEqual({ + ...base.browser, + timeoutMs: 12_345, + }) + }) +}) diff --git a/packages/opencode/test/kilocode/world-script.test.ts b/packages/opencode/test/kilocode/world-script.test.ts new file mode 100644 index 00000000000..262abbd4ce7 --- /dev/null +++ b/packages/opencode/test/kilocode/world-script.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test" +import { homedir } from "node:os" +import path from "node:path" +import { inspect } from "../../src/kilocode/tool/world-script" + +describe("world script inspection", () => { + test("finds every navigated URL and deduplicates it", () => { + const script = inspect( + "navigate --url=https://example.com ; tabs open --url https://kilo.ai ; navigate --url https://example.com", + "/workspace", + ) + expect(script.urls).toEqual(["https://example.com", "https://kilo.ai"]) + expect(script.evaluates).toBe(false) + }) + + test("resolves file reads and writes against the active directory", () => { + const root = path.resolve("/workspace/project") + const script = inspect( + "evaluate --js-file scripts/check.js ; screenshot --out=artifacts/page.png ; screenshot --out /tmp/final.png", + root, + ) + expect(script.reads).toEqual([path.join(root, "scripts/check.js")]) + expect(script.writes).toEqual([path.join(root, "artifacts/page.png"), path.normalize("/tmp/final.png")]) + }) + + test("expands home-relative paths before requesting permission", () => { + const script = inspect("screenshot --out ~/screenshots/page.png", "/workspace") + expect(script.writes).toEqual([path.join(homedir(), "screenshots/page.png")]) + }) + + test("expands environment paths before requesting permission", () => { + const key = "KILO_WORLD_TEST_OUTPUT" + const before = process.env[key] + process.env[key] = path.join(homedir(), "screenshots") + try { + const script = inspect(`screenshot --out %${key}%/page.png`, "/workspace") + expect(script.writes).toEqual([path.join(homedir(), "screenshots/page.png")]) + } finally { + if (before === undefined) delete process.env[key] + else process.env[key] = before + } + }) + + test("rejects agent-controlled session selection", () => { + expect(() => inspect("status --session another-session", "/workspace")).toThrow("--session is reserved for Kilo") + }) + + test("identifies scripts that execute JavaScript", () => { + expect(inspect('evaluate --js "document.title"', "/workspace").evaluates).toBe(true) + }) + + test("does not treat unrelated --url and --out flags as capabilities", () => { + const script = inspect("wait-for --url https://example.com ; evaluate --out secret.txt --js 1", "/workspace") + expect(script.urls).toEqual([]) + expect(script.writes).toEqual([]) + }) +}) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index f234bbed584..ddc159b5afe 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1723,6 +1723,20 @@ export type Config = { max_lines?: number max_bytes?: number } + world?: { + browser?: { + headless?: boolean + anti_detect?: boolean + timeout_ms?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + viewport?: { + width: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + height: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + executable_path?: string + use_system_chrome?: boolean + args?: Array + } + } compaction?: { auto?: boolean /** @@ -1744,6 +1758,7 @@ export type Config = { native_notebook_tools?: boolean speech_to_text_model?: string openTelemetry?: boolean + world_browser?: boolean primary_tools?: Array continue_loop_on_deny?: boolean sandbox?: boolean diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 71ff553fff6..973f587840c 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -29635,6 +29635,114 @@ }, "additionalProperties": false }, + "world": { + "type": "object", + "properties": { + "browser": { + "type": "object", + "properties": { + "headless": { + "type": "boolean" + }, + "anti_detect": { + "type": "boolean" + }, + "timeout_ms": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "viewport": { + "type": "object", + "properties": { + "width": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "height": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["width", "height"], + "additionalProperties": false + }, + "executable_path": { + "type": "string" + }, + "use_system_chrome": { + "type": "boolean", + "description": "Use the system-installed Google Chrome instead of the bundled Chromium. Falls back to bundled Chromium when Chrome is not found." + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "description": "Browser runtime settings for the world tool" + }, "compaction": { "type": "object", "properties": { @@ -29713,6 +29821,10 @@ "openTelemetry": { "type": "boolean" }, + "world_browser": { + "type": "boolean", + "description": "Enable the World browser tool. The browser runs headless by default and returns an inline image after every visual action. Disable to hide browser capabilities from the agent." + }, "primary_tools": { "type": "array", "items": {