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
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,17 @@
"typecheck:cli": "pnpm --filter @anton/cli typecheck",
"typecheck:desktop": "pnpm --filter @anton/desktop typecheck",
"typecheck:protocol": "pnpm --filter @anton/protocol typecheck",
"verify": "pnpm typecheck && pnpm check",
"test": "vitest run",
"test:watch": "vitest",
"verify": "pnpm typecheck && pnpm check && pnpm test",
"deploy": "./deploy/sync.sh",
"dev": "pnpm protocol:build && concurrently \"pnpm agent:dev\" \"pnpm desktop:dev\"",
"dev:local": "ANTON_LOCAL=1 pnpm dev"
},
"devDependencies": {
"@biomejs/biome": "^1.9.0",
"esbuild": "^0.27.4"
"esbuild": "^0.27.4",
"vitest": "^3"
},
"keywords": [
"ai",
Expand Down
170 changes: 170 additions & 0 deletions packages/agent-config/src/attachments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'

// Pin ANTON_DIR to a freshly-created temp dir BEFORE importing config.ts —
// the module reads the env var once at load time. afterAll wipes the dir.
const TEMP_ROOT = mkdtempSync(join(tmpdir(), 'anton-test-'))
process.env.ANTON_DIR = TEMP_ROOT

const { loadSession, resolveSessionImagePath, saveSession } = await import('./config.js')
type ConfigModule = typeof import('./config.js')
type PersistedSession = Parameters<ConfigModule['saveSession']>[0]

afterAll(() => {
try {
rmSync(TEMP_ROOT, { recursive: true, force: true })
} catch {
/* ignore */
}
})

describe('resolveSessionImagePath', () => {
it('rejects empty / non-string / null-byte input', () => {
expect(resolveSessionImagePath('s', '')).toBeNull()
expect(resolveSessionImagePath('s', '\0images/foo.png')).toBeNull()
expect(resolveSessionImagePath('s', 'images/foo\0.png')).toBeNull()
})

it('rejects paths that do not start with images/', () => {
expect(resolveSessionImagePath('s', 'meta.json')).toBeNull()
expect(resolveSessionImagePath('s', '../etc/passwd')).toBeNull()
expect(resolveSessionImagePath('s', '/etc/passwd')).toBeNull()
})

it('rejects traversal attempts that escape the images dir', () => {
expect(resolveSessionImagePath('s', 'images/../../etc/passwd')).toBeNull()
expect(resolveSessionImagePath('s', 'images/../../../root/.ssh/id_rsa')).toBeNull()
expect(resolveSessionImagePath('s', 'images/../meta.json')).toBeNull()
})

it('accepts well-formed image paths', () => {
const ok = resolveSessionImagePath('sess-1', 'images/0001-01-image.png')
expect(ok).not.toBeNull()
expect(ok).toContain('sess-1')
expect(ok).toContain('images')
})

it('normalizes backslashes from windows-style input', () => {
const ok = resolveSessionImagePath('sess-2', 'images\\nested\\foo.png')
expect(ok).not.toBeNull()
})

it('rejects malformed sessionIds', () => {
// Empty / dot-segments / path-separator-bearing IDs would let a
// crafted request escape the per-session sandbox.
expect(resolveSessionImagePath('', 'images/foo.png')).toBeNull()
expect(resolveSessionImagePath('.', 'images/foo.png')).toBeNull()
expect(resolveSessionImagePath('..', 'images/foo.png')).toBeNull()
expect(resolveSessionImagePath('a/b', 'images/foo.png')).toBeNull()
expect(resolveSessionImagePath('a\\b', 'images/foo.png')).toBeNull()
expect(resolveSessionImagePath('a\0b', 'images/foo.png')).toBeNull()
})
})

// 1×1 transparent PNG
const PNG_BYTES = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYGD4DwABBQECzr1vQwAAAABJRU5ErkJggg==',
'base64',
)

function makeUserMessage() {
return {
role: 'user',
content: [
{ type: 'text', text: 'see this' },
{
type: 'image',
mimeType: 'image/png',
data: PNG_BYTES.toString('base64'),
name: 'pixel.png',
sizeBytes: PNG_BYTES.byteLength,
},
],
timestamp: 1,
}
}

function persistedSession(id: string): PersistedSession {
return {
id,
provider: 'anthropic',
model: 'claude-opus-4',
title: 'test',
createdAt: 1,
lastActiveAt: 2,
messages: [makeUserMessage()],
} satisfies PersistedSession
}

type ImageBlock = {
type: 'image'
mimeType?: string
data?: string
storagePath?: string
name?: string
sizeBytes?: number
}

function findImageBlock(loaded: ReturnType<typeof loadSession>): ImageBlock {
expect(loaded).not.toBeNull()
const userMsg = loaded?.messages[0] as { role: string; content: ImageBlock[] }
expect(userMsg.role).toBe('user')
const imgBlock = userMsg.content.find((b) => b.type === 'image')
expect(imgBlock).toBeDefined()
return imgBlock as ImageBlock
}

describe('saveSession + loadSession round-trip with image attachments', () => {
it('global session: persists image bytes to disk and rehydrates data on load', () => {
const id = 'sess-global'
saveSession(persistedSession(id))

const imgBlock = findImageBlock(loadSession(id))
expect(imgBlock.data).toBe(PNG_BYTES.toString('base64'))
expect(imgBlock.storagePath).toMatch(/^images\//)
expect(imgBlock.mimeType).toBe('image/png')
})

it('project (basePath) session: rehydrates image data via hydrateSessionMessage, not hydrateSessionContent', () => {
// Regression: the project-scoped loader previously called
// hydrateSessionContent on a whole message object instead of
// hydrateSessionMessage. hydrateSessionContent expects content (an
// array) and returned undefined for objects, so messages reached
// consumers with only storagePath and no `data` after a reload —
// chips disappeared from "My Computer" sessions despite the bytes
// being safely on disk.
const id = 'sess-project'
const projectDir = join(TEMP_ROOT, 'projects/proj-1/conversations')
mkdirSync(projectDir, { recursive: true })
saveSession(persistedSession(id), projectDir)

const imgBlock = findImageBlock(loadSession(id, projectDir))
expect(imgBlock.data).toBe(PNG_BYTES.toString('base64'))
})

it('gracefully degrades when the image file on disk is missing', () => {
const id = 'sess-missing-file'
saveSession(persistedSession(id))

rmSync(join(TEMP_ROOT, 'conversations', id, 'images'), { recursive: true, force: true })

const imgBlock = findImageBlock(loadSession(id))
expect(imgBlock.storagePath).toMatch(/^images\//)
expect(imgBlock.data).toBeUndefined()
})

it('writes images atomically (no .tmp residue on success)', () => {
const id = 'sess-atomic'
saveSession(persistedSession(id))

const imagesDir = join(TEMP_ROOT, 'conversations', id, 'images')
const entries = readdirSync(imagesDir)
const pngs = entries.filter((f) => f.endsWith('.png'))
expect(pngs).toHaveLength(1)
const written = readFileSync(join(imagesDir, pngs[0]))
expect(written.byteLength).toBe(PNG_BYTES.byteLength)
expect(entries.some((f) => f.endsWith('.tmp'))).toBe(false)
})
})
74 changes: 65 additions & 9 deletions packages/agent-config/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import {
mkdirSync,
readFileSync,
readdirSync,
renameSync,
rmSync,
unlinkSync,
writeFileSync,
} from 'node:fs'
import { homedir, hostname } from 'node:os'
import { dirname, join } from 'node:path'
import { dirname, join, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
import { copyImageToWorkspace } from './image-storage.js'
Expand Down Expand Up @@ -306,7 +307,12 @@ export interface ConnectorConfig {

// ── Paths ───────────────────────────────────────────────────────────

const ANTON_DIR = join(homedir(), '.anton')
// `ANTON_DIR` env var lets tests (and unusual deployments) point persistence
// at a non-default root without monkey-patching homedir().
const ANTON_DIR =
typeof process.env.ANTON_DIR === 'string' && process.env.ANTON_DIR.length > 0
? process.env.ANTON_DIR
: join(homedir(), '.anton')
const CONFIG_PATH = join(ANTON_DIR, 'config.yaml')
const ENV_FILE_PATH = join(ANTON_DIR, 'agent.env')
const CONVERSATIONS_DIR = join(ANTON_DIR, 'conversations')
Expand Down Expand Up @@ -351,7 +357,7 @@ export const DEFAULT_PROVIDERS: ProvidersMap = {
},
openai: {
apiKey: process.env.OPENAI_API_KEY || '',
models: ['gpt-4o'],
models: ['gpt-5.5', 'gpt-5.4', 'gpt-4o'],
},
google: {
apiKey: process.env.GOOGLE_API_KEY || '',
Expand Down Expand Up @@ -800,6 +806,31 @@ function sessionImagesDir(id: string): string {
return join(sessionDir(id), 'images')
}

/**
* Resolve a session-relative attachment storagePath to an absolute file path,
* with traversal protection. Returns null for any malformed or escaping input.
*
* Why: both inputs come from the client and are read directly off disk —
* without this guard, `images/../../foo` (or `..` / empty-string sessionId)
* would let an attacker reach files outside the session's images dir.
*/
export function resolveSessionImagePath(sessionId: string, storagePath: string): string | null {
// sessionId guard — must be a non-empty string with no path syntax.
if (typeof sessionId !== 'string' || sessionId.length === 0) return null
if (sessionId.includes('\0') || sessionId.includes('/') || sessionId.includes('\\')) return null
if (sessionId === '.' || sessionId === '..') return null

if (typeof storagePath !== 'string' || storagePath.length === 0) return null
if (storagePath.includes('\0')) return null
const normalized = storagePath.replaceAll('\\', '/')
if (!normalized.startsWith('images/')) return null

const imagesBase = resolve(sessionImagesDir(sessionId))
const resolved = resolve(sessionDir(sessionId), normalized)
if (resolved !== imagesBase && !resolved.startsWith(imagesBase + sep)) return null
return resolved
}

function clearSessionImages(id: string): void {
const dir = sessionImagesDir(id)
if (!existsSync(dir)) return
Expand Down Expand Up @@ -888,8 +919,22 @@ function serializeSessionContent(
const absolutePath = join(sessionDir(sessionId), relativePath)
const imageBuffer = Buffer.from(value.data, 'base64')

// Always write to session images dir (safety net)
writeFileSync(absolutePath, imageBuffer)
// Atomic write: tmp + rename. A crash mid-write could otherwise leave
// a zero-byte file at the final path that hydrateSessionContent would
// try to load as a real image.
const tmpPath = `${absolutePath}.${randomBytes(6).toString('hex')}.tmp`
writeFileSync(tmpPath, imageBuffer)
try {
renameSync(tmpPath, absolutePath)
} catch {
// Cleanup on rename failure (e.g. cross-device); fall back to direct write.
try {
unlinkSync(tmpPath)
} catch {
/* ignore */
}
writeFileSync(absolutePath, imageBuffer)
}

// Dual-write to workspace .uploads/ if available
const sanitizedName =
Expand Down Expand Up @@ -1103,10 +1148,7 @@ export function loadSession(id: string, basePath?: string): PersistedSession | n
.trim()
.split('\n')
.filter(Boolean)
.map((l) => {
const parsed = JSON.parse(l)
return hydrateSessionContent(id, parsed) || parsed
})
.map((l) => hydrateSessionMessage(id, JSON.parse(l) as SessionMessage))
: []
let compactionState: PersistedSession['compactionState'] | undefined
const compPath = join(dir, 'compaction.json')
Expand Down Expand Up @@ -1294,6 +1336,20 @@ export function archiveSession(id: string): boolean {
return true
}

/**
* Re-publish an existing session's meta into the global sync index.
*
* Used when a side-channel writer (e.g. `writeHarnessSessionTitle` after
* a user-initiated rename) updates meta.json directly: that path does
* not pass through `updateIndex`, so connected clients would otherwise
* miss the change until reconnect. No-op for project-scoped sessions
* (their meta lives outside this index).
*/
export function refreshSessionIndex(id: string): void {
const meta = loadSessionMeta(id)
if (meta) updateIndex(meta)
}

/** Clean expired sessions */
export function cleanExpiredSessions(ttlDays = 30): number {
const archiveCutoff = Date.now() - ttlDays * 24 * 60 * 60 * 1000
Expand Down
3 changes: 2 additions & 1 deletion packages/agent-config/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
39 changes: 21 additions & 18 deletions packages/agent-core/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1553,32 +1553,35 @@ export class Session {
messageIndex: number,
): SessionImageAttachment[] | undefined => {
if (!Array.isArray(content)) return undefined
// History payloads only carry metadata. The renderer fetches bytes
// on demand via the request_attachment WS message, so we never
// serialize base64 data into history responses — that kept ~1MB per
// image resident on the wire and in client memory for every page.
// The LLM-facing path (piAgent.state.messages) still carries `data`
// because it's hydrated at resume time directly from disk.
const attachments = content
.filter(
(block) =>
block.type === 'image' &&
typeof block.mimeType === 'string' &&
typeof block.data === 'string',
(typeof block.data === 'string' || typeof block.storagePath === 'string'),
)
.map((block, index) => ({
id:
.map((block, index) => {
const storagePath =
typeof block.storagePath === 'string'
? block.storagePath
: inferStoragePath(messageIndex, index, block),
name:
typeof block.name === 'string'
? block.name
: typeof block.storagePath === 'string'
? block.storagePath.split('/').pop() || `image-${index + 1}`
: `image-${index + 1}`,
mimeType: block.mimeType!,
storagePath:
typeof block.storagePath === 'string'
? block.storagePath
: inferStoragePath(messageIndex, index, block),
sizeBytes: typeof block.sizeBytes === 'number' ? block.sizeBytes : 0,
data: block.data,
}))
: inferStoragePath(messageIndex, index, block)
return {
id: storagePath,
name:
typeof block.name === 'string' && block.name.length > 0
? block.name
: storagePath.split('/').pop() || `image-${index + 1}`,
mimeType: block.mimeType!,
storagePath,
sizeBytes: typeof block.sizeBytes === 'number' ? block.sizeBytes : 0,
}
})
return attachments.length > 0 ? attachments : undefined
}

Expand Down
Loading