Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
]
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@
"analyze": "vite analyze",
"db": "drizzle-kit",
"eval": "bun run src/ai/eval/run.ts",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
"lint": "eslint src shared",
"lint:fix": "eslint src shared --fix",
"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",
Expand Down
16 changes: 10 additions & 6 deletions shared/agent-core/anthropic-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}}}',
'',
Expand All @@ -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
Expand All @@ -56,16 +56,20 @@ 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.
}
Expand Down
44 changes: 25 additions & 19 deletions shared/agent-core/anthropic-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Api>` to the anthropic-messages model this
* provider exclusively serves, surfacing misuse loudly rather than guessing.
*/
const requireAnthropic = (model: Model<Api>): Model<typeof API> => {
if (!hasApi(model, API)) {
throw new Error(`Expected an "${API}" model, got "${model.api}".`)
const requireAnthropic = (model: Model<Api>): Model<typeof apiId> => {
if (!hasApi(model, apiId)) {
throw new Error(`Expected an "${apiId}" model, got "${model.api}".`)
}
return model
}
Expand All @@ -108,14 +108,20 @@ const requireAnthropic = (model: Model<Api>): Model<typeof API> => {
* model's `thinkingLevelMap` override (e.g. opus models remap `xhigh`). Mirrors
* Pi's internal mapping, which is not exported.
*/
const mapThinkingLevelToEffort = (model: Model<typeof API>, level: ThinkingLevel): AnthropicEffort => {
const mapThinkingLevelToEffort = (model: Model<typeof apiId>, 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
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'
}

Expand All @@ -125,7 +131,7 @@ const mapThinkingLevelToEffort = (model: Model<typeof API>, level: ThinkingLevel
* are exported; only the (unexported) effort mapping is reproduced above.
*/
const toFullAnthropicOptions = (
model: Model<typeof API>,
model: Model<typeof apiId>,
context: Context,
options?: SimpleStreamOptions,
): AnthropicOptions => {
Expand Down Expand Up @@ -154,7 +160,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<typeof API>, opts: BuildAnthropicModelOptions): Anthropic =>
const createAnthropicClient = (model: Model<typeof apiId>, opts: BuildAnthropicModelOptions): Anthropic =>
new Anthropic({
apiKey: opts.apiKey,
baseURL: model.baseUrl,
Expand All @@ -177,8 +183,8 @@ const createAnthropicClient = (model: Model<typeof API>, opts: BuildAnthropicMod
*/
export const buildAnthropicModel = (opts: BuildAnthropicModelOptions): { models: Models; model: Model<Api> } => {
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}".`)
}

Expand All @@ -194,7 +200,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
Expand Down
38 changes: 27 additions & 11 deletions shared/agent-core/browser-env/browser-execution-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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)
})

Expand All @@ -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 () => {
Expand All @@ -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)
Expand Down Expand Up @@ -129,12 +141,14 @@ 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))
Expand All @@ -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')
})
Expand Down
Loading
Loading