From f607ab8d1880f229d00705429056c20e878d843a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Tue, 28 Jul 2026 13:10:40 -0300 Subject: [PATCH 1/3] chore: lint shared/ folder --- eslint.config.js | 17 ++++ package.json | 4 +- shared/agent-core/anthropic-model.test.ts | 12 +-- shared/agent-core/anthropic-model.ts | 36 ++++---- .../browser-env/browser-execution-env.test.ts | 22 ++--- .../browser-env/browser-execution-env.ts | 68 +++++++-------- .../coding-tool-operations.test.ts | 28 +++--- .../agent-core/browser-env/fs-helpers.test.ts | 4 +- shared/agent-core/browser-env/fs-helpers.ts | 14 +-- shared/agent-core/browser-env/mount.test.ts | 2 +- .../browser-env/workspace-jail.test.ts | 22 ++--- .../browser-env/zen-bash-fs.test.ts | 6 +- shared/agent-core/build-app-harness.ts | 8 +- shared/agent-core/coding-tools/index.test.ts | 86 +++++++++---------- shared/agent-core/coding-tools/index.ts | 20 +++-- .../agent-core/coding-tools/truncate.test.ts | 16 ++-- shared/agent-core/coding-tools/truncate.ts | 16 ++-- shared/agent-core/environment-prompt.test.ts | 10 +-- shared/agent-core/environment-prompt.ts | 2 +- shared/agent-core/mcp-tools.test.ts | 4 +- shared/agent-core/openai-compat-model.ts | 20 ++--- shared/agent-tool-permissions.ts | 6 +- shared/ip-classification.test.ts | 4 +- shared/ip-classification.ts | 28 +++--- shared/url.ts | 2 +- src/acp/built-in-adapter.test.ts | 4 +- src/acp/built-in-adapter.ts | 4 +- src/ai/prompt.test.ts | 4 +- 28 files changed, 244 insertions(+), 225 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index b28a2bac7..60030699e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -84,5 +84,22 @@ export default [ ], }, }, + { + // Environment-agnostic code shared by frontend and backend. No React or + // browser assumptions here — just the shared TypeScript conventions. + files: ['shared/**/*.{ts,tsx}'], + languageOptions: { + parser: typescriptParser, + parserOptions: sharedParserOptions, + globals: { + ...globals.node, + Bun: 'readonly', + }, + }, + plugins: { + '@typescript-eslint': typescript, + }, + rules: sharedRules, + }, ...storybook.configs['flat/recommended'], ] diff --git a/package.json b/package.json index 16ebb9802..6ddc845e5 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,8 @@ "analyze": "vite analyze", "db": "drizzle-kit", "eval": "bun run src/ai/eval/run.ts", - "lint": "eslint src", - "lint:fix": "eslint src --fix", + "lint": "eslint src shared", + "lint:fix": "eslint src shared --fix", "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", "format:rust": "cd src-tauri && cargo fmt && cd ..", "format:rust-check": "cd src-tauri && cargo fmt --check && cd ..", diff --git a/shared/agent-core/anthropic-model.test.ts b/shared/agent-core/anthropic-model.test.ts index 150c69031..e65fa3ac9 100644 --- a/shared/agent-core/anthropic-model.test.ts +++ b/shared/agent-core/anthropic-model.test.ts @@ -21,7 +21,7 @@ import { buildAnthropicModel, isKnownAnthropicModel, type AgentFetch } from './a /** Minimal well-formed Anthropic messages SSE: a start then an immediate stop, so * the SDK parses a clean stream rather than erroring mid-iteration. */ -const SSE = [ +const sse = [ 'event: message_start', 'data: {"type":"message_start","message":{"id":"m","type":"message","role":"assistant","model":"x","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":1}}}', '', @@ -31,7 +31,7 @@ const SSE = [ '', ].join('\n') -const CONTEXT: Context = { messages: [{ role: 'user', content: 'hi', timestamp: 0 }] } +const context: Context = { messages: [{ role: 'user', content: 'hi', timestamp: 0 }] } type CapturedBody = { max_tokens?: number @@ -56,16 +56,16 @@ const drive = async ( injectedCalls += 1 headers = init?.headers ? new Headers(init.headers) : null body = init?.body ? (JSON.parse(init.body as string) as CapturedBody) : null - return new Response(SSE, { status: 200, headers: { 'content-type': 'text/event-stream' } }) + return new Response(sse, { status: 200, headers: { 'content-type': 'text/event-stream' } }) } const { models, model } = buildAnthropicModel({ apiKey: 'test-key', fetch: injectedFetch, modelId }) const provider = models.getProvider('anthropic') - if (!provider) throw new Error('anthropic provider not registered') + if (!provider) {throw new Error('anthropic provider not registered')} - const stream = provider[entry](model, CONTEXT, options) + const stream = provider[entry](model, context, options) try { - for await (const event of stream) void event + for await (const event of stream) {void event} } catch { // Stream-parse hiccups are irrelevant; the captured request body is the contract. } diff --git a/shared/agent-core/anthropic-model.ts b/shared/agent-core/anthropic-model.ts index 43102e911..f05c8a81a 100644 --- a/shared/agent-core/anthropic-model.ts +++ b/shared/agent-core/anthropic-model.ts @@ -50,11 +50,11 @@ import { adjustMaxTokensForThinking, buildBaseOptions } from '@earendil-works/pi import { builtinModels } from '@earendil-works/pi-ai/providers/all' /** Provider id of the resolved model; matches Pi's built-in anthropic provider. */ -const PROVIDER = 'anthropic' -const API = 'anthropic-messages' +const provider = 'anthropic' +const apiId = 'anthropic-messages' /** Valid adaptive-thinking effort levels, used to narrow catalog overrides. */ -const EFFORTS: readonly AnthropicEffort[] = ['low', 'medium', 'high', 'xhigh', 'max'] +const efforts: readonly AnthropicEffort[] = ['low', 'medium', 'high', 'xhigh', 'max'] /** * Minimal fetch shape every request is routed through. The app passes its proxy @@ -88,17 +88,17 @@ export type BuildAnthropicModelOptions = { * @returns true if the catalog has an anthropic-messages model with that id */ export const isKnownAnthropicModel = (modelId: string): boolean => { - const resolved = builtinModels().getModel(PROVIDER, modelId) - return Boolean(resolved && hasApi(resolved, API)) + const resolved = builtinModels().getModel(provider, modelId) + return Boolean(resolved && hasApi(resolved, apiId)) } /** * Narrows a dispatched `Model` to the anthropic-messages model this * provider exclusively serves, surfacing misuse loudly rather than guessing. */ -const requireAnthropic = (model: Model): Model => { - if (!hasApi(model, API)) { - throw new Error(`Expected an "${API}" model, got "${model.api}".`) +const requireAnthropic = (model: Model): Model => { + if (!hasApi(model, apiId)) { + throw new Error(`Expected an "${apiId}" model, got "${model.api}".`) } return model } @@ -108,14 +108,14 @@ const requireAnthropic = (model: Model): Model => { * model's `thinkingLevelMap` override (e.g. opus models remap `xhigh`). Mirrors * Pi's internal mapping, which is not exported. */ -const mapThinkingLevelToEffort = (model: Model, level: ThinkingLevel): AnthropicEffort => { +const mapThinkingLevelToEffort = (model: Model, level: ThinkingLevel): AnthropicEffort => { const mapped = model.thinkingLevelMap?.[level] // `EFFORTS.find` validates the catalog override as a real effort (type-safe, // no cast). Every Anthropic catalog override is valid, so this matches Pi. - const override = typeof mapped === 'string' ? EFFORTS.find((effort) => effort === mapped) : undefined - if (override) return override - if (level === 'minimal' || level === 'low') return 'low' - if (level === 'medium') return 'medium' + const override = typeof mapped === 'string' ? efforts.find((effort) => effort === mapped) : undefined + if (override) {return override} + if (level === 'minimal' || level === 'low') {return 'low'} + if (level === 'medium') {return 'medium'} return 'high' } @@ -125,7 +125,7 @@ const mapThinkingLevelToEffort = (model: Model, level: ThinkingLevel * are exported; only the (unexported) effort mapping is reproduced above. */ const toFullAnthropicOptions = ( - model: Model, + model: Model, context: Context, options?: SimpleStreamOptions, ): AnthropicOptions => { @@ -154,7 +154,7 @@ const toFullAnthropicOptions = ( * Builds the `@anthropic-ai/sdk` client with the injected fetch, restoring the * static headers Pi would otherwise add for direct browser access. */ -const createAnthropicClient = (model: Model, opts: BuildAnthropicModelOptions): Anthropic => +const createAnthropicClient = (model: Model, opts: BuildAnthropicModelOptions): Anthropic => new Anthropic({ apiKey: opts.apiKey, baseURL: model.baseUrl, @@ -177,8 +177,8 @@ const createAnthropicClient = (model: Model, opts: BuildAnthropicMod */ export const buildAnthropicModel = (opts: BuildAnthropicModelOptions): { models: Models; model: Model } => { const catalog = builtinModels() - const resolved = catalog.getModel(PROVIDER, opts.modelId) - if (!resolved || !hasApi(resolved, API)) { + const resolved = catalog.getModel(provider, opts.modelId) + if (!resolved || !hasApi(resolved, apiId)) { throw new Error(`Unknown Anthropic model "${opts.modelId}".`) } @@ -194,7 +194,7 @@ export const buildAnthropicModel = (opts: BuildAnthropicModelOptions): { models: const models = createModels() models.setProvider( createProvider({ - id: PROVIDER, + id: provider, name: 'Anthropic', baseUrl: resolved.baseUrl, // Advisory only: the pre-built `client` owns the real credential diff --git a/shared/agent-core/browser-env/browser-execution-env.test.ts b/shared/agent-core/browser-env/browser-execution-env.test.ts index 6c6ce23b2..482907f24 100644 --- a/shared/agent-core/browser-env/browser-execution-env.test.ts +++ b/shared/agent-core/browser-env/browser-execution-env.test.ts @@ -15,7 +15,7 @@ import * as fsp from '@zenfs/core/promises' import { BrowserExecutionEnv } from './browser-execution-env.ts' import { mountInMemoryFs } from './mount.ts' -const JAIL = '/workspace/t1' +const jail = '/workspace/t1' beforeAll(async () => { await mountInMemoryFs() @@ -27,7 +27,7 @@ beforeAll(async () => { await fsp.writeFile('/etc/passwd', 'root') }) -const env = new BrowserExecutionEnv({ cwd: JAIL }) +const env = new BrowserExecutionEnv({ cwd: jail }) describe('BrowserExecutionEnv filesystem jail', () => { it('reads files inside the workspace (absolute and relative)', async () => { @@ -40,19 +40,19 @@ describe('BrowserExecutionEnv filesystem jail', () => { it('blocks reading a sibling thread workspace without leaking content', async () => { const result = await env.readTextFile('/workspace/t2/secret.txt') expect(result.ok).toBe(false) - if (!result.ok) expect(result.error.code).toBe('permission_denied') + if (!result.ok) {expect(result.error.code).toBe('permission_denied')} }) it('blocks reading an absolute system path', async () => { const result = await env.readTextFile('/etc/passwd') expect(result.ok).toBe(false) - if (!result.ok) expect(result.error.code).toBe('permission_denied') + if (!result.ok) {expect(result.error.code).toBe('permission_denied')} }) it('blocks `..` traversal into a sibling', async () => { const result = await env.readTextFile('../t2/secret.txt') expect(result.ok).toBe(false) - if (!result.ok) expect(result.error.code).toBe('permission_denied') + if (!result.ok) {expect(result.error.code).toBe('permission_denied')} }) it('blocks readBinaryFile and readTextLines outside the jail', async () => { @@ -65,7 +65,7 @@ describe('BrowserExecutionEnv filesystem jail', () => { it('blocks writing outside the workspace and does not create the file', async () => { const result = await env.writeFile('/workspace/t2/pwned.txt', 'x') expect(result.ok).toBe(false) - if (!result.ok) expect(result.error.code).toBe('permission_denied') + if (!result.ok) {expect(result.error.code).toBe('permission_denied')} expect(await fsp.exists('/workspace/t2/pwned.txt')).toBe(false) }) @@ -84,7 +84,7 @@ describe('BrowserExecutionEnv filesystem jail', () => { it('blocks listing the parent workspace root', async () => { const result = await env.listDir('/workspace') expect(result.ok).toBe(false) - if (!result.ok) expect(result.error.code).toBe('permission_denied') + if (!result.ok) {expect(result.error.code).toBe('permission_denied')} }) it('blocks fileInfo, canonicalPath and createDir outside the jail', async () => { @@ -100,7 +100,7 @@ describe('BrowserExecutionEnv filesystem jail', () => { it('reports exists() as a permission error for out-of-jail paths (no existence oracle)', async () => { const escape = await env.exists('/etc/passwd') expect(escape.ok).toBe(false) - if (!escape.ok) expect(escape.error.code).toBe('permission_denied') + if (!escape.ok) {expect(escape.error.code).toBe('permission_denied')} const insideMissing = await env.exists('nope.txt') expect(insideMissing.ok && insideMissing.value).toBe(false) @@ -129,12 +129,12 @@ describe('BrowserExecutionEnv filesystem jail', () => { it('blocks a shell whose cwd escapes the workspace', async () => { const result = await env.exec('echo hi', { cwd: '../t2' }) expect(result.ok).toBe(false) - if (!result.ok) expect(result.error.message).toContain('cwd escapes workspace') + if (!result.ok) {expect(result.error.message).toContain('cwd escapes workspace')} }) it('emits output produced before a command times out', async () => { const timeoutEnv = new BrowserExecutionEnv({ - cwd: JAIL, + cwd: jail, createBash: () => ({ exec: async () => { await new Promise((resolve) => setTimeout(resolve, 10)) @@ -151,7 +151,7 @@ describe('BrowserExecutionEnv filesystem jail', () => { }) expect(result.ok).toBe(false) - if (!result.ok) expect(result.error.code).toBe('timeout') + if (!result.ok) {expect(result.error.code).toBe('timeout')} expect(stdout.join('')).toBe('partial\n') expect(stderr.join('')).toBe('warning\n') }) diff --git a/shared/agent-core/browser-env/browser-execution-env.ts b/shared/agent-core/browser-env/browser-execution-env.ts index cf0528b37..4b264cc95 100644 --- a/shared/agent-core/browser-env/browser-execution-env.ts +++ b/shared/agent-core/browser-env/browser-execution-env.ts @@ -48,7 +48,7 @@ import { ZenBashFileSystem } from './zen-bash-fs.ts' * INSIDE the env root so temp files stay within the workspace jail (readable by * the jailed coding tools, e.g. bash's "Full output" file) and are torn down * with the workspace instead of leaking into a shared `/tmp`. */ -const TEMP_SUBDIR = '.tmp' +const tempSubdir = '.tmp' export class BrowserExecutionEnv implements ExecutionEnv { cwd: string @@ -66,7 +66,7 @@ export class BrowserExecutionEnv implements ExecutionEnv { }) { this.cwd = options.cwd this.env = options.env ?? {} - this.tempRoot = join(options.cwd, TEMP_SUBDIR) + this.tempRoot = join(options.cwd, tempSubdir) this.createBash = options.createBash ?? ((bashOptions) => new Bash(bashOptions)) // The shell is jailed to the env's root (the thread's workspace) so bash // commands can't read or write a sibling thread's files on the shared mount. @@ -112,7 +112,7 @@ export class BrowserExecutionEnv implements ExecutionEnv { command: string, options?: ShellExecOptions, ): Promise> { - if (options?.abortSignal?.aborted) return err(new ExecutionError('aborted', 'aborted')) + if (options?.abortSignal?.aborted) {return err(new ExecutionError('aborted', 'aborted'))} const cwd = options?.cwd ? resolve(this.cwd, options.cwd) : this.cwd if (!isWithinWorkspace(this.cwd, cwd)) { @@ -122,7 +122,7 @@ export class BrowserExecutionEnv implements ExecutionEnv { const controller = new AbortController() const state = { timedOut: false } const onExternalAbort = () => controller.abort() - if (options?.abortSignal) options.abortSignal.addEventListener('abort', onExternalAbort, { once: true }) + if (options?.abortSignal) {options.abortSignal.addEventListener('abort', onExternalAbort, { once: true })} const timeoutId = typeof options?.timeout === 'number' ? setTimeout(() => { @@ -140,27 +140,27 @@ export class BrowserExecutionEnv implements ExecutionEnv { // virtual ZenFS mount with no host-process access, so we disable it. const bash = this.createBash({ fs: this.bashFs, cwd, env, defenseInDepth: false }) const result = await bash.exec(command, { signal: controller.signal }) - if (result.stdout) options?.onStdout?.(result.stdout) - if (result.stderr) options?.onStderr?.(result.stderr) - if (state.timedOut) return err(new ExecutionError('timeout', `timeout:${options?.timeout}`)) - if (options?.abortSignal?.aborted) return err(new ExecutionError('aborted', 'aborted')) + if (result.stdout) {options?.onStdout?.(result.stdout)} + if (result.stderr) {options?.onStderr?.(result.stderr)} + if (state.timedOut) {return err(new ExecutionError('timeout', `timeout:${options?.timeout}`))} + if (options?.abortSignal?.aborted) {return err(new ExecutionError('aborted', 'aborted'))} return ok({ stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode }) } catch (error) { - if (state.timedOut) return err(new ExecutionError('timeout', `timeout:${options?.timeout}`)) - if (controller.signal.aborted) return err(new ExecutionError('aborted', 'aborted')) + if (state.timedOut) {return err(new ExecutionError('timeout', `timeout:${options?.timeout}`))} + if (controller.signal.aborted) {return err(new ExecutionError('aborted', 'aborted'))} const cause = toError(error) return err(new ExecutionError('unknown', cause.message, cause)) } finally { - if (timeoutId) clearTimeout(timeoutId) - if (options?.abortSignal) options.abortSignal.removeEventListener('abort', onExternalAbort) + if (timeoutId) {clearTimeout(timeoutId)} + if (options?.abortSignal) {options.abortSignal.removeEventListener('abort', onExternalAbort)} } } async readTextFile(path: string, abortSignal?: AbortSignal): Promise> { const resolved = this.jailed(path) - if (!resolved.ok) return resolved + if (!resolved.ok) {return resolved} const aborted = abortedResult(abortSignal, resolved.value) - if (aborted) return aborted + if (aborted) {return aborted} try { return ok(await fsp.readFile(resolved.value, { encoding: 'utf8' })) } catch (error) { @@ -173,10 +173,10 @@ export class BrowserExecutionEnv implements ExecutionEnv { options?: { maxLines?: number; abortSignal?: AbortSignal }, ): Promise> { const resolved = this.jailed(path) - if (!resolved.ok) return resolved + if (!resolved.ok) {return resolved} const aborted = abortedResult(options?.abortSignal, resolved.value) - if (aborted) return aborted - if (options?.maxLines !== undefined && options.maxLines <= 0) return ok([]) + if (aborted) {return aborted} + if (options?.maxLines !== undefined && options.maxLines <= 0) {return ok([])} try { const lines = splitLines(await fsp.readFile(resolved.value, { encoding: 'utf8' })) return ok(options?.maxLines !== undefined ? lines.slice(0, options.maxLines) : lines) @@ -187,9 +187,9 @@ export class BrowserExecutionEnv implements ExecutionEnv { async readBinaryFile(path: string, abortSignal?: AbortSignal): Promise> { const resolved = this.jailed(path) - if (!resolved.ok) return resolved + if (!resolved.ok) {return resolved} const aborted = abortedResult(abortSignal, resolved.value) - if (aborted) return aborted + if (aborted) {return aborted} try { return ok(new Uint8Array(await fsp.readFile(resolved.value))) } catch (error) { @@ -203,13 +203,13 @@ export class BrowserExecutionEnv implements ExecutionEnv { abortSignal?: AbortSignal, ): Promise> { const resolved = this.jailed(path) - if (!resolved.ok) return resolved + if (!resolved.ok) {return resolved} const aborted = abortedResult(abortSignal, resolved.value) - if (aborted) return aborted + if (aborted) {return aborted} try { await fsp.mkdir(dirname(resolved.value), { recursive: true }) const afterMkdir = abortedResult(abortSignal, resolved.value) - if (afterMkdir) return afterMkdir + if (afterMkdir) {return afterMkdir} await fsp.writeFile(resolved.value, content) return ok(undefined) } catch (error) { @@ -219,7 +219,7 @@ export class BrowserExecutionEnv implements ExecutionEnv { async appendFile(path: string, content: string | Uint8Array): Promise> { const resolved = this.jailed(path) - if (!resolved.ok) return resolved + if (!resolved.ok) {return resolved} try { await fsp.mkdir(dirname(resolved.value), { recursive: true }) await fsp.appendFile(resolved.value, content) @@ -231,7 +231,7 @@ export class BrowserExecutionEnv implements ExecutionEnv { async fileInfo(path: string): Promise> { const resolved = this.jailed(path) - if (!resolved.ok) return resolved + if (!resolved.ok) {return resolved} try { return fileInfoFrom(resolved.value, await fsp.lstat(resolved.value)) } catch (error) { @@ -241,18 +241,18 @@ export class BrowserExecutionEnv implements ExecutionEnv { async listDir(path: string, abortSignal?: AbortSignal): Promise> { const resolved = this.jailed(path) - if (!resolved.ok) return resolved + if (!resolved.ok) {return resolved} const aborted = abortedResult(abortSignal, resolved.value) - if (aborted) return aborted + if (aborted) {return aborted} try { const entries = await fsp.readdir(resolved.value, { withFileTypes: true }) const infos: FileInfo[] = [] for (const entry of entries) { const loopAborted = abortedResult(abortSignal, resolved.value) - if (loopAborted) return loopAborted + if (loopAborted) {return loopAborted} const childPath = join(resolved.value, entry.name) const info = fileInfoFrom(childPath, await fsp.lstat(childPath)) - if (info.ok) infos.push(info.value) + if (info.ok) {infos.push(info.value)} } return ok(infos) } catch (error) { @@ -262,7 +262,7 @@ export class BrowserExecutionEnv implements ExecutionEnv { async canonicalPath(path: string): Promise> { const resolved = this.jailed(path) - if (!resolved.ok) return resolved + if (!resolved.ok) {return resolved} try { const real = await fsp.realpath(resolved.value) // Defense-in-depth: `realpath` is the one method that follows symlinks, so @@ -280,14 +280,14 @@ export class BrowserExecutionEnv implements ExecutionEnv { async exists(path: string): Promise> { const result = await this.fileInfo(path) - if (result.ok) return ok(true) - if (result.error.code === 'not_found') return ok(false) + if (result.ok) {return ok(true)} + if (result.error.code === 'not_found') {return ok(false)} return err(result.error) } async createDir(path: string, options?: { recursive?: boolean }): Promise> { const resolved = this.jailed(path) - if (!resolved.ok) return resolved + if (!resolved.ok) {return resolved} try { await fsp.mkdir(resolved.value, { recursive: options?.recursive ?? true }) return ok(undefined) @@ -298,7 +298,7 @@ export class BrowserExecutionEnv implements ExecutionEnv { async remove(path: string, options?: { recursive?: boolean; force?: boolean }): Promise> { const resolved = this.jailed(path) - if (!resolved.ok) return resolved + if (!resolved.ok) {return resolved} try { await fsp.rm(resolved.value, { recursive: options?.recursive ?? false, force: options?.force ?? false }) return ok(undefined) @@ -323,7 +323,7 @@ export class BrowserExecutionEnv implements ExecutionEnv { async createTempFile(options?: { prefix?: string; suffix?: string }): Promise> { const dir = await this.createTempDir('tmp-') - if (!dir.ok) return dir + if (!dir.ok) {return dir} try { const filePath = join(dir.value, `${options?.prefix ?? ''}${crypto.randomUUID()}${options?.suffix ?? ''}`) await fsp.writeFile(filePath, '') diff --git a/shared/agent-core/browser-env/coding-tool-operations.test.ts b/shared/agent-core/browser-env/coding-tool-operations.test.ts index 8d09928bc..2396b5639 100644 --- a/shared/agent-core/browser-env/coding-tool-operations.test.ts +++ b/shared/agent-core/browser-env/coding-tool-operations.test.ts @@ -41,8 +41,8 @@ const fakeEnv = (script: { const env = { exec: async (command: string, options: Record) => { calls.push({ command, options }) - if (script.emit?.stdout !== undefined) (options.onStdout as (c: string) => void)(script.emit.stdout) - if (script.emit?.stderr !== undefined) (options.onStderr as (c: string) => void)(script.emit.stderr) + if (script.emit?.stdout !== undefined) {(options.onStdout as (c: string) => void)(script.emit.stdout)} + if (script.emit?.stderr !== undefined) {(options.onStderr as (c: string) => void)(script.emit.stderr)} return script.result }, } as unknown as BrowserExecutionEnv @@ -94,22 +94,22 @@ describe('createBashOperations', () => { }) }) -const DIR = '/optest' +const dir = '/optest' describe('file operation adapters (real in-memory ZenFS)', () => { beforeAll(async () => { await mountInMemoryFs() - await fsp.mkdir(DIR, { recursive: true }) - await fsp.writeFile(`${DIR}/seed.txt`, 'seed-content') + await fsp.mkdir(dir, { recursive: true }) + await fsp.writeFile(`${dir}/seed.txt`, 'seed-content') }) afterAll(async () => { - await fsp.rm(DIR, { recursive: true, force: true }) + await fsp.rm(dir, { recursive: true, force: true }) }) it('readFile returns a Node Buffer (not a bare Uint8Array)', async () => { const ops = createReadOperations() - const buf = await ops.readFile(`${DIR}/seed.txt`) + const buf = await ops.readFile(`${dir}/seed.txt`) // The operation contract is typed in terms of Buffer; ZenFS hands back a // Uint8Array, so the Buffer.from wrap is load-bearing for callers doing // Buffer-only ops (.toString('utf8'), slicing). @@ -119,24 +119,24 @@ describe('file operation adapters (real in-memory ZenFS)', () => { it('access resolves for an existing file and rejects (ENOENT) for a missing one', async () => { const ops = createReadOperations() - await expect(ops.access(`${DIR}/seed.txt`)).resolves.toBeUndefined() + await expect(ops.access(`${dir}/seed.txt`)).resolves.toBeUndefined() // The edit tool branches on this rejection to distinguish edit vs create. - await expect(ops.access(`${DIR}/missing.txt`)).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(ops.access(`${dir}/missing.txt`)).rejects.toMatchObject({ code: 'ENOENT' }) }) it('write mkdir creates nested dirs recursively; the file is then visible to the read adapter (shared mount)', async () => { const writeOps = createWriteOperations() - await writeOps.mkdir(`${DIR}/deep/nested`) - await writeOps.writeFile(`${DIR}/deep/nested/out.txt`, 'written') + await writeOps.mkdir(`${dir}/deep/nested`) + await writeOps.writeFile(`${dir}/deep/nested/out.txt`, 'written') // Read back through a SEPARATE adapter to prove both target the one ZenFS // singleton, not via raw fsp (which would prove nothing about the adapters). const readOps = createReadOperations() - expect((await readOps.readFile(`${DIR}/deep/nested/out.txt`)).toString('utf8')).toBe('written') + expect((await readOps.readFile(`${dir}/deep/nested/out.txt`)).toString('utf8')).toBe('written') }) it('edit writeFile overwrites in place and readFile reads the new content back as a Buffer', async () => { const ops = createEditOperations() - const target = `${DIR}/edit-target.txt` + const target = `${dir}/edit-target.txt` await ops.writeFile(target, 'v1') await ops.writeFile(target, 'v2-overwritten') const buf = await ops.readFile(target) @@ -146,6 +146,6 @@ describe('file operation adapters (real in-memory ZenFS)', () => { it('edit access rejects for a missing file (the create-vs-edit signal)', async () => { const ops = createEditOperations() - await expect(ops.access(`${DIR}/never-existed.txt`)).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(ops.access(`${dir}/never-existed.txt`)).rejects.toMatchObject({ code: 'ENOENT' }) }) }) diff --git a/shared/agent-core/browser-env/fs-helpers.test.ts b/shared/agent-core/browser-env/fs-helpers.test.ts index 8a5f7b411..c10cd8014 100644 --- a/shared/agent-core/browser-env/fs-helpers.test.ts +++ b/shared/agent-core/browser-env/fs-helpers.test.ts @@ -93,7 +93,7 @@ describe('fileInfoFrom', () => { it('builds a file info with basename, size and mtimeMs', () => { const result = fileInfoFrom('/workspace/t1/mine.txt', statOf('file', { size: 42, mtimeMs: 1234 })) expect(result.ok).toBe(true) - if (!result.ok) throw new Error('unreachable') + if (!result.ok) {throw new Error('unreachable')} expect(result.value).toEqual({ name: 'mine.txt', path: '/workspace/t1/mine.txt', kind: 'file', size: 42, mtimeMs: 1234 }) }) @@ -113,7 +113,7 @@ describe('fileInfoFrom', () => { it('returns an invalid FileError for a kind Pi does not model (e.g. device/fifo)', () => { const result = fileInfoFrom('/dev/null', statOf('other')) expect(result.ok).toBe(false) - if (result.ok) throw new Error('unreachable') + if (result.ok) {throw new Error('unreachable')} expect(result.error.code).toBe('invalid') expect(result.error.path).toBe('/dev/null') }) diff --git a/shared/agent-core/browser-env/fs-helpers.ts b/shared/agent-core/browser-env/fs-helpers.ts index dd272249d..226c0ab02 100644 --- a/shared/agent-core/browser-env/fs-helpers.ts +++ b/shared/agent-core/browser-env/fs-helpers.ts @@ -40,7 +40,7 @@ const isErrnoError = (error: unknown): error is { code: string; message: string * @returns a {@link FileError} carrying the mapped code and the original cause */ export const toFileError = (error: unknown, path?: string): FileError => { - if (error instanceof FileError) return error + if (error instanceof FileError) {return error} const cause = toError(error) if (isErrnoError(error)) { switch (error.code) { @@ -63,9 +63,9 @@ export const toFileError = (error: unknown, path?: string): FileError => { } const fileKindFrom = (stat: ZenStats): FileKind | undefined => { - if (stat.isFile()) return 'file' - if (stat.isDirectory()) return 'directory' - if (stat.isSymbolicLink()) return 'symlink' + if (stat.isFile()) {return 'file'} + if (stat.isDirectory()) {return 'directory'} + if (stat.isSymbolicLink()) {return 'symlink'} return undefined } @@ -79,7 +79,7 @@ const fileKindFrom = (stat: ZenStats): FileKind | undefined => { */ export const fileInfoFrom = (path: string, stat: ZenStats): Result => { const kind = fileKindFrom(stat) - if (!kind) return err(new FileError('invalid', 'Unsupported file type', path)) + if (!kind) {return err(new FileError('invalid', 'Unsupported file type', path))} return ok({ name: basename(path) || path, path, kind, size: stat.size, mtimeMs: stat.mtimeMs }) } @@ -101,8 +101,8 @@ export const abortedResult = (signal: AbortSignal | undefined, path: string): Re * @param text - the file contents to split */ export const splitLines = (text: string): string[] => { - if (text === '') return [] + if (text === '') {return []} const lines = text.split(/\r?\n/) - if (lines[lines.length - 1] === '') lines.pop() + if (lines[lines.length - 1] === '') {lines.pop()} return lines } diff --git a/shared/agent-core/browser-env/mount.test.ts b/shared/agent-core/browser-env/mount.test.ts index 5d7a6b3d9..23ee7dee7 100644 --- a/shared/agent-core/browser-env/mount.test.ts +++ b/shared/agent-core/browser-env/mount.test.ts @@ -33,7 +33,7 @@ beforeEach(() => { }) afterEach(() => { - if (originalNavigator) Object.defineProperty(globalThis, 'navigator', originalNavigator) + if (originalNavigator) {Object.defineProperty(globalThis, 'navigator', originalNavigator)} }) describe('mountInMemoryFs', () => { diff --git a/shared/agent-core/browser-env/workspace-jail.test.ts b/shared/agent-core/browser-env/workspace-jail.test.ts index f3842bed1..5e81d8158 100644 --- a/shared/agent-core/browser-env/workspace-jail.test.ts +++ b/shared/agent-core/browser-env/workspace-jail.test.ts @@ -10,42 +10,42 @@ import { describe, expect, it } from 'bun:test' import { isWithinWorkspace, resolveInWorkspace } from './workspace-jail.ts' -const WS = '/workspace/thread-1' +const ws = '/workspace/thread-1' describe('resolveInWorkspace', () => { it('resolves a relative path inside the workspace', () => { - expect(resolveInWorkspace(WS, 'notes/todo.md')).toBe(`${WS}/notes/todo.md`) + expect(resolveInWorkspace(ws, 'notes/todo.md')).toBe(`${ws}/notes/todo.md`) }) it('allows the workspace root itself', () => { - expect(resolveInWorkspace(WS, '.')).toBe(WS) + expect(resolveInWorkspace(ws, '.')).toBe(ws) }) it('throws on an absolute path outside the workspace', () => { - expect(() => resolveInWorkspace(WS, '/etc/passwd')).toThrow('path escapes workspace') + expect(() => resolveInWorkspace(ws, '/etc/passwd')).toThrow('path escapes workspace') }) it('throws on a `..` traversal into a sibling thread', () => { - expect(() => resolveInWorkspace(WS, '../thread-2/secret')).toThrow('path escapes workspace') + expect(() => resolveInWorkspace(ws, '../thread-2/secret')).toThrow('path escapes workspace') }) it('throws on a `..` chain that climbs above the workspace root', () => { - expect(() => resolveInWorkspace(WS, 'a/../../thread-2/x')).toThrow('path escapes workspace') + expect(() => resolveInWorkspace(ws, 'a/../../thread-2/x')).toThrow('path escapes workspace') }) it('does not treat a sibling whose name shares a prefix as inside', () => { - expect(() => resolveInWorkspace(WS, '/workspace/thread-12/x')).toThrow('path escapes workspace') + expect(() => resolveInWorkspace(ws, '/workspace/thread-12/x')).toThrow('path escapes workspace') }) }) describe('isWithinWorkspace', () => { it('accepts the root and its descendants', () => { - expect(isWithinWorkspace(WS, WS)).toBe(true) - expect(isWithinWorkspace(WS, `${WS}/a/b`)).toBe(true) + expect(isWithinWorkspace(ws, ws)).toBe(true) + expect(isWithinWorkspace(ws, `${ws}/a/b`)).toBe(true) }) it('rejects siblings and prefix-aliased siblings', () => { - expect(isWithinWorkspace(WS, '/workspace/thread-2')).toBe(false) - expect(isWithinWorkspace(WS, '/workspace/thread-1-evil')).toBe(false) + expect(isWithinWorkspace(ws, '/workspace/thread-2')).toBe(false) + expect(isWithinWorkspace(ws, '/workspace/thread-1-evil')).toBe(false) }) }) diff --git a/shared/agent-core/browser-env/zen-bash-fs.test.ts b/shared/agent-core/browser-env/zen-bash-fs.test.ts index 46e803e59..fac8b29f4 100644 --- a/shared/agent-core/browser-env/zen-bash-fs.test.ts +++ b/shared/agent-core/browser-env/zen-bash-fs.test.ts @@ -14,7 +14,7 @@ import { Bash } from 'just-bash' import { mountInMemoryFs } from './mount.ts' import { ZenBashFileSystem } from './zen-bash-fs.ts' -const JAIL = '/workspace/t1' +const jail = '/workspace/t1' beforeAll(async () => { await mountInMemoryFs() @@ -26,7 +26,7 @@ beforeAll(async () => { await fsp.writeFile('/etc/passwd', 'root') }) -const fs = new ZenBashFileSystem(JAIL) +const fs = new ZenBashFileSystem(jail) describe('ZenBashFileSystem jail', () => { it('reads files inside the workspace (absolute and relative)', async () => { @@ -89,7 +89,7 @@ describe('ZenBashFileSystem jail', () => { await fsp.writeFile('/workspace/sibling/secret', 'TOPSECRET') await fsp.mkdir('/workspace/t1/deep', { recursive: true }) - const bash = new Bash({ fs, cwd: JAIL, defenseInDepth: false }) + const bash = new Bash({ fs, cwd: jail, defenseInDepth: false }) const result = await bash.exec('ln -s ../sibling deep/x && mv deep/x x && cat x/secret', {}) expect(result.stdout).not.toContain('TOPSECRET') diff --git a/shared/agent-core/build-app-harness.ts b/shared/agent-core/build-app-harness.ts index 8491253b5..5c5f87c6d 100644 --- a/shared/agent-core/build-app-harness.ts +++ b/shared/agent-core/build-app-harness.ts @@ -37,11 +37,11 @@ import { ensureBufferPolyfill } from './ensure-buffer.ts' import { buildSeedMessages, type SeedTurn } from './seed-history.ts' /** Mount-relative root under which every thread carves its isolated workspace. */ -const WORKSPACE_ROOT = '/workspace' +const workspaceRoot = '/workspace' /** * Absolute mount-relative workspace directory for a thread. Each thread gets its - * own subtree under {@link WORKSPACE_ROOT} so concurrent threads never see each + * own subtree under {@link workspaceRoot} so concurrent threads never see each * other's files even though they share the one process-global ZenFS mount. * * @param threadId - the chat thread id (an app-generated id, e.g. a UUID) @@ -55,7 +55,7 @@ export const workspaceDirFor = (threadId: string): string => { if (!/^[A-Za-z0-9._-]+$/.test(threadId) || threadId === '.' || threadId === '..') { throw new Error(`unsafe threadId for workspace: ${threadId}`) } - return `${WORKSPACE_ROOT}/${threadId}` + return `${workspaceRoot}/${threadId}` } /** @@ -116,7 +116,7 @@ export type BuildAppHarnessOptions = { /** * Build a ready-to-run app harness for a thread. Mounts the ZenFS singleton - * (once), carves the thread's isolated workspace under {@link WORKSPACE_ROOT}, + * (once), carves the thread's isolated workspace under {@link workspaceRoot}, * binds the coding tools to it, resolves the proxied model (anthropic or * openai-compatible), and returns the constructed harness. The workspace persists * with the harness; tear it down by removing {@link workspaceDirFor}`(threadId)` diff --git a/shared/agent-core/coding-tools/index.test.ts b/shared/agent-core/coding-tools/index.test.ts index 3422ee878..47c888021 100644 --- a/shared/agent-core/coding-tools/index.test.ts +++ b/shared/agent-core/coding-tools/index.test.ts @@ -19,13 +19,13 @@ import type { AgentTool } from '@earendil-works/pi-agent-core' import { mountInMemoryFs } from '../browser-env/mount.ts' import { BrowserExecutionEnv } from '../browser-env/browser-execution-env.ts' import { createBrowserCodingTools } from './index.ts' -import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from './truncate.ts' +import { defaultMaxBytes, defaultMaxLines } from './truncate.ts' -const WS = '/ws' +const ws = '/ws' const textOf = (result: { content: { type: string; text?: string }[] }): string => { const block = result.content[0] - if (block.type !== 'text' || block.text === undefined) throw new Error('expected a text block') + if (block.type !== 'text' || block.text === undefined) {throw new Error('expected a text block')} return block.text } @@ -40,10 +40,10 @@ beforeAll(async () => { beforeEach(async () => { // Fresh workspace per test so files from one test can't leak into another. - await fsp.rm(WS, { recursive: true, force: true }) - await fsp.mkdir(WS, { recursive: true }) - const env = new BrowserExecutionEnv({ cwd: WS }) - ;[bash, read, write, edit] = createBrowserCodingTools(env, { cwd: WS }) + await fsp.rm(ws, { recursive: true, force: true }) + await fsp.mkdir(ws, { recursive: true }) + const env = new BrowserExecutionEnv({ cwd: ws }) + ;[bash, read, write, edit] = createBrowserCodingTools(env, { cwd: ws }) }) const run = (tool: AgentTool, args: unknown) => @@ -67,55 +67,55 @@ describe('createBrowserCodingTools', () => { describe('read tool', () => { it('returns the full file content when small', async () => { - await fsp.writeFile(`${WS}/a.txt`, 'line one\nline two') + await fsp.writeFile(`${ws}/a.txt`, 'line one\nline two') expect(textOf(await run(read, { path: 'a.txt' }))).toBe('line one\nline two') }) it('applies offset + limit and appends a continuation hint with the next offset', async () => { - await fsp.writeFile(`${WS}/f.txt`, 'l1\nl2\nl3\nl4\nl5') + await fsp.writeFile(`${ws}/f.txt`, 'l1\nl2\nl3\nl4\nl5') const out = textOf(await run(read, { path: 'f.txt', offset: 2, limit: 2 })) expect(out).toBe('l2\nl3\n\n[2 more lines in file. Use offset=4 to continue.]') }) it('does not append a continuation hint when the limit reaches end of file', async () => { - await fsp.writeFile(`${WS}/f.txt`, 'l1\nl2\nl3') + await fsp.writeFile(`${ws}/f.txt`, 'l1\nl2\nl3') expect(textOf(await run(read, { path: 'f.txt', offset: 1, limit: 3 }))).toBe('l1\nl2\nl3') }) it('counts a trailing newline as a line (unlike truncate.ts, which drops it)', async () => { // 'a\nb\n' splits into ['a','b',''] here, so the empty trailing entry is a 3rd line. - await fsp.writeFile(`${WS}/f.txt`, 'a\nb\n') + await fsp.writeFile(`${ws}/f.txt`, 'a\nb\n') expect(textOf(await run(read, { path: 'f.txt', offset: 1, limit: 1 }))).toBe( 'a\n\n[2 more lines in file. Use offset=2 to continue.]', ) }) it('clamps a negative offset to the start of the file', async () => { - await fsp.writeFile(`${WS}/f.txt`, 'l1\nl2\nl3') + await fsp.writeFile(`${ws}/f.txt`, 'l1\nl2\nl3') expect(textOf(await run(read, { path: 'f.txt', offset: -5, limit: 1 }))).toBe( 'l1\n\n[2 more lines in file. Use offset=2 to continue.]', ) }) it('handles limit=0 (selects no lines) and still reports the remaining count', async () => { - await fsp.writeFile(`${WS}/f.txt`, 'l1\nl2\nl3') + await fsp.writeFile(`${ws}/f.txt`, 'l1\nl2\nl3') expect(textOf(await run(read, { path: 'f.txt', offset: 1, limit: 0 }))).toBe( '\n\n[3 more lines in file. Use offset=1 to continue.]', ) }) it('throws when the offset is beyond end of file', async () => { - await fsp.writeFile(`${WS}/f.txt`, 'only\ntwo') + await fsp.writeFile(`${ws}/f.txt`, 'only\ntwo') await expect(run(read, { path: 'f.txt', offset: 10 })).rejects.toThrow( 'Offset 10 is beyond end of file (2 lines total)', ) }) it('head-truncates very long files and emits a line-based continuation footer', async () => { - const content = Array.from({ length: DEFAULT_MAX_LINES + 1 }, (_, i) => `row${i}`).join('\n') - await fsp.writeFile(`${WS}/big.txt`, content) + const content = Array.from({ length: defaultMaxLines + 1 }, (_, i) => `row${i}`).join('\n') + await fsp.writeFile(`${ws}/big.txt`, content) const out = textOf(await run(read, { path: 'big.txt' })) - expect(out).toContain(`[Showing lines 1-${DEFAULT_MAX_LINES} of ${DEFAULT_MAX_LINES + 1}. Use offset=${DEFAULT_MAX_LINES + 1} to continue.]`) + expect(out).toContain(`[Showing lines 1-${defaultMaxLines} of ${defaultMaxLines + 1}. Use offset=${defaultMaxLines + 1} to continue.]`) expect(out).not.toContain('limit)') // line-based truncation has no "(50.0KB limit)" note }) @@ -123,27 +123,27 @@ describe('read tool', () => { // 100 lines × ~600 bytes = ~60KB: under the 2000-line limit but over the 50KB // byte limit, so truncation is byte-based and the footer carries the limit note. const content = Array.from({ length: 100 }, (_, i) => `${i}:${'y'.repeat(600)}`).join('\n') - await fsp.writeFile(`${WS}/wide.txt`, content) + await fsp.writeFile(`${ws}/wide.txt`, content) const out = textOf(await run(read, { path: 'wide.txt' })) expect(out).toContain('(50.0KB limit). Use offset=') expect(out.startsWith('0:')).toBe(true) // head truncation keeps the FIRST lines }) it('shifts the displayed line numbers and next offset when reading from an offset into a truncated region', async () => { - const content = Array.from({ length: DEFAULT_MAX_LINES + 10 }, (_, i) => `row${i}`).join('\n') - await fsp.writeFile(`${WS}/big.txt`, content) + const content = Array.from({ length: defaultMaxLines + 10 }, (_, i) => `row${i}`).join('\n') + await fsp.writeFile(`${ws}/big.txt`, content) const out = textOf(await run(read, { path: 'big.txt', offset: 5 })) - // From offset 5, the kept window is DEFAULT_MAX_LINES lines: 5 .. 5+2000-1 = 2004. + // From offset 5, the kept window is defaultMaxLines lines: 5 .. 5+2000-1 = 2004. expect(out).toContain( - `[Showing lines 5-${5 + DEFAULT_MAX_LINES - 1} of ${DEFAULT_MAX_LINES + 10}. Use offset=${5 + DEFAULT_MAX_LINES} to continue.]`, + `[Showing lines 5-${5 + defaultMaxLines - 1} of ${defaultMaxLines + 10}. Use offset=${5 + defaultMaxLines} to continue.]`, ) }) it('returns the sed escape hint (no content) when a single line exceeds the byte limit', async () => { - await fsp.writeFile(`${WS}/wide.txt`, 'x'.repeat(60 * 1024)) + await fsp.writeFile(`${ws}/wide.txt`, 'x'.repeat(60 * 1024)) const out = textOf(await run(read, { path: 'wide.txt' })) expect(out).toContain('[Line 1 is 60.0KB, exceeds 50.0KB limit.') - expect(out).toContain("sed -n '1p' wide.txt | head -c " + DEFAULT_MAX_BYTES) + expect(out).toContain("sed -n '1p' wide.txt | head -c " + defaultMaxBytes) }) it('rejects an absolute path that escapes the workspace jail', async () => { @@ -172,24 +172,24 @@ describe('write tool', () => { it('writes content and reports the UTF-8 byte count and path', async () => { const out = textOf(await run(write, { path: 'out.txt', content: 'hello' })) expect(out).toBe('Successfully wrote 5 bytes to out.txt') - expect(await fsp.readFile(`${WS}/out.txt`, { encoding: 'utf8' })).toBe('hello') + expect(await fsp.readFile(`${ws}/out.txt`, { encoding: 'utf8' })).toBe('hello') }) it('reports the UTF-8 byte length for multibyte content', async () => { const out = textOf(await run(write, { path: 'mb.txt', content: '€€' })) expect(out).toBe('Successfully wrote 6 bytes to mb.txt') - expect(Buffer.byteLength(await fsp.readFile(`${WS}/mb.txt`, { encoding: 'utf8' }), 'utf-8')).toBe(6) + expect(Buffer.byteLength(await fsp.readFile(`${ws}/mb.txt`, { encoding: 'utf8' }), 'utf-8')).toBe(6) }) it('creates missing parent directories', async () => { await run(write, { path: 'nested/deep/file.txt', content: 'x' }) - expect(await fsp.readFile(`${WS}/nested/deep/file.txt`, { encoding: 'utf8' })).toBe('x') + expect(await fsp.readFile(`${ws}/nested/deep/file.txt`, { encoding: 'utf8' })).toBe('x') }) it('overwrites an existing file', async () => { - await fsp.writeFile(`${WS}/o.txt`, 'old') + await fsp.writeFile(`${ws}/o.txt`, 'old') await run(write, { path: 'o.txt', content: 'new' }) - expect(await fsp.readFile(`${WS}/o.txt`, { encoding: 'utf8' })).toBe('new') + expect(await fsp.readFile(`${ws}/o.txt`, { encoding: 'utf8' })).toBe('new') }) it('rejects writing outside the workspace jail', async () => { @@ -233,7 +233,7 @@ describe('edit tool – argument preparation', () => { describe('edit tool – execution', () => { it('throws when no replacements are provided', async () => { - await fsp.writeFile(`${WS}/f.txt`, 'abc') + await fsp.writeFile(`${ws}/f.txt`, 'abc') await expect(run(edit, { path: 'f.txt', edits: [] })).rejects.toThrow( 'edits must contain at least one replacement', ) @@ -252,47 +252,47 @@ describe('edit tool – execution', () => { }) it('propagates a match failure and leaves the file unchanged when oldText is absent', async () => { - await fsp.writeFile(`${WS}/f.txt`, 'hello world') + await fsp.writeFile(`${ws}/f.txt`, 'hello world') await expect(run(edit, { path: 'f.txt', edits: [{ oldText: 'zzz', newText: 'q' }] })).rejects.toThrow() - expect(await fsp.readFile(`${WS}/f.txt`, { encoding: 'utf8' })).toBe('hello world') + expect(await fsp.readFile(`${ws}/f.txt`, { encoding: 'utf8' })).toBe('hello world') }) it('composes prepareArguments with execute (stringified edits applied end-to-end)', async () => { - await fsp.writeFile(`${WS}/f.txt`, 'hello world') + await fsp.writeFile(`${ws}/f.txt`, 'hello world') const prepared = (edit.prepareArguments as (i: unknown) => unknown)({ path: 'f.txt', edits: '[{"oldText":"world","newText":"pi"}]', }) expect(textOf(await run(edit, prepared))).toBe('Successfully replaced 1 block(s) in f.txt.') - expect(await fsp.readFile(`${WS}/f.txt`, { encoding: 'utf8' })).toBe('hello pi') + expect(await fsp.readFile(`${ws}/f.txt`, { encoding: 'utf8' })).toBe('hello pi') }) it('applies a replacement and reports the block count', async () => { - await fsp.writeFile(`${WS}/f.txt`, 'hello world') + await fsp.writeFile(`${ws}/f.txt`, 'hello world') const out = textOf(await run(edit, { path: 'f.txt', edits: [{ oldText: 'world', newText: 'pi' }] })) expect(out).toBe('Successfully replaced 1 block(s) in f.txt.') - expect(await fsp.readFile(`${WS}/f.txt`, { encoding: 'utf8' })).toBe('hello pi') + expect(await fsp.readFile(`${ws}/f.txt`, { encoding: 'utf8' })).toBe('hello pi') }) it('preserves CRLF line endings across the edit', async () => { - await fsp.writeFile(`${WS}/crlf.txt`, 'a\r\nb\r\nc') + await fsp.writeFile(`${ws}/crlf.txt`, 'a\r\nb\r\nc') await run(edit, { path: 'crlf.txt', edits: [{ oldText: 'b', newText: 'B' }] }) - expect(await fsp.readFile(`${WS}/crlf.txt`, { encoding: 'utf8' })).toBe('a\r\nB\r\nc') + expect(await fsp.readFile(`${ws}/crlf.txt`, { encoding: 'utf8' })).toBe('a\r\nB\r\nc') }) it('preserves a leading BOM (U+FEFF) across the edit', async () => { - await fsp.writeFile(`${WS}/bom.txt`, '\uFEFFhello') + await fsp.writeFile(`${ws}/bom.txt`, '\uFEFFhello') await run(edit, { path: 'bom.txt', edits: [{ oldText: 'hello', newText: 'bye' }] }) - expect(await fsp.readFile(`${WS}/bom.txt`, { encoding: 'utf8' })).toBe('\uFEFFbye') + expect(await fsp.readFile(`${ws}/bom.txt`, { encoding: 'utf8' })).toBe('\uFEFFbye') }) it('reports the count for multiple blocks', async () => { - await fsp.writeFile(`${WS}/m.txt`, 'a x c') + await fsp.writeFile(`${ws}/m.txt`, 'a x c') const out = textOf( await run(edit, { path: 'm.txt', edits: [{ oldText: 'a', newText: 'A' }, { oldText: 'c', newText: 'C' }] }), ) expect(out).toBe('Successfully replaced 2 block(s) in m.txt.') - expect(await fsp.readFile(`${WS}/m.txt`, { encoding: 'utf8' })).toBe('A x C') + expect(await fsp.readFile(`${ws}/m.txt`, { encoding: 'utf8' })).toBe('A x C') }) }) @@ -335,7 +335,7 @@ describe('bash tool', () => { }) it('truncates long output (tail) and persists the FULL output to the temp file it names', async () => { - const lineCount = DEFAULT_MAX_LINES + 5 + const lineCount = defaultMaxLines + 5 const out = textOf(await run(bash, { command: `for i in $(seq 1 ${lineCount}); do echo line$i; done` })) expect(out).toContain('[Showing lines') // Tail truncation keeps the END: the last line is present, an early line is dropped. diff --git a/shared/agent-core/coding-tools/index.ts b/shared/agent-core/coding-tools/index.ts index d12ebc349..1710c0b76 100644 --- a/shared/agent-core/coding-tools/index.ts +++ b/shared/agent-core/coding-tools/index.ts @@ -50,7 +50,7 @@ import { stripBom, type EditReplacement, } from './edit-apply.ts' -import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead, truncateTail } from './truncate.ts' +import { defaultMaxBytes, defaultMaxLines, formatSize, truncateHead, truncateTail } from './truncate.ts' /** Wrap plain text as a single-block tool result with no structured details. */ const textResult = (text: string): AgentToolResult => ({ @@ -103,7 +103,7 @@ const formatBashOutput = async (env: BrowserExecutionEnv, output: string, emptyT if (truncation.truncatedBy === 'lines') { return `${truncation.content}\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}.${fullOutputNote}]` } - return `${truncation.content}\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit).${fullOutputNote}]` + return `${truncation.content}\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(defaultMaxBytes)} limit).${fullOutputNote}]` } /** `${text}\n\n${status}`, or just `status` when there is no captured output. */ @@ -114,7 +114,7 @@ const buildBashTool = (env: BrowserExecutionEnv, cwd: string): AgentTool` framing. const text = await formatBashOutput(env, chunks.join(''), '') if (error instanceof Error && error.message === 'aborted') { - throw new Error(appendStatus(text, 'Command aborted')) + throw new Error(appendStatus(text, 'Command aborted'), { cause: error }) } if (error instanceof Error && error.message.startsWith('timeout:')) { - throw new Error(appendStatus(text, `Command timed out after ${error.message.split(':')[1]} seconds`)) + throw new Error(appendStatus(text, `Command timed out after ${error.message.split(':')[1]} seconds`), { + cause: error, + }) } throw error } @@ -181,12 +183,12 @@ const formatReadOutput = ( if (truncation.firstLineExceedsLimit) { const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], 'utf-8')) - return `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${rawPath} | head -c ${DEFAULT_MAX_BYTES}]` + return `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(defaultMaxBytes)} limit. Use bash: sed -n '${startLineDisplay}p' ${rawPath} | head -c ${defaultMaxBytes}]` } if (truncation.truncated) { const endLineDisplay = startLineDisplay + truncation.outputLines - 1 const nextOffset = endLineDisplay + 1 - const limitNote = truncation.truncatedBy === 'lines' ? '' : ` (${formatSize(DEFAULT_MAX_BYTES)} limit)` + const limitNote = truncation.truncatedBy === 'lines' ? '' : ` (${formatSize(defaultMaxBytes)} limit)` return `${truncation.content}\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}${limitNote}. Use offset=${nextOffset} to continue.]` } if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) { @@ -202,7 +204,7 @@ const buildReadTool = (cwd: string): AgentTool => { return { name: 'read', label: 'read', - description: `Read the contents of a file. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`, + description: `Read the contents of a file. For text files, output is truncated to ${defaultMaxLines} lines or ${defaultMaxBytes / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`, parameters: readSchema, async execute(_toolCallId, { path, offset, limit }, signal) { const absolutePath = resolveInWorkspace(cwd, path) @@ -340,7 +342,7 @@ const buildEditTool = (cwd: string): AgentTool => { await operations.access(absolutePath) } catch (error) { const reason = error instanceof Error && 'code' in error ? `Error code: ${error.code}` : String(error) - throw new Error(`Could not edit file: ${path}. ${reason}.`) + throw new Error(`Could not edit file: ${path}. ${reason}.`, { cause: error }) } throwIfAborted(signal) const buffer = await operations.readFile(absolutePath) diff --git a/shared/agent-core/coding-tools/truncate.test.ts b/shared/agent-core/coding-tools/truncate.test.ts index f7996d0b5..ef3938d6d 100644 --- a/shared/agent-core/coding-tools/truncate.test.ts +++ b/shared/agent-core/coding-tools/truncate.test.ts @@ -12,7 +12,7 @@ */ import { describe, expect, it } from 'bun:test' -import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead, truncateTail } from './truncate.ts' +import { defaultMaxBytes, defaultMaxLines, formatSize, truncateHead, truncateTail } from './truncate.ts' describe('formatSize', () => { it('renders sub-KB byte counts with a B suffix', () => { @@ -24,7 +24,7 @@ describe('formatSize', () => { it('switches to KB exactly at 1024 bytes with one decimal', () => { expect(formatSize(1024)).toBe('1.0KB') expect(formatSize(1536)).toBe('1.5KB') - expect(formatSize(DEFAULT_MAX_BYTES)).toBe('50.0KB') + expect(formatSize(defaultMaxBytes)).toBe('50.0KB') }) it('switches to MB exactly at 1024*1024 bytes', () => { @@ -121,12 +121,12 @@ describe('truncateHead', () => { }) it('uses the default limits when no options are passed', () => { - const content = Array.from({ length: DEFAULT_MAX_LINES + 1 }, (_, i) => `${i}`).join('\n') + const content = Array.from({ length: defaultMaxLines + 1 }, (_, i) => `${i}`).join('\n') const r = truncateHead(content) expect(r.truncated).toBe(true) expect(r.truncatedBy).toBe('lines') - expect(r.outputLines).toBe(DEFAULT_MAX_LINES) - expect(r.maxBytes).toBe(DEFAULT_MAX_BYTES) + expect(r.outputLines).toBe(defaultMaxLines) + expect(r.maxBytes).toBe(defaultMaxBytes) }) }) @@ -205,13 +205,13 @@ describe('truncateTail', () => { }) it('uses the default limits when no options are passed', () => { - const content = Array.from({ length: DEFAULT_MAX_LINES + 1 }, (_, i) => `${i}`).join('\n') + const content = Array.from({ length: defaultMaxLines + 1 }, (_, i) => `${i}`).join('\n') const r = truncateTail(content) expect(r.truncated).toBe(true) expect(r.truncatedBy).toBe('lines') - expect(r.outputLines).toBe(DEFAULT_MAX_LINES) + expect(r.outputLines).toBe(defaultMaxLines) // The last line is retained, the first dropped. - expect(r.content.endsWith(`${DEFAULT_MAX_LINES}`)).toBe(true) + expect(r.content.endsWith(`${defaultMaxLines}`)).toBe(true) expect(r.content.startsWith('0\n')).toBe(false) }) }) diff --git a/shared/agent-core/coding-tools/truncate.ts b/shared/agent-core/coding-tools/truncate.ts index af1fa1bf3..b33ba52ee 100644 --- a/shared/agent-core/coding-tools/truncate.ts +++ b/shared/agent-core/coding-tools/truncate.ts @@ -10,17 +10,17 @@ * tools indistinguishable from the CLI's for the model. * * Truncation honours two independent limits (whichever is hit first wins): - * - line limit ({@link DEFAULT_MAX_LINES}) - * - byte limit ({@link DEFAULT_MAX_BYTES}) + * - line limit ({@link defaultMaxLines}) + * - byte limit ({@link defaultMaxBytes}) * * `Buffer` is used for byte counting (UTF-8 aware); the app installs the global * `Buffer` polyfill (`ensureBufferPolyfill`) before any tool runs. */ /** Maximum lines retained before truncation kicks in. */ -export const DEFAULT_MAX_LINES = 2000 +export const defaultMaxLines = 2000 /** Maximum bytes retained before truncation kicks in (50KB). */ -export const DEFAULT_MAX_BYTES = 50 * 1024 +export const defaultMaxBytes = 50 * 1024 /** Result of a head/tail truncation pass. */ export type TruncationResult = { @@ -73,8 +73,8 @@ export const formatSize = (bytes: number): string => { * @param options - optional line/byte limit overrides */ export const truncateHead = (content: string, options: TruncateOptions = {}): TruncationResult => { - const maxLines = options.maxLines ?? DEFAULT_MAX_LINES - const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES + const maxLines = options.maxLines ?? defaultMaxLines + const maxBytes = options.maxBytes ?? defaultMaxBytes const totalBytes = Buffer.byteLength(content, 'utf-8') const lines = splitLinesForCounting(content) const totalLines = lines.length @@ -167,8 +167,8 @@ const truncateStringToBytesFromEnd = (text: string, maxBytes: number): string => * @param options - optional line/byte limit overrides */ export const truncateTail = (content: string, options: TruncateOptions = {}): TruncationResult => { - const maxLines = options.maxLines ?? DEFAULT_MAX_LINES - const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES + const maxLines = options.maxLines ?? defaultMaxLines + const maxBytes = options.maxBytes ?? defaultMaxBytes const totalBytes = Buffer.byteLength(content, 'utf-8') const lines = splitLinesForCounting(content) const totalLines = lines.length diff --git a/shared/agent-core/environment-prompt.test.ts b/shared/agent-core/environment-prompt.test.ts index 4050e00d9..f3dffeccc 100644 --- a/shared/agent-core/environment-prompt.test.ts +++ b/shared/agent-core/environment-prompt.test.ts @@ -3,12 +3,12 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { describe, expect, it } from 'bun:test' -import { APP_HARNESS_ENVIRONMENT_PROMPT } from './environment-prompt.ts' +import { appHarnessEnvironmentPrompt } from './environment-prompt.ts' -describe('APP_HARNESS_ENVIRONMENT_PROMPT', () => { +describe('appHarnessEnvironmentPrompt', () => { it('documents sandbox network constraints without assuming optional web tools exist', () => { - expect(APP_HARNESS_ENVIRONMENT_PROMPT).toContain('no network access') - expect(APP_HARNESS_ENVIRONMENT_PROMPT).toContain('`curl` and `wget` are unavailable') - expect(APP_HARNESS_ENVIRONMENT_PROMPT).toContain('when available') + expect(appHarnessEnvironmentPrompt).toContain('no network access') + expect(appHarnessEnvironmentPrompt).toContain('`curl` and `wget` are unavailable') + expect(appHarnessEnvironmentPrompt).toContain('when available') }) }) diff --git a/shared/agent-core/environment-prompt.ts b/shared/agent-core/environment-prompt.ts index 5b38a0eda..2102f5b2a 100644 --- a/shared/agent-core/environment-prompt.ts +++ b/shared/agent-core/environment-prompt.ts @@ -6,7 +6,7 @@ * Browser-only execution constraints for the Pi app harness. This stays outside * the base app prompt so legacy and CLI model paths do not receive it. */ -export const APP_HARNESS_ENVIRONMENT_PROMPT = `# Environment +export const appHarnessEnvironmentPrompt = `# Environment The \`bash\`, \`read\`, \`write\`, and \`edit\` tools operate in an isolated virtual workspace private to this conversation. \`bash\` is a simulated shell with no network access; \`curl\` and \`wget\` are unavailable. Available inside \`bash\`: coreutils, \`grep\`, \`find\`, \`sed\`, \`awk\`, \`rg\`, \`jq\`, \`sqlite3\`, and \`tar\`. Use it for computation and data processing. For anything on the web, use web search or fetch tools when available; never use \`bash\` for network access. diff --git a/shared/agent-core/mcp-tools.test.ts b/shared/agent-core/mcp-tools.test.ts index 92bfa4282..c86229bfe 100644 --- a/shared/agent-core/mcp-tools.test.ts +++ b/shared/agent-core/mcp-tools.test.ts @@ -123,7 +123,7 @@ describe('toPiAgentTools — execute routing + result mapping', () => { }) it('uses the final chunk of an async-iterable (streaming) execute result', async () => { - async function* stream() { + const stream = async function* () { yield { partial: 1 } yield { partial: 2 } yield { final: true } @@ -135,7 +135,7 @@ describe('toPiAgentTools — execute routing + result mapping', () => { }) it('treats an EMPTY async-iterable result as a "null" output (no final chunk)', async () => { - async function* empty() { + const empty = async function* () { // yields nothing } const [pi] = await toPiAgentTools({ t: makeTool({ execute: () => empty() as ReturnType }) }) diff --git a/shared/agent-core/openai-compat-model.ts b/shared/agent-core/openai-compat-model.ts index 3722d2953..68e4f8100 100644 --- a/shared/agent-core/openai-compat-model.ts +++ b/shared/agent-core/openai-compat-model.ts @@ -57,17 +57,17 @@ import { import type { AgentFetch } from './anthropic-model.ts' /** The Pi API this provider exclusively serves. */ -const API = 'openai-completions' +const apiName = 'openai-completions' /** Context window used when the app model carries none. Only consumed by Pi's * token-budget math (irrelevant to openai-completions, which never caps tokens * unless the caller passes `maxTokens`), so a generous default is harmless. */ -const DEFAULT_CONTEXT_WINDOW = 128_000 +const defaultContextWindow = 128_000 /** Advisory max-output budget on the synthetic model. openai-completions only * sends `max_completion_tokens` when the caller sets `options.maxTokens` (the * harness does not), so this value never reaches the wire — it exists solely to * satisfy the `Model` shape. */ -const DEFAULT_MAX_TOKENS = 8_192 +const defaultMaxTokens = 8_192 /** Inputs for {@link buildOpenAiCompatModel}. */ export type BuildOpenAiCompatModelOptions = { @@ -119,9 +119,9 @@ const withInjectedFetch = (fetchImpl: AgentFetch, run: () => T): T => { * exclusively serves, surfacing misuse loudly rather than guessing. Mirrors * {@link buildAnthropicModel}'s `requireAnthropic`. */ -const requireOpenAiCompletions = (model: Model): Model => { - if (!hasApi(model, API)) { - throw new Error(`Expected an "${API}" model, got "${model.api}".`) +const requireOpenAiCompletions = (model: Model): Model => { + if (!hasApi(model, apiName)) { + throw new Error(`Expected an "${apiName}" model, got "${model.api}".`) } return model } @@ -129,17 +129,17 @@ const requireOpenAiCompletions = (model: Model): Model => { /** Synthesize the Pi `Model<"openai-completions">` descriptor. The app's models * live outside Pi's built-in catalog (custom URLs, backend-proxied ids), so we * build the descriptor directly rather than resolving it. */ -const synthesizeModel = (opts: BuildOpenAiCompatModelOptions): Model => ({ +const synthesizeModel = (opts: BuildOpenAiCompatModelOptions): Model => ({ id: opts.modelId, name: opts.modelId, - api: API, + api: apiName, provider: opts.providerId, baseUrl: opts.baseURL, reasoning: opts.reasoning, input: ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: opts.contextWindow ?? DEFAULT_CONTEXT_WINDOW, - maxTokens: DEFAULT_MAX_TOKENS, + contextWindow: opts.contextWindow ?? defaultContextWindow, + maxTokens: defaultMaxTokens, }) /** diff --git a/shared/agent-tool-permissions.ts b/shared/agent-tool-permissions.ts index c0fa25e11..96c7b5f0e 100644 --- a/shared/agent-tool-permissions.ts +++ b/shared/agent-tool-permissions.ts @@ -49,9 +49,9 @@ export const resolveToolPermission = ( outcome: AcpRequestPermissionOutcome, options: readonly AcpPermissionOption[], ): ToolPermissionDecision => { - if (outcome.outcome === 'cancelled') return 'reject' + if (outcome.outcome === 'cancelled') {return 'reject'} const selected = options.find((option) => option.optionId === outcome.optionId) - if (selected?.kind === 'allow_always') return 'allow-always' - if (selected?.kind === 'allow_once') return 'allow-once' + if (selected?.kind === 'allow_always') {return 'allow-always'} + if (selected?.kind === 'allow_once') {return 'allow-once'} return 'reject' } diff --git a/shared/ip-classification.test.ts b/shared/ip-classification.test.ts index 338ef33a3..b0ee69304 100644 --- a/shared/ip-classification.test.ts +++ b/shared/ip-classification.test.ts @@ -13,7 +13,7 @@ import { /** Parse one test IP literal. */ const parseLiteral = (address: string) => { const parsedAddress = parseIpAddress(address) - if (!parsedAddress) throw new Error(`Expected an IP literal: ${address}`) + if (!parsedAddress) {throw new Error(`Expected an IP literal: ${address}`)} return parsedAddress } @@ -23,7 +23,7 @@ const classify = (address: string): boolean => isPrivateOrInternalAddress(parseL /** Parse one test IPv4 literal. */ const parseIpv4Literal = (address: string) => { const parsedAddress = parseLiteral(address) - if (parsedAddress.version !== 4) throw new Error(`Expected an IPv4 literal: ${address}`) + if (parsedAddress.version !== 4) {throw new Error(`Expected an IPv4 literal: ${address}`)} return parsedAddress } diff --git a/shared/ip-classification.ts b/shared/ip-classification.ts index 86f2db8c0..25e210837 100644 --- a/shared/ip-classification.ts +++ b/shared/ip-classification.ts @@ -55,9 +55,9 @@ const teredoNetwork = 0x20010000000000000000000000000000n /** Parse canonical dotted-decimal IPv4 into a 32-bit integer. */ const parseIpv4 = (address: string): bigint | undefined => { const parts = address.split('.') - if (parts.length !== 4 || parts.some((part) => !/^\d{1,3}$/.test(part))) return undefined + if (parts.length !== 4 || parts.some((part) => !/^\d{1,3}$/.test(part))) {return undefined} const bytes = parts.map(Number) - if (bytes.some((byte) => byte > 255)) return undefined + if (bytes.some((byte) => byte > 255)) {return undefined} return bytes.reduce((value, byte) => (value << 8n) | BigInt(byte), 0n) } @@ -65,34 +65,34 @@ const parseIpv4 = (address: string): bigint | undefined => { const parseIpv6 = (address: string): bigint | undefined => { const hasOpeningBracket = address.startsWith('[') const hasClosingBracket = address.endsWith(']') - if (hasOpeningBracket !== hasClosingBracket) return undefined + if (hasOpeningBracket !== hasClosingBracket) {return undefined} const unwrappedAddress = hasOpeningBracket ? address.slice(1, -1) : address const normalizedAddress = unwrappedAddress.split('%')[0] - if (normalizedAddress === undefined || !normalizedAddress.includes(':')) return undefined + if (normalizedAddress === undefined || !normalizedAddress.includes(':')) {return undefined} const dottedTail = normalizedAddress.match(/(?:^|:)(\d{1,3}(?:\.\d{1,3}){3})$/)?.[1] const ipv4Tail = dottedTail ? parseIpv4(dottedTail) : undefined - if (dottedTail && ipv4Tail === undefined) return undefined + if (dottedTail && ipv4Tail === undefined) {return undefined} const expandedAddress = dottedTail && ipv4Tail !== undefined ? normalizedAddress.replace(dottedTail, `${(ipv4Tail >> 16n).toString(16)}:${(ipv4Tail & 0xffffn).toString(16)}`) : normalizedAddress - if ((expandedAddress.match(/::/g) ?? []).length > 1) return undefined + if ((expandedAddress.match(/::/g) ?? []).length > 1) {return undefined} const [left = '', right] = expandedAddress.split('::') const leftGroups = left ? left.split(':') : [] const rightGroups = right ? right.split(':') : [] const missingGroups = 8 - leftGroups.length - rightGroups.length - if ((right === undefined && missingGroups !== 0) || (right !== undefined && missingGroups < 1)) return undefined + if ((right === undefined && missingGroups !== 0) || (right !== undefined && missingGroups < 1)) {return undefined} const groups = [...leftGroups, ...Array.from({ length: missingGroups }, () => '0'), ...rightGroups] - if (groups.length !== 8 || groups.some((group) => !/^[\da-f]{1,4}$/i.test(group))) return undefined + if (groups.length !== 8 || groups.some((group) => !/^[\da-f]{1,4}$/i.test(group))) {return undefined} return groups.reduce((value, group) => (value << 16n) | BigInt(`0x${group}`), 0n) } /** Parse an IP literal while leaving domain names unresolved. */ export const parseIpAddress = (address: string): ParsedIpAddress | undefined => { const ipv4 = parseIpv4(address) - if (ipv4 !== undefined) return { version: 4, value: ipv4 } + if (ipv4 !== undefined) {return { version: 4, value: ipv4 }} const ipv6 = parseIpv6(address) return ipv6 === undefined ? undefined : { version: 6, value: ipv6 } } @@ -105,9 +105,9 @@ const isInCidr = (value: bigint, network: bigint, prefixLength: number, addressB /** Extract IPv4 embedded by an IPv6 mapping or transition mechanism. */ export const extractEmbeddedIpv4Address = (address: ParsedIpAddress): EmbeddedIpv4Address | undefined => { - if (address.version === 4) return undefined + if (address.version === 4) {return undefined} const ipv6Value = address.value - if (typeof ipv6Value !== 'bigint') return undefined + if (typeof ipv6Value !== 'bigint') {return undefined} if (isInCidr(ipv6Value, ipv4MappedNetwork, 96, 128)) { return { mechanism: 'ipv4-mapped', address: { version: 4, value: ipv6Value & ipv4Mask } } } @@ -133,10 +133,10 @@ export const isPrivateOrInternalAddress = (address: ParsedIpAddress): boolean => } const embeddedIpv4 = extractEmbeddedIpv4Address(address) - if (embeddedIpv4?.mechanism === 'ipv4-mapped') return isPrivateOrInternalAddress(embeddedIpv4.address) - if (embeddedIpv4 && isPrivateOrInternalAddress(embeddedIpv4.address)) return true + if (embeddedIpv4?.mechanism === 'ipv4-mapped') {return isPrivateOrInternalAddress(embeddedIpv4.address)} + if (embeddedIpv4 && isPrivateOrInternalAddress(embeddedIpv4.address)) {return true} const isGlobalUnicast = isInCidr(address.value, 0x20000000000000000000000000000000n, 3, 128) - if (!isGlobalUnicast) return true + if (!isGlobalUnicast) {return true} return blockedIpv6Ranges.some(({ network, prefixLength }) => isInCidr(address.value, network, prefixLength, 128)) } diff --git a/shared/url.ts b/shared/url.ts index b0300ad36..537f4d36a 100644 --- a/shared/url.ts +++ b/shared/url.ts @@ -12,7 +12,7 @@ export const deriveFaviconUrl = (pageUrl: string): string | null => { try { const { origin, protocol } = new URL(pageUrl) - if (protocol !== 'https:') return null + if (protocol !== 'https:') {return null} return `${origin}/favicon.ico` } catch { return null diff --git a/src/acp/built-in-adapter.test.ts b/src/acp/built-in-adapter.test.ts index c6424637b..351c73442 100644 --- a/src/acp/built-in-adapter.test.ts +++ b/src/acp/built-in-adapter.test.ts @@ -23,7 +23,7 @@ import { type ResolvedPiModel, } from './built-in-adapter' import type { BuildAppHarnessOptions, PiModelDescriptor } from '@shared/agent-core' -import { APP_HARNESS_ENVIRONMENT_PROMPT } from '@shared/agent-core/environment-prompt' +import { appHarnessEnvironmentPrompt } from '@shared/agent-core/environment-prompt' import type { AgentHarness, AgentTool } from '@earendil-works/pi-agent-core' const noopFetch = (async () => new Response('')) as PiModelDescriptor['fetch'] @@ -237,7 +237,7 @@ describe('createBuiltInAdapter persistent harness', () => { const firstSystemPrompt = buildCalls[0]?.systemPrompt as () => string const secondSystemPrompt = buildCalls[1]?.systemPrompt as () => string const expectedPrompt = (timestamp: string): string => - `stable prompt\n\n${APP_HARNESS_ENVIRONMENT_PROMPT}\n\n${timestamp}` + `stable prompt\n\n${appHarnessEnvironmentPrompt}\n\n${timestamp}` expect(seededSystemPrompts).toEqual([expectedPrompt('timestamp 1'), expectedPrompt('timestamp 3')]) expect(firstSystemPrompt()).toBe(expectedPrompt('timestamp 2')) expect(secondSystemPrompt()).toBe(expectedPrompt('timestamp 3')) diff --git a/src/acp/built-in-adapter.ts b/src/acp/built-in-adapter.ts index 6cf5c6bc0..931441f7e 100644 --- a/src/acp/built-in-adapter.ts +++ b/src/acp/built-in-adapter.ts @@ -56,7 +56,7 @@ import type { Agent, AgentAdapter, AgentAdapterContext } from '@/types/acp' import type { Model, ModelProfile, ThunderboltUIMessage } from '@/types' import { extractLastUserText, resolveSkillTokenInstructions } from '@/skills/resolve-skill-system-messages' import type { PiModelDescriptor, SeedTurn } from '@shared/agent-core' -import { APP_HARNESS_ENVIRONMENT_PROMPT } from '@shared/agent-core/environment-prompt' +import { appHarnessEnvironmentPrompt } from '@shared/agent-core/environment-prompt' import type { AgentHarness, AgentTool, ThinkingLevel } from '@earendil-works/pi-agent-core' import { prepareBuiltInConversation } from './built-in-conversation' @@ -304,7 +304,7 @@ export const harnessSignature = ( /** Compose Pi's cacheable prompt prefix while keeping the per-send timestamp last. */ const composeAppHarnessSystemPrompt = (config: AppHarnessSystemPromptConfig): string => - `${config.stableSystemPrompt}\n\n${APP_HARNESS_ENVIRONMENT_PROMPT}\n\n${config.volatileSystemPrompt}` + `${config.stableSystemPrompt}\n\n${appHarnessEnvironmentPrompt}\n\n${config.volatileSystemPrompt}` /** Build a thread's harness from the lazily-loaded engine and bind it to the * thread's isolated workspace with resolved model + thinking level. Per-send app diff --git a/src/ai/prompt.test.ts b/src/ai/prompt.test.ts index 643afe8a0..b835419c7 100644 --- a/src/ai/prompt.test.ts +++ b/src/ai/prompt.test.ts @@ -5,7 +5,7 @@ import { describe, expect, test } from 'bun:test' import type { ModelProfile } from '@/types' import { widgetRegistry } from '@/widgets' -import { APP_HARNESS_ENVIRONMENT_PROMPT } from '@shared/agent-core/environment-prompt' +import { appHarnessEnvironmentPrompt } from '@shared/agent-core/environment-prompt' import { createPrompt, createPromptParts, type PromptParams } from './prompt' const createStubProfile = (overrides: Partial = {}): ModelProfile => ({ @@ -228,6 +228,6 @@ describe('createPrompt', () => { test('does not include the Pi app harness environment', () => { const result = createPromptParts(baseParams) - expect(result.fullPrompt).not.toContain(APP_HARNESS_ENVIRONMENT_PROMPT) + expect(result.fullPrompt).not.toContain(appHarnessEnvironmentPrompt) }) }) From b41e6b6650da78af37f074637699ff3660bda288 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Thu, 30 Jul 2026 09:19:19 -0300 Subject: [PATCH 2/3] docs: sync EFFORTS.find comment to efforts.find --- shared/agent-core/anthropic-model.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/agent-core/anthropic-model.ts b/shared/agent-core/anthropic-model.ts index f05c8a81a..3aaca0b47 100644 --- a/shared/agent-core/anthropic-model.ts +++ b/shared/agent-core/anthropic-model.ts @@ -110,7 +110,7 @@ const requireAnthropic = (model: Model): Model => { */ const mapThinkingLevelToEffort = (model: Model, level: ThinkingLevel): AnthropicEffort => { const mapped = model.thinkingLevelMap?.[level] - // `EFFORTS.find` validates the catalog override as a real effort (type-safe, + // `efforts.find` validates the catalog override as a real effort (type-safe, // no cast). Every Anthropic catalog override is valid, so this matches Pi. const override = typeof mapped === 'string' ? efforts.find((effort) => effort === mapped) : undefined if (override) {return override} From 8f6cf0d4d879c3887862e1047f8a97fda7732a48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=AD=20Adriano?= Date: Thu, 30 Jul 2026 10:04:17 -0300 Subject: [PATCH 3/3] chore: extend prettier format scripts to shared/ --- package.json | 4 +- shared/agent-core/anthropic-model.test.ts | 8 +- shared/agent-core/anthropic-model.ts | 12 +- .../browser-env/browser-execution-env.test.ts | 32 +++-- .../browser-env/browser-execution-env.ts | 128 +++++++++++++----- .../coding-tool-operations.test.ts | 8 +- .../agent-core/browser-env/fs-helpers.test.ts | 19 ++- shared/agent-core/browser-env/fs-helpers.ts | 28 +++- shared/agent-core/browser-env/mount.test.ts | 4 +- shared/agent-core/coding-tools/index.test.ts | 45 +++--- shared/agent-core/pi-to-aisdk-stream.test.ts | 7 +- shared/agent-tool-permissions.ts | 16 ++- shared/ip-classification.test.ts | 8 +- shared/ip-classification.ts | 56 ++++++-- shared/url.ts | 4 +- 15 files changed, 274 insertions(+), 105 deletions(-) diff --git a/package.json b/package.json index 6ddc845e5..90f8d500c 100644 --- a/package.json +++ b/package.json @@ -28,10 +28,10 @@ "eval": "bun run src/ai/eval/run.ts", "lint": "eslint src shared", "lint:fix": "eslint src shared --fix", - "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", + "format": "prettier --write \"{src,shared}/**/*.{ts,tsx,js,jsx,json,css,md}\"", "format:rust": "cd src-tauri && cargo fmt && cd ..", "format:rust-check": "cd src-tauri && cargo fmt --check && cd ..", - "format-check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", + "format-check": "prettier --check \"{src,shared}/**/*.{ts,tsx,js,jsx,json,css,md}\"", "type-check": "tsc --noEmit", "license:check": "bun scripts/license-headers.ts --check", "license:fix": "bun scripts/license-headers.ts", diff --git a/shared/agent-core/anthropic-model.test.ts b/shared/agent-core/anthropic-model.test.ts index e65fa3ac9..dcbb3793a 100644 --- a/shared/agent-core/anthropic-model.test.ts +++ b/shared/agent-core/anthropic-model.test.ts @@ -61,11 +61,15 @@ const drive = async ( const { models, model } = buildAnthropicModel({ apiKey: 'test-key', fetch: injectedFetch, modelId }) const provider = models.getProvider('anthropic') - if (!provider) {throw new Error('anthropic provider not registered')} + if (!provider) { + throw new Error('anthropic provider not registered') + } const stream = provider[entry](model, context, options) try { - for await (const event of stream) {void event} + for await (const event of stream) { + void event + } } catch { // Stream-parse hiccups are irrelevant; the captured request body is the contract. } diff --git a/shared/agent-core/anthropic-model.ts b/shared/agent-core/anthropic-model.ts index 3aaca0b47..5e4abe6cf 100644 --- a/shared/agent-core/anthropic-model.ts +++ b/shared/agent-core/anthropic-model.ts @@ -113,9 +113,15 @@ const mapThinkingLevelToEffort = (model: Model, level: ThinkingLev // `efforts.find` validates the catalog override as a real effort (type-safe, // no cast). Every Anthropic catalog override is valid, so this matches Pi. const override = typeof mapped === 'string' ? efforts.find((effort) => effort === mapped) : undefined - if (override) {return override} - if (level === 'minimal' || level === 'low') {return 'low'} - if (level === 'medium') {return 'medium'} + if (override) { + return override + } + if (level === 'minimal' || level === 'low') { + return 'low' + } + if (level === 'medium') { + return 'medium' + } return 'high' } diff --git a/shared/agent-core/browser-env/browser-execution-env.test.ts b/shared/agent-core/browser-env/browser-execution-env.test.ts index 482907f24..fb7412f06 100644 --- a/shared/agent-core/browser-env/browser-execution-env.test.ts +++ b/shared/agent-core/browser-env/browser-execution-env.test.ts @@ -40,19 +40,25 @@ describe('BrowserExecutionEnv filesystem jail', () => { it('blocks reading a sibling thread workspace without leaking content', async () => { const result = await env.readTextFile('/workspace/t2/secret.txt') expect(result.ok).toBe(false) - if (!result.ok) {expect(result.error.code).toBe('permission_denied')} + if (!result.ok) { + expect(result.error.code).toBe('permission_denied') + } }) it('blocks reading an absolute system path', async () => { const result = await env.readTextFile('/etc/passwd') expect(result.ok).toBe(false) - if (!result.ok) {expect(result.error.code).toBe('permission_denied')} + if (!result.ok) { + expect(result.error.code).toBe('permission_denied') + } }) it('blocks `..` traversal into a sibling', async () => { const result = await env.readTextFile('../t2/secret.txt') expect(result.ok).toBe(false) - if (!result.ok) {expect(result.error.code).toBe('permission_denied')} + if (!result.ok) { + expect(result.error.code).toBe('permission_denied') + } }) it('blocks readBinaryFile and readTextLines outside the jail', async () => { @@ -65,7 +71,9 @@ describe('BrowserExecutionEnv filesystem jail', () => { it('blocks writing outside the workspace and does not create the file', async () => { const result = await env.writeFile('/workspace/t2/pwned.txt', 'x') expect(result.ok).toBe(false) - if (!result.ok) {expect(result.error.code).toBe('permission_denied')} + if (!result.ok) { + expect(result.error.code).toBe('permission_denied') + } expect(await fsp.exists('/workspace/t2/pwned.txt')).toBe(false) }) @@ -84,7 +92,9 @@ describe('BrowserExecutionEnv filesystem jail', () => { it('blocks listing the parent workspace root', async () => { const result = await env.listDir('/workspace') expect(result.ok).toBe(false) - if (!result.ok) {expect(result.error.code).toBe('permission_denied')} + if (!result.ok) { + expect(result.error.code).toBe('permission_denied') + } }) it('blocks fileInfo, canonicalPath and createDir outside the jail', async () => { @@ -100,7 +110,9 @@ describe('BrowserExecutionEnv filesystem jail', () => { it('reports exists() as a permission error for out-of-jail paths (no existence oracle)', async () => { const escape = await env.exists('/etc/passwd') expect(escape.ok).toBe(false) - if (!escape.ok) {expect(escape.error.code).toBe('permission_denied')} + if (!escape.ok) { + expect(escape.error.code).toBe('permission_denied') + } const insideMissing = await env.exists('nope.txt') expect(insideMissing.ok && insideMissing.value).toBe(false) @@ -129,7 +141,9 @@ describe('BrowserExecutionEnv filesystem jail', () => { it('blocks a shell whose cwd escapes the workspace', async () => { const result = await env.exec('echo hi', { cwd: '../t2' }) expect(result.ok).toBe(false) - if (!result.ok) {expect(result.error.message).toContain('cwd escapes workspace')} + if (!result.ok) { + expect(result.error.message).toContain('cwd escapes workspace') + } }) it('emits output produced before a command times out', async () => { @@ -151,7 +165,9 @@ describe('BrowserExecutionEnv filesystem jail', () => { }) expect(result.ok).toBe(false) - if (!result.ok) {expect(result.error.code).toBe('timeout')} + if (!result.ok) { + expect(result.error.code).toBe('timeout') + } expect(stdout.join('')).toBe('partial\n') expect(stderr.join('')).toBe('warning\n') }) diff --git a/shared/agent-core/browser-env/browser-execution-env.ts b/shared/agent-core/browser-env/browser-execution-env.ts index 4b264cc95..61ac73358 100644 --- a/shared/agent-core/browser-env/browser-execution-env.ts +++ b/shared/agent-core/browser-env/browser-execution-env.ts @@ -112,7 +112,9 @@ export class BrowserExecutionEnv implements ExecutionEnv { command: string, options?: ShellExecOptions, ): Promise> { - if (options?.abortSignal?.aborted) {return err(new ExecutionError('aborted', 'aborted'))} + if (options?.abortSignal?.aborted) { + return err(new ExecutionError('aborted', 'aborted')) + } const cwd = options?.cwd ? resolve(this.cwd, options.cwd) : this.cwd if (!isWithinWorkspace(this.cwd, cwd)) { @@ -122,7 +124,9 @@ export class BrowserExecutionEnv implements ExecutionEnv { const controller = new AbortController() const state = { timedOut: false } const onExternalAbort = () => controller.abort() - if (options?.abortSignal) {options.abortSignal.addEventListener('abort', onExternalAbort, { once: true })} + if (options?.abortSignal) { + options.abortSignal.addEventListener('abort', onExternalAbort, { once: true }) + } const timeoutId = typeof options?.timeout === 'number' ? setTimeout(() => { @@ -140,27 +144,47 @@ export class BrowserExecutionEnv implements ExecutionEnv { // virtual ZenFS mount with no host-process access, so we disable it. const bash = this.createBash({ fs: this.bashFs, cwd, env, defenseInDepth: false }) const result = await bash.exec(command, { signal: controller.signal }) - if (result.stdout) {options?.onStdout?.(result.stdout)} - if (result.stderr) {options?.onStderr?.(result.stderr)} - if (state.timedOut) {return err(new ExecutionError('timeout', `timeout:${options?.timeout}`))} - if (options?.abortSignal?.aborted) {return err(new ExecutionError('aborted', 'aborted'))} + if (result.stdout) { + options?.onStdout?.(result.stdout) + } + if (result.stderr) { + options?.onStderr?.(result.stderr) + } + if (state.timedOut) { + return err(new ExecutionError('timeout', `timeout:${options?.timeout}`)) + } + if (options?.abortSignal?.aborted) { + return err(new ExecutionError('aborted', 'aborted')) + } return ok({ stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode }) } catch (error) { - if (state.timedOut) {return err(new ExecutionError('timeout', `timeout:${options?.timeout}`))} - if (controller.signal.aborted) {return err(new ExecutionError('aborted', 'aborted'))} + if (state.timedOut) { + return err(new ExecutionError('timeout', `timeout:${options?.timeout}`)) + } + if (controller.signal.aborted) { + return err(new ExecutionError('aborted', 'aborted')) + } const cause = toError(error) return err(new ExecutionError('unknown', cause.message, cause)) } finally { - if (timeoutId) {clearTimeout(timeoutId)} - if (options?.abortSignal) {options.abortSignal.removeEventListener('abort', onExternalAbort)} + if (timeoutId) { + clearTimeout(timeoutId) + } + if (options?.abortSignal) { + options.abortSignal.removeEventListener('abort', onExternalAbort) + } } } async readTextFile(path: string, abortSignal?: AbortSignal): Promise> { const resolved = this.jailed(path) - if (!resolved.ok) {return resolved} + if (!resolved.ok) { + return resolved + } const aborted = abortedResult(abortSignal, resolved.value) - if (aborted) {return aborted} + if (aborted) { + return aborted + } try { return ok(await fsp.readFile(resolved.value, { encoding: 'utf8' })) } catch (error) { @@ -173,10 +197,16 @@ export class BrowserExecutionEnv implements ExecutionEnv { options?: { maxLines?: number; abortSignal?: AbortSignal }, ): Promise> { const resolved = this.jailed(path) - if (!resolved.ok) {return resolved} + if (!resolved.ok) { + return resolved + } const aborted = abortedResult(options?.abortSignal, resolved.value) - if (aborted) {return aborted} - if (options?.maxLines !== undefined && options.maxLines <= 0) {return ok([])} + if (aborted) { + return aborted + } + if (options?.maxLines !== undefined && options.maxLines <= 0) { + return ok([]) + } try { const lines = splitLines(await fsp.readFile(resolved.value, { encoding: 'utf8' })) return ok(options?.maxLines !== undefined ? lines.slice(0, options.maxLines) : lines) @@ -187,9 +217,13 @@ export class BrowserExecutionEnv implements ExecutionEnv { async readBinaryFile(path: string, abortSignal?: AbortSignal): Promise> { const resolved = this.jailed(path) - if (!resolved.ok) {return resolved} + if (!resolved.ok) { + return resolved + } const aborted = abortedResult(abortSignal, resolved.value) - if (aborted) {return aborted} + if (aborted) { + return aborted + } try { return ok(new Uint8Array(await fsp.readFile(resolved.value))) } catch (error) { @@ -203,13 +237,19 @@ export class BrowserExecutionEnv implements ExecutionEnv { abortSignal?: AbortSignal, ): Promise> { const resolved = this.jailed(path) - if (!resolved.ok) {return resolved} + if (!resolved.ok) { + return resolved + } const aborted = abortedResult(abortSignal, resolved.value) - if (aborted) {return aborted} + if (aborted) { + return aborted + } try { await fsp.mkdir(dirname(resolved.value), { recursive: true }) const afterMkdir = abortedResult(abortSignal, resolved.value) - if (afterMkdir) {return afterMkdir} + if (afterMkdir) { + return afterMkdir + } await fsp.writeFile(resolved.value, content) return ok(undefined) } catch (error) { @@ -219,7 +259,9 @@ export class BrowserExecutionEnv implements ExecutionEnv { async appendFile(path: string, content: string | Uint8Array): Promise> { const resolved = this.jailed(path) - if (!resolved.ok) {return resolved} + if (!resolved.ok) { + return resolved + } try { await fsp.mkdir(dirname(resolved.value), { recursive: true }) await fsp.appendFile(resolved.value, content) @@ -231,7 +273,9 @@ export class BrowserExecutionEnv implements ExecutionEnv { async fileInfo(path: string): Promise> { const resolved = this.jailed(path) - if (!resolved.ok) {return resolved} + if (!resolved.ok) { + return resolved + } try { return fileInfoFrom(resolved.value, await fsp.lstat(resolved.value)) } catch (error) { @@ -241,18 +285,26 @@ export class BrowserExecutionEnv implements ExecutionEnv { async listDir(path: string, abortSignal?: AbortSignal): Promise> { const resolved = this.jailed(path) - if (!resolved.ok) {return resolved} + if (!resolved.ok) { + return resolved + } const aborted = abortedResult(abortSignal, resolved.value) - if (aborted) {return aborted} + if (aborted) { + return aborted + } try { const entries = await fsp.readdir(resolved.value, { withFileTypes: true }) const infos: FileInfo[] = [] for (const entry of entries) { const loopAborted = abortedResult(abortSignal, resolved.value) - if (loopAborted) {return loopAborted} + if (loopAborted) { + return loopAborted + } const childPath = join(resolved.value, entry.name) const info = fileInfoFrom(childPath, await fsp.lstat(childPath)) - if (info.ok) {infos.push(info.value)} + if (info.ok) { + infos.push(info.value) + } } return ok(infos) } catch (error) { @@ -262,7 +314,9 @@ export class BrowserExecutionEnv implements ExecutionEnv { async canonicalPath(path: string): Promise> { const resolved = this.jailed(path) - if (!resolved.ok) {return resolved} + if (!resolved.ok) { + return resolved + } try { const real = await fsp.realpath(resolved.value) // Defense-in-depth: `realpath` is the one method that follows symlinks, so @@ -280,14 +334,20 @@ export class BrowserExecutionEnv implements ExecutionEnv { async exists(path: string): Promise> { const result = await this.fileInfo(path) - if (result.ok) {return ok(true)} - if (result.error.code === 'not_found') {return ok(false)} + if (result.ok) { + return ok(true) + } + if (result.error.code === 'not_found') { + return ok(false) + } return err(result.error) } async createDir(path: string, options?: { recursive?: boolean }): Promise> { const resolved = this.jailed(path) - if (!resolved.ok) {return resolved} + if (!resolved.ok) { + return resolved + } try { await fsp.mkdir(resolved.value, { recursive: options?.recursive ?? true }) return ok(undefined) @@ -298,7 +358,9 @@ export class BrowserExecutionEnv implements ExecutionEnv { async remove(path: string, options?: { recursive?: boolean; force?: boolean }): Promise> { const resolved = this.jailed(path) - if (!resolved.ok) {return resolved} + if (!resolved.ok) { + return resolved + } try { await fsp.rm(resolved.value, { recursive: options?.recursive ?? false, force: options?.force ?? false }) return ok(undefined) @@ -323,7 +385,9 @@ export class BrowserExecutionEnv implements ExecutionEnv { async createTempFile(options?: { prefix?: string; suffix?: string }): Promise> { const dir = await this.createTempDir('tmp-') - if (!dir.ok) {return dir} + if (!dir.ok) { + return dir + } try { const filePath = join(dir.value, `${options?.prefix ?? ''}${crypto.randomUUID()}${options?.suffix ?? ''}`) await fsp.writeFile(filePath, '') diff --git a/shared/agent-core/browser-env/coding-tool-operations.test.ts b/shared/agent-core/browser-env/coding-tool-operations.test.ts index 2396b5639..5ef990144 100644 --- a/shared/agent-core/browser-env/coding-tool-operations.test.ts +++ b/shared/agent-core/browser-env/coding-tool-operations.test.ts @@ -41,8 +41,12 @@ const fakeEnv = (script: { const env = { exec: async (command: string, options: Record) => { calls.push({ command, options }) - if (script.emit?.stdout !== undefined) {(options.onStdout as (c: string) => void)(script.emit.stdout)} - if (script.emit?.stderr !== undefined) {(options.onStderr as (c: string) => void)(script.emit.stderr)} + if (script.emit?.stdout !== undefined) { + ;(options.onStdout as (c: string) => void)(script.emit.stdout) + } + if (script.emit?.stderr !== undefined) { + ;(options.onStderr as (c: string) => void)(script.emit.stderr) + } return script.result }, } as unknown as BrowserExecutionEnv diff --git a/shared/agent-core/browser-env/fs-helpers.test.ts b/shared/agent-core/browser-env/fs-helpers.test.ts index c10cd8014..39ccc4dcc 100644 --- a/shared/agent-core/browser-env/fs-helpers.test.ts +++ b/shared/agent-core/browser-env/fs-helpers.test.ts @@ -15,8 +15,7 @@ import { describe, expect, it } from 'bun:test' import { FileError } from '@earendil-works/pi-agent-core' import { abortedResult, fileInfoFrom, splitLines, toFileError, type ZenStats } from './fs-helpers.ts' -const errno = (code: string, message = code): Error & { code: string } => - Object.assign(new Error(message), { code }) +const errno = (code: string, message = code): Error & { code: string } => Object.assign(new Error(message), { code }) const statOf = (kind: 'file' | 'dir' | 'symlink' | 'other', over: Partial = {}): ZenStats => ({ isFile: () => kind === 'file', @@ -93,8 +92,16 @@ describe('fileInfoFrom', () => { it('builds a file info with basename, size and mtimeMs', () => { const result = fileInfoFrom('/workspace/t1/mine.txt', statOf('file', { size: 42, mtimeMs: 1234 })) expect(result.ok).toBe(true) - if (!result.ok) {throw new Error('unreachable')} - expect(result.value).toEqual({ name: 'mine.txt', path: '/workspace/t1/mine.txt', kind: 'file', size: 42, mtimeMs: 1234 }) + if (!result.ok) { + throw new Error('unreachable') + } + expect(result.value).toEqual({ + name: 'mine.txt', + path: '/workspace/t1/mine.txt', + kind: 'file', + size: 42, + mtimeMs: 1234, + }) }) it.each([ @@ -113,7 +120,9 @@ describe('fileInfoFrom', () => { it('returns an invalid FileError for a kind Pi does not model (e.g. device/fifo)', () => { const result = fileInfoFrom('/dev/null', statOf('other')) expect(result.ok).toBe(false) - if (result.ok) {throw new Error('unreachable')} + if (result.ok) { + throw new Error('unreachable') + } expect(result.error.code).toBe('invalid') expect(result.error.path).toBe('/dev/null') }) diff --git a/shared/agent-core/browser-env/fs-helpers.ts b/shared/agent-core/browser-env/fs-helpers.ts index 226c0ab02..552d00a8b 100644 --- a/shared/agent-core/browser-env/fs-helpers.ts +++ b/shared/agent-core/browser-env/fs-helpers.ts @@ -40,7 +40,9 @@ const isErrnoError = (error: unknown): error is { code: string; message: string * @returns a {@link FileError} carrying the mapped code and the original cause */ export const toFileError = (error: unknown, path?: string): FileError => { - if (error instanceof FileError) {return error} + if (error instanceof FileError) { + return error + } const cause = toError(error) if (isErrnoError(error)) { switch (error.code) { @@ -63,9 +65,15 @@ export const toFileError = (error: unknown, path?: string): FileError => { } const fileKindFrom = (stat: ZenStats): FileKind | undefined => { - if (stat.isFile()) {return 'file'} - if (stat.isDirectory()) {return 'directory'} - if (stat.isSymbolicLink()) {return 'symlink'} + if (stat.isFile()) { + return 'file' + } + if (stat.isDirectory()) { + return 'directory' + } + if (stat.isSymbolicLink()) { + return 'symlink' + } return undefined } @@ -79,7 +87,9 @@ const fileKindFrom = (stat: ZenStats): FileKind | undefined => { */ export const fileInfoFrom = (path: string, stat: ZenStats): Result => { const kind = fileKindFrom(stat) - if (!kind) {return err(new FileError('invalid', 'Unsupported file type', path))} + if (!kind) { + return err(new FileError('invalid', 'Unsupported file type', path)) + } return ok({ name: basename(path) || path, path, kind, size: stat.size, mtimeMs: stat.mtimeMs }) } @@ -101,8 +111,12 @@ export const abortedResult = (signal: AbortSignal | undefined, path: string): Re * @param text - the file contents to split */ export const splitLines = (text: string): string[] => { - if (text === '') {return []} + if (text === '') { + return [] + } const lines = text.split(/\r?\n/) - if (lines[lines.length - 1] === '') {lines.pop()} + if (lines[lines.length - 1] === '') { + lines.pop() + } return lines } diff --git a/shared/agent-core/browser-env/mount.test.ts b/shared/agent-core/browser-env/mount.test.ts index 23ee7dee7..6a95f24d4 100644 --- a/shared/agent-core/browser-env/mount.test.ts +++ b/shared/agent-core/browser-env/mount.test.ts @@ -33,7 +33,9 @@ beforeEach(() => { }) afterEach(() => { - if (originalNavigator) {Object.defineProperty(globalThis, 'navigator', originalNavigator)} + if (originalNavigator) { + Object.defineProperty(globalThis, 'navigator', originalNavigator) + } }) describe('mountInMemoryFs', () => { diff --git a/shared/agent-core/coding-tools/index.test.ts b/shared/agent-core/coding-tools/index.test.ts index 47c888021..3b1bde549 100644 --- a/shared/agent-core/coding-tools/index.test.ts +++ b/shared/agent-core/coding-tools/index.test.ts @@ -25,7 +25,9 @@ const ws = '/ws' const textOf = (result: { content: { type: string; text?: string }[] }): string => { const block = result.content[0] - if (block.type !== 'text' || block.text === undefined) {throw new Error('expected a text block')} + if (block.type !== 'text' || block.text === undefined) { + throw new Error('expected a text block') + } return block.text } @@ -48,11 +50,9 @@ beforeEach(async () => { const run = (tool: AgentTool, args: unknown) => // The Pi AgentTool.execute signature: (toolCallId, args, signal). - (tool.execute as (id: string, a: unknown, s?: AbortSignal) => Promise<{ content: { type: string; text?: string }[] }>)( - 'call-1', - args, - undefined, - ) + ( + tool.execute as (id: string, a: unknown, s?: AbortSignal) => Promise<{ content: { type: string; text?: string }[] }> + )('call-1', args, undefined) describe('createBrowserCodingTools', () => { it('exposes the four tools in order with the model-visible names', () => { @@ -115,7 +115,9 @@ describe('read tool', () => { const content = Array.from({ length: defaultMaxLines + 1 }, (_, i) => `row${i}`).join('\n') await fsp.writeFile(`${ws}/big.txt`, content) const out = textOf(await run(read, { path: 'big.txt' })) - expect(out).toContain(`[Showing lines 1-${defaultMaxLines} of ${defaultMaxLines + 1}. Use offset=${defaultMaxLines + 1} to continue.]`) + expect(out).toContain( + `[Showing lines 1-${defaultMaxLines} of ${defaultMaxLines + 1}. Use offset=${defaultMaxLines + 1} to continue.]`, + ) expect(out).not.toContain('limit)') // line-based truncation has no "(50.0KB limit)" note }) @@ -198,8 +200,7 @@ describe('write tool', () => { }) describe('edit tool – argument preparation', () => { - const prepare = (input: unknown): unknown => - (edit.prepareArguments as (i: unknown) => unknown)(input) + const prepare = (input: unknown): unknown => (edit.prepareArguments as (i: unknown) => unknown)(input) it('parses a JSON-string `edits` into an array (some models stringify it)', () => { expect(prepare({ path: 'f', edits: '[{"oldText":"a","newText":"b"}]' })).toEqual({ @@ -216,9 +217,13 @@ describe('edit tool – argument preparation', () => { }) it('appends a top-level oldText/newText pair to an existing edits[] array', () => { - expect( - prepare({ path: 'f', edits: [{ oldText: 'x', newText: 'y' }], oldText: 'a', newText: 'b' }), - ).toEqual({ path: 'f', edits: [{ oldText: 'x', newText: 'y' }, { oldText: 'a', newText: 'b' }] }) + expect(prepare({ path: 'f', edits: [{ oldText: 'x', newText: 'y' }], oldText: 'a', newText: 'b' })).toEqual({ + path: 'f', + edits: [ + { oldText: 'x', newText: 'y' }, + { oldText: 'a', newText: 'b' }, + ], + }) }) it('leaves a non-JSON `edits` string untouched so validation surfaces a clear error', () => { @@ -234,9 +239,7 @@ describe('edit tool – argument preparation', () => { describe('edit tool – execution', () => { it('throws when no replacements are provided', async () => { await fsp.writeFile(`${ws}/f.txt`, 'abc') - await expect(run(edit, { path: 'f.txt', edits: [] })).rejects.toThrow( - 'edits must contain at least one replacement', - ) + await expect(run(edit, { path: 'f.txt', edits: [] })).rejects.toThrow('edits must contain at least one replacement') }) it('reports a friendly error (with the FS error code) when the file does not exist', async () => { @@ -289,7 +292,13 @@ describe('edit tool – execution', () => { it('reports the count for multiple blocks', async () => { await fsp.writeFile(`${ws}/m.txt`, 'a x c') const out = textOf( - await run(edit, { path: 'm.txt', edits: [{ oldText: 'a', newText: 'A' }, { oldText: 'c', newText: 'C' }] }), + await run(edit, { + path: 'm.txt', + edits: [ + { oldText: 'a', newText: 'A' }, + { oldText: 'c', newText: 'C' }, + ], + }), ) expect(out).toBe('Successfully replaced 2 block(s) in m.txt.') expect(await fsp.readFile(`${ws}/m.txt`, { encoding: 'utf8' })).toBe('A x C') @@ -328,9 +337,7 @@ describe('bash tool', () => { it('byte-truncates output and labels the footer with the 50KB limit', async () => { // 60 lines × ~1001 bytes ≈ 60KB: under the line limit but over the byte limit. - const out = textOf( - await run(bash, { command: 'for i in $(seq 1 60); do printf "%01000d\\n" $i; done' }), - ) + const out = textOf(await run(bash, { command: 'for i in $(seq 1 60); do printf "%01000d\\n" $i; done' })) expect(out).toContain('(50.0KB limit). Full output:') }) diff --git a/shared/agent-core/pi-to-aisdk-stream.test.ts b/shared/agent-core/pi-to-aisdk-stream.test.ts index 03dc1b613..185ba4618 100644 --- a/shared/agent-core/pi-to-aisdk-stream.test.ts +++ b/shared/agent-core/pi-to-aisdk-stream.test.ts @@ -22,7 +22,12 @@ describe('piHarnessToUiMessageStream metadata', () => { async () => { emit({ type: 'agent_start' } as AgentHarnessEvent) emit({ type: 'turn_start' } as AgentHarnessEvent) - emit({ type: 'tool_execution_start', toolCallId: 'call-1', toolName: 'search_web', args: {} } as AgentHarnessEvent) + emit({ + type: 'tool_execution_start', + toolCallId: 'call-1', + toolName: 'search_web', + args: {}, + } as AgentHarnessEvent) emit({ type: 'tool_execution_end', toolCallId: 'call-1', diff --git a/shared/agent-tool-permissions.ts b/shared/agent-tool-permissions.ts index 96c7b5f0e..ec222c930 100644 --- a/shared/agent-tool-permissions.ts +++ b/shared/agent-tool-permissions.ts @@ -19,9 +19,7 @@ type AcpPermissionOption = { kind: 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always' } -type AcpRequestPermissionOutcome = - | { outcome: 'cancelled' } - | { outcome: 'selected'; optionId: string } +type AcpRequestPermissionOutcome = { outcome: 'cancelled' } | { outcome: 'selected'; optionId: string } /** Whether a Pi tool is deterministic and side-effect free. */ export const isReadOnlyAgentTool = (toolName: string): boolean => toolName === 'read' @@ -49,9 +47,15 @@ export const resolveToolPermission = ( outcome: AcpRequestPermissionOutcome, options: readonly AcpPermissionOption[], ): ToolPermissionDecision => { - if (outcome.outcome === 'cancelled') {return 'reject'} + if (outcome.outcome === 'cancelled') { + return 'reject' + } const selected = options.find((option) => option.optionId === outcome.optionId) - if (selected?.kind === 'allow_always') {return 'allow-always'} - if (selected?.kind === 'allow_once') {return 'allow-once'} + if (selected?.kind === 'allow_always') { + return 'allow-always' + } + if (selected?.kind === 'allow_once') { + return 'allow-once' + } return 'reject' } diff --git a/shared/ip-classification.test.ts b/shared/ip-classification.test.ts index b0ee69304..bdaa70fa6 100644 --- a/shared/ip-classification.test.ts +++ b/shared/ip-classification.test.ts @@ -13,7 +13,9 @@ import { /** Parse one test IP literal. */ const parseLiteral = (address: string) => { const parsedAddress = parseIpAddress(address) - if (!parsedAddress) {throw new Error(`Expected an IP literal: ${address}`)} + if (!parsedAddress) { + throw new Error(`Expected an IP literal: ${address}`) + } return parsedAddress } @@ -23,7 +25,9 @@ const classify = (address: string): boolean => isPrivateOrInternalAddress(parseL /** Parse one test IPv4 literal. */ const parseIpv4Literal = (address: string) => { const parsedAddress = parseLiteral(address) - if (parsedAddress.version !== 4) {throw new Error(`Expected an IPv4 literal: ${address}`)} + if (parsedAddress.version !== 4) { + throw new Error(`Expected an IPv4 literal: ${address}`) + } return parsedAddress } diff --git a/shared/ip-classification.ts b/shared/ip-classification.ts index 25e210837..9305f4f99 100644 --- a/shared/ip-classification.ts +++ b/shared/ip-classification.ts @@ -55,9 +55,13 @@ const teredoNetwork = 0x20010000000000000000000000000000n /** Parse canonical dotted-decimal IPv4 into a 32-bit integer. */ const parseIpv4 = (address: string): bigint | undefined => { const parts = address.split('.') - if (parts.length !== 4 || parts.some((part) => !/^\d{1,3}$/.test(part))) {return undefined} + if (parts.length !== 4 || parts.some((part) => !/^\d{1,3}$/.test(part))) { + return undefined + } const bytes = parts.map(Number) - if (bytes.some((byte) => byte > 255)) {return undefined} + if (bytes.some((byte) => byte > 255)) { + return undefined + } return bytes.reduce((value, byte) => (value << 8n) | BigInt(byte), 0n) } @@ -65,34 +69,48 @@ const parseIpv4 = (address: string): bigint | undefined => { const parseIpv6 = (address: string): bigint | undefined => { const hasOpeningBracket = address.startsWith('[') const hasClosingBracket = address.endsWith(']') - if (hasOpeningBracket !== hasClosingBracket) {return undefined} + if (hasOpeningBracket !== hasClosingBracket) { + return undefined + } const unwrappedAddress = hasOpeningBracket ? address.slice(1, -1) : address const normalizedAddress = unwrappedAddress.split('%')[0] - if (normalizedAddress === undefined || !normalizedAddress.includes(':')) {return undefined} + if (normalizedAddress === undefined || !normalizedAddress.includes(':')) { + return undefined + } const dottedTail = normalizedAddress.match(/(?:^|:)(\d{1,3}(?:\.\d{1,3}){3})$/)?.[1] const ipv4Tail = dottedTail ? parseIpv4(dottedTail) : undefined - if (dottedTail && ipv4Tail === undefined) {return undefined} + if (dottedTail && ipv4Tail === undefined) { + return undefined + } const expandedAddress = dottedTail && ipv4Tail !== undefined ? normalizedAddress.replace(dottedTail, `${(ipv4Tail >> 16n).toString(16)}:${(ipv4Tail & 0xffffn).toString(16)}`) : normalizedAddress - if ((expandedAddress.match(/::/g) ?? []).length > 1) {return undefined} + if ((expandedAddress.match(/::/g) ?? []).length > 1) { + return undefined + } const [left = '', right] = expandedAddress.split('::') const leftGroups = left ? left.split(':') : [] const rightGroups = right ? right.split(':') : [] const missingGroups = 8 - leftGroups.length - rightGroups.length - if ((right === undefined && missingGroups !== 0) || (right !== undefined && missingGroups < 1)) {return undefined} + if ((right === undefined && missingGroups !== 0) || (right !== undefined && missingGroups < 1)) { + return undefined + } const groups = [...leftGroups, ...Array.from({ length: missingGroups }, () => '0'), ...rightGroups] - if (groups.length !== 8 || groups.some((group) => !/^[\da-f]{1,4}$/i.test(group))) {return undefined} + if (groups.length !== 8 || groups.some((group) => !/^[\da-f]{1,4}$/i.test(group))) { + return undefined + } return groups.reduce((value, group) => (value << 16n) | BigInt(`0x${group}`), 0n) } /** Parse an IP literal while leaving domain names unresolved. */ export const parseIpAddress = (address: string): ParsedIpAddress | undefined => { const ipv4 = parseIpv4(address) - if (ipv4 !== undefined) {return { version: 4, value: ipv4 }} + if (ipv4 !== undefined) { + return { version: 4, value: ipv4 } + } const ipv6 = parseIpv6(address) return ipv6 === undefined ? undefined : { version: 6, value: ipv6 } } @@ -105,9 +123,13 @@ const isInCidr = (value: bigint, network: bigint, prefixLength: number, addressB /** Extract IPv4 embedded by an IPv6 mapping or transition mechanism. */ export const extractEmbeddedIpv4Address = (address: ParsedIpAddress): EmbeddedIpv4Address | undefined => { - if (address.version === 4) {return undefined} + if (address.version === 4) { + return undefined + } const ipv6Value = address.value - if (typeof ipv6Value !== 'bigint') {return undefined} + if (typeof ipv6Value !== 'bigint') { + return undefined + } if (isInCidr(ipv6Value, ipv4MappedNetwork, 96, 128)) { return { mechanism: 'ipv4-mapped', address: { version: 4, value: ipv6Value & ipv4Mask } } } @@ -133,10 +155,16 @@ export const isPrivateOrInternalAddress = (address: ParsedIpAddress): boolean => } const embeddedIpv4 = extractEmbeddedIpv4Address(address) - if (embeddedIpv4?.mechanism === 'ipv4-mapped') {return isPrivateOrInternalAddress(embeddedIpv4.address)} - if (embeddedIpv4 && isPrivateOrInternalAddress(embeddedIpv4.address)) {return true} + if (embeddedIpv4?.mechanism === 'ipv4-mapped') { + return isPrivateOrInternalAddress(embeddedIpv4.address) + } + if (embeddedIpv4 && isPrivateOrInternalAddress(embeddedIpv4.address)) { + return true + } const isGlobalUnicast = isInCidr(address.value, 0x20000000000000000000000000000000n, 3, 128) - if (!isGlobalUnicast) {return true} + if (!isGlobalUnicast) { + return true + } return blockedIpv6Ranges.some(({ network, prefixLength }) => isInCidr(address.value, network, prefixLength, 128)) } diff --git a/shared/url.ts b/shared/url.ts index 537f4d36a..12e3835c1 100644 --- a/shared/url.ts +++ b/shared/url.ts @@ -12,7 +12,9 @@ export const deriveFaviconUrl = (pageUrl: string): string | null => { try { const { origin, protocol } = new URL(pageUrl) - if (protocol !== 'https:') {return null} + if (protocol !== 'https:') { + return null + } return `${origin}/favicon.ico` } catch { return null