From 48a03d8668b6ecf25a8cdf417657dff7968b7cc4 Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Wed, 28 Oct 2026 09:20:33 +0000 Subject: [PATCH 1/5] feat(agentlet): bundle a stateless read-only ACP filesystem proxy Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- external/agentlet/README.md | 2 + .../agentlet/packages/local/src/filesystem.ts | 158 ++++++++++++++++++ .../agentlet/packages/local/src/fs-proxy.ts | 74 ++++++++ .../packages/local/src/harnesses/proxy.ts | 27 +++ .../packages/local/src/harnesses/registry.ts | 2 + .../packages/local/tests/filesystem.test.ts | 126 ++++++++++++++ .../packages/local/tests/fs-proxy.test.ts | 64 +++++++ .../packages/local/tests/harnesses.test.ts | 8 +- .../packages/protocol/src/filesystem.ts | 70 ++++++++ .../agentlet/packages/protocol/src/index.ts | 2 + 10 files changed, 529 insertions(+), 4 deletions(-) create mode 100644 external/agentlet/packages/local/src/filesystem.ts create mode 100644 external/agentlet/packages/local/src/fs-proxy.ts create mode 100644 external/agentlet/packages/local/src/harnesses/proxy.ts create mode 100644 external/agentlet/packages/local/tests/filesystem.test.ts create mode 100644 external/agentlet/packages/local/tests/fs-proxy.test.ts create mode 100644 external/agentlet/packages/protocol/src/filesystem.ts diff --git a/external/agentlet/README.md b/external/agentlet/README.md index 6cb3cf62b..618531a43 100644 --- a/external/agentlet/README.md +++ b/external/agentlet/README.md @@ -18,6 +18,8 @@ It contains no AI or host-application logic. The daemon accepts authenticated co Agentlet does not provide a standalone relay server, REST API, browser UI, token administration service, or durable session/event store. Those control-plane responsibilities belong to the embedding host and its Gateway. +The bundled `proxy` harness is a deterministic ACP agent, not an AI provider. It advertises only `fs-list` and `fs-read` through available commands and accepts `/COMMAND ` followed by a strict JSON object with an absolute `path` and optional stateless continuation cursor. Native read-only operations use the daemon account's filesystem permissions, follow ordinary symlinks, and never execute input as shell commands. Each response is one versioned JSON result or explicit error; listing, UTF-8 reads and serialized output are bounded. The executable is built beside the daemon and needs no downloaded runtime. Hosts decide whether and how to expose this capability to their owner. + ## Packages | Package | Responsibility | diff --git a/external/agentlet/packages/local/src/filesystem.ts b/external/agentlet/packages/local/src/filesystem.ts new file mode 100644 index 000000000..70b4f4dde --- /dev/null +++ b/external/agentlet/packages/local/src/filesystem.ts @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { constants } from 'node:fs' +import { open, opendir, realpath, stat } from 'node:fs/promises' +import path from 'node:path' +import { createHash } from 'node:crypto' +import { + FS_MAX_ENTRIES, FS_MAX_TEXT_BYTES, FS_PROTOCOL_VERSION, parseFsRequest, + type FsEntry, type FsErrorCode, type FsOperation, type FsRequest, type FsResponse, +} from '@agentlet/protocol' + +class FsFailure extends Error { + constructor(readonly code: FsErrorCode, message: string) { super(message) } +} + +function cursor(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString('base64url') +} + +function decodeCursor(value: string | undefined): Record | undefined { + if (value === undefined) return undefined + try { + const parsed: unknown = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed as Record + } catch { + throw new FsFailure('invalid_request', 'Invalid continuation cursor') + } + throw new FsFailure('invalid_request', 'Invalid continuation cursor') +} + +function absolutePath(value: string): string { + if (!path.isAbsolute(value) || (process.platform === 'win32' && + (/^\\\\[?.]\\/.test(value) || value.slice(2).includes(':')))) { + throw new FsFailure('invalid_request', 'Expected a normal absolute filesystem path') + } + return path.resolve(value) +} + +async function list(request: FsRequest, signal: AbortSignal) { + const directory = await realpath(absolutePath(request.path)) + const previous = decodeCursor(request.cursor) + if (previous && (previous.path !== directory || previous.hidden !== !!request.showHidden || + typeof previous.after !== 'string')) { + throw new FsFailure('invalid_request', 'Cursor does not match this directory request') + } + const entries: FsEntry[] = [] + let scanned = 0 + const handle = await opendir(directory) + for await (const entry of handle) { + signal.throwIfAborted() + if (++scanned > 20_000) throw new FsFailure('directory_too_large', 'Directory exceeds the scan limit') + if (!request.showHidden && entry.name.startsWith('.')) continue + // Names come from the OS, but still require direct-child containment. + const child = path.resolve(directory, entry.name) + if (path.dirname(child) !== directory) throw new FsFailure('io_error', 'Invalid directory entry') + entries.push({ + name: entry.name, path: child, + kind: entry.isDirectory() ? 'directory' : entry.isFile() ? 'file' : entry.isSymbolicLink() ? 'symlink' : 'other', + }) + } + entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0) + const limit = request.limit ?? FS_MAX_ENTRIES + const remaining = previous ? entries.filter((entry) => entry.name > String(previous.after)) : entries + const page = remaining.slice(0, limit) + for (const entry of page) { + signal.throwIfAborted() + if (entry.kind !== 'symlink') continue + try { + const target = await stat(entry.path) + entry.kind = target.isDirectory() ? 'directory' : target.isFile() ? 'file' : 'other' + } catch (error) { + if (!(error instanceof Error) || !('code' in error) || + !['ENOENT', 'EACCES', 'EPERM', 'ELOOP'].includes(String(error.code))) throw error + // Broken or inaccessible links remain explicit non-browsable link entries. + } + } + return { + path: directory, parentPath: path.dirname(directory), entries: page, + ...(remaining.length > limit ? { nextCursor: cursor({ + path: directory, hidden: !!request.showHidden, after: page.at(-1)!.name, + }) } : {}), + } +} + +async function read(request: FsRequest, signal: AbortSignal) { + const filename = absolutePath(request.path) + // O_NONBLOCK prevents a FIFO substituted before open from hanging the process. + const handle = await open(filename, constants.O_RDONLY | constants.O_NONBLOCK) + try { + const before = await handle.stat() + if (!before.isFile()) throw new FsFailure('unsupported_file', 'Only regular text files are supported') + if (!Number.isSafeInteger(before.size)) throw new FsFailure('unsupported_file', 'File size exceeds the supported range') + const version = (stat: typeof before) => createHash('sha256') + .update([stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(':')).digest('hex') + const contentVersion = version(before) + const previous = decodeCursor(request.cursor) + if (previous && (previous.path !== filename || previous.version !== contentVersion)) { + throw new FsFailure('stale_cursor', 'File changed; read it again from the beginning') + } + const offset = previous?.offset ?? 0 + if (typeof offset !== 'number' || !Number.isSafeInteger(offset) || offset < 0 || offset > before.size) { + throw new FsFailure('invalid_request', 'Invalid continuation offset') + } + signal.throwIfAborted() + const limit = request.limitBytes ?? FS_MAX_TEXT_BYTES + const buffer = Buffer.alloc(limit + 1) + let bytesRead = 0 + while (bytesRead < buffer.length) { + signal.throwIfAborted() + const chunk = await handle.read(buffer, bytesRead, buffer.length - bytesRead, offset + bytesRead) + if (chunk.bytesRead === 0) break + bytesRead += chunk.bytesRead + } + signal.throwIfAborted() + if (version(await handle.stat()) !== contentVersion) throw new FsFailure('stale_cursor', 'File changed during reading') + let length = Math.min(bytesRead, limit) + if (bytesRead > limit) { + while (length > 0 && (buffer[length]! & 0xc0) === 0x80) length-- + } + let text: string + try { text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: offset !== 0 }).decode(buffer.subarray(0, length)) } + catch { throw new FsFailure('unsupported_encoding', 'File is not valid UTF-8 text') } + if (/[\u0000-\u0008\u000b\u000e-\u001f]/.test(text)) { + throw new FsFailure('unsupported_encoding', 'File contains unsupported binary or control data') + } + return { + path: filename, encoding: 'utf-8' as const, text, byteLength: length, size: before.size, contentVersion, + ...(offset + length < before.size ? { nextCursor: cursor({ + path: filename, version: contentVersion, offset: offset + length, + }) } : {}), + } + } finally { await handle.close() } +} + +export async function executeFsCommand( + operation: FsOperation, input: unknown, signal: AbortSignal = new AbortController().signal, +): Promise { + try { + let request: FsRequest + try { request = parseFsRequest(operation, input) } + catch { throw new FsFailure('invalid_request', 'Invalid filesystem request') } + const result = operation === 'fs-list' ? await list(request, signal) : await read(request, signal) + signal.throwIfAborted() + return { version: FS_PROTOCOL_VERSION, operation, ok: true, result } + } catch (error) { + let code: FsErrorCode = 'io_error' + let message = 'Filesystem operation failed' + if (signal.aborted) { code = 'cancelled'; message = 'Operation cancelled' } + else if (error instanceof FsFailure) { code = error.code; message = error.message } + else if (error instanceof Error && 'code' in error) { + if (error.code === 'EACCES' || error.code === 'EPERM') { code = 'permission_denied'; message = 'Permission denied' } + if (error.code === 'ENOENT') { code = 'not_found'; message = 'Path not found' } + if (error.code === 'ENOTDIR') { code = 'not_directory'; message = 'Path is not a directory' } + } + return { version: FS_PROTOCOL_VERSION, operation, ok: false, error: { code, message } } + } +} diff --git a/external/agentlet/packages/local/src/fs-proxy.ts b/external/agentlet/packages/local/src/fs-proxy.ts new file mode 100644 index 000000000..6c10418a7 --- /dev/null +++ b/external/agentlet/packages/local/src/fs-proxy.ts @@ -0,0 +1,74 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { randomUUID } from 'node:crypto' +import { AgentSideConnection, ndJsonStream, PROTOCOL_VERSION, RequestError, + type Agent, type InitializeResponse, type NewSessionResponse, type PromptRequest, type PromptResponse, +} from '@agentclientprotocol/sdk' +import { FS_MAX_RESPONSE_BYTES } from '@agentlet/protocol' +import { executeFsCommand } from './filesystem.js' + +class FilesystemProxy implements Agent { + private sessionId: string | undefined + private active: AbortController | undefined + constructor(private readonly connection: AgentSideConnection) {} + async initialize(): Promise { + return { protocolVersion: PROTOCOL_VERSION, agentInfo: { name: 'proxy', version: '1' }, + agentCapabilities: { loadSession: false } } + } + async authenticate() { return {} } + async newSession(): Promise { + if (this.sessionId) throw RequestError.invalidParams('Proxy supports one private session') + this.sessionId = randomUUID() + await this.connection.sessionUpdate({ + sessionId: this.sessionId, + update: { sessionUpdate: 'available_commands_update', availableCommands: [ + { name: 'fs-list', description: 'List a directory (filesystem contract v1)', input: { hint: '{"path":"/absolute/path","cursor":"optional","limit":100,"showHidden":false}' } }, + { name: 'fs-read', description: 'Read UTF-8 text (filesystem contract v1)', input: { hint: '{"path":"/absolute/path","cursor":"optional","limitBytes":65536}' } }, + ] }, + }) + return { sessionId: this.sessionId } + } + async prompt(params: PromptRequest): Promise { + if (params.sessionId !== this.sessionId || this.active) throw RequestError.invalidParams('Invalid or busy proxy session') + if (params.prompt.length !== 1 || params.prompt[0]?.type !== 'text') throw RequestError.invalidParams('Expected one command text block') + const match = /^\/(fs-list|fs-read) ([\s\S]+)$/.exec(params.prompt[0].text) + if (!match) throw RequestError.invalidParams('Unknown filesystem command') + let input: unknown + try { input = JSON.parse(match[2]!) } catch { throw RequestError.invalidParams('Expected JSON command arguments') } + const operation = match[1] === 'fs-list' ? 'fs-list' : 'fs-read' + const controller = new AbortController() + this.active = controller + try { + const result = await executeFsCommand(operation, input, controller.signal) + const text = JSON.stringify(result) + if (Buffer.byteLength(text) > FS_MAX_RESPONSE_BYTES) throw RequestError.internalError('Filesystem response exceeds limit') + if (controller.signal.aborted) return { stopReason: 'cancelled' } + await this.connection.sessionUpdate({ + sessionId: params.sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } }, + }) + return { stopReason: 'end_turn' } + } finally { this.active = undefined } + } + async cancel(params: { sessionId: string }) { + if (params.sessionId === this.sessionId) this.active?.abort() + } +} + +const input = process.stdin[Symbol.asyncIterator]() +new AgentSideConnection((connection) => new FilesystemProxy(connection), ndJsonStream( + new WritableStream({ + write(chunk) { return new Promise((resolve, reject) => { + process.stdout.write(chunk, (error) => error ? reject(error) : resolve()) + }) }, + }), + new ReadableStream({ + async pull(controller) { + const next = await input.next() + if (next.done) controller.close() + else if (Buffer.isBuffer(next.value)) controller.enqueue(next.value) + else controller.error(new Error('Expected binary stdin')) + }, + }), +)) diff --git a/external/agentlet/packages/local/src/harnesses/proxy.ts b/external/agentlet/packages/local/src/harnesses/proxy.ts new file mode 100644 index 000000000..8b2504581 --- /dev/null +++ b/external/agentlet/packages/local/src/harnesses/proxy.ts @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { existsSync } from 'node:fs' +import { dirname, join } from 'node:path' +import type { HarnessDefinition } from './types.js' + +function quoteTrustedPath(value: string): string { + if (process.platform === 'win32') { + if (/["%\r\n!]/.test(value)) throw new Error('Unsupported character in proxy installation path') + return `"${value}"` + } + return `'${value.replaceAll("'", "'\\''")}'` +} + +function proxyEntry(): string { return join(dirname(process.argv[1]!), 'fs-proxy.js') } + +export const proxyHarness: HarnessDefinition = { + id: 'proxy', displayName: 'Filesystem Proxy', installHint: 'Included with Agentlet', + capabilities: { customLaunchCommand: false, autoApprove: false }, + async discover() { return { status: existsSync(proxyEntry()) ? 'ready' : 'not-found' } }, + compile() { + if (!existsSync(proxyEntry())) throw new Error('Bundled filesystem proxy is missing') + const nodeMode = process.platform === 'win32' ? 'set "ELECTRON_RUN_AS_NODE=1"&& ' : 'ELECTRON_RUN_AS_NODE=1 ' + return { kind: 'shell', command: `${nodeMode}${quoteTrustedPath(process.execPath)} ${quoteTrustedPath(proxyEntry())}` } + }, +} diff --git a/external/agentlet/packages/local/src/harnesses/registry.ts b/external/agentlet/packages/local/src/harnesses/registry.ts index fc5747604..5b60de357 100644 --- a/external/agentlet/packages/local/src/harnesses/registry.ts +++ b/external/agentlet/packages/local/src/harnesses/registry.ts @@ -9,6 +9,7 @@ import { hermesHarness } from './hermes.js' import { kimiHarness } from './kimi.js' import { opencodeHarness } from './opencode.js' import { qwenHarness } from './qwen.js' +import { proxyHarness } from './proxy.js' import type { HarnessDefinition } from './types.js' export const HARNESS_DEFINITIONS: readonly HarnessDefinition[] = [ @@ -22,6 +23,7 @@ export const HARNESS_DEFINITIONS: readonly HarnessDefinition[] = [ cursorHarness, codebuddyHarness, hermesHarness, + proxyHarness, customHarness, ] diff --git a/external/agentlet/packages/local/tests/filesystem.test.ts b/external/agentlet/packages/local/tests/filesystem.test.ts new file mode 100644 index 000000000..91d0a4fa7 --- /dev/null +++ b/external/agentlet/packages/local/tests/filesystem.test.ts @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { mkdtemp, mkdir, writeFile, rm, symlink, rename } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { executeFsCommand } from '../src/filesystem.js' + +let directory: string +beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'agentlet-files-')) }) +afterEach(async () => { await rm(directory, { recursive: true, force: true }) }) + +describe('stateless filesystem commands', () => { + it('lists bounded pages with hidden-file filtering and absolute navigation paths', async () => { + for (const name of ['a', 'b', 'c', 'd', 'e', '.hidden']) await writeFile(join(directory, name), '') + const first = await executeFsCommand('fs-list', { path: directory, limit: 4 }) + expect(first.ok).toBe(true) + if (!first.ok || !('entries' in first.result)) throw new Error('Expected directory') + expect(first.result.entries.map((entry) => entry.name)).toEqual(['a', 'b', 'c', 'd']) + expect(first.result.entries[0]?.path).toBe(join(directory, 'a')) + const next = await executeFsCommand('fs-list', { path: directory, cursor: first.result.nextCursor }) + expect(next).toMatchObject({ ok: true, result: { entries: [{ name: 'e' }] } }) + expect(await executeFsCommand('fs-list', { path: directory, showHidden: true })).toMatchObject({ + ok: true, result: { entries: expect.arrayContaining([expect.objectContaining({ name: '.hidden' })]) }, + }) + }) + + it('continues UTF-8 at byte boundaries and detects replacement between chunks', async () => { + const path = join(directory, 'text') + const text = 'abc你好🙂end' + await writeFile(path, text) + let cursor: string | undefined + let actual = '' + do { + const response = await executeFsCommand('fs-read', { path, limitBytes: 4, ...(cursor ? { cursor } : {}) }) + if (!response.ok || !('text' in response.result)) throw new Error(JSON.stringify(response)) + actual += response.result.text + cursor = response.result.nextCursor + } while (cursor) + expect(actual).toBe(text) + const first = await executeFsCommand('fs-read', { path, limitBytes: 4 }) + if (!first.ok || !('text' in first.result)) throw new Error('Expected text') + await writeFile(path, 'different text') + expect(await executeFsCommand('fs-read', { path, cursor: first.result.nextCursor })).toMatchObject({ + ok: false, error: { code: 'stale_cursor' }, + }) + }) + + it('rejects invalid requests, binary text, malformed UTF-8 and directories without leaking OS errors', async () => { + expect(await executeFsCommand('fs-list', { path: '.', extra: true })).toMatchObject({ ok: false, error: { code: 'invalid_request' } }) + expect(await executeFsCommand('fs-read', { path: directory })).toMatchObject({ ok: false, error: { code: 'unsupported_file' } }) + const path = join(directory, 'bytes') + await writeFile(path, Buffer.from([0xff])) + expect(await executeFsCommand('fs-read', { path })).toMatchObject({ ok: false, error: { code: 'unsupported_encoding' } }) + await writeFile(path, Buffer.from([0])) + expect(await executeFsCommand('fs-read', { path })).toMatchObject({ ok: false, error: { code: 'unsupported_encoding' } }) + expect(await executeFsCommand('fs-read', { path: join(directory, 'missing') })).toEqual({ + version: 1, operation: 'fs-read', ok: false, error: { code: 'not_found', message: 'Path not found' }, + }) + }) + + it('follows normal symlinks and has no project-root confinement', async () => { + await mkdir(join(directory, 'root')) + await writeFile(join(directory, 'outside.txt'), 'outside') + if (process.platform !== 'win32') { + await symlink('../outside.txt', join(directory, 'root', 'link')) + expect(await executeFsCommand('fs-read', { path: join(directory, 'root', 'link') })).toMatchObject({ ok: true, result: { text: 'outside' } }) + await symlink('root', join(directory, 'directory-link')) + expect(await executeFsCommand('fs-list', { path: directory })).toMatchObject({ ok: true, result: { + entries: expect.arrayContaining([expect.objectContaining({ name: 'directory-link', kind: 'directory' })]), + } }) + } + expect(await executeFsCommand('fs-read', { path: join(directory, 'outside.txt') })).toMatchObject({ ok: true, result: { text: 'outside' } }) + }) + + it('accepts single-entry pages, binds list cursors to options, and returns empty files', async () => { + await writeFile(join(directory, 'a'), '') + await writeFile(join(directory, 'b'), '') + const first = await executeFsCommand('fs-list', { path: directory, limit: 1 }) + if (!first.ok || !('entries' in first.result)) throw new Error('Expected list') + expect(first.result.entries).toHaveLength(1) + expect(await executeFsCommand('fs-list', { path: directory, showHidden: true, cursor: first.result.nextCursor })).toMatchObject({ ok: false, error: { code: 'invalid_request' } }) + expect(await executeFsCommand('fs-read', { path: join(directory, 'a') })).toMatchObject({ ok: true, result: { text: '', byteLength: 0, size: 0 } }) + }) + + it('bounds large text chunks and rejects continuation after atomic-save replacement', async () => { + const path = join(directory, 'large.tex') + await writeFile(path, '🙂'.repeat(30_000)) + const first = await executeFsCommand('fs-read', { path }) + if (!first.ok || !('text' in first.result)) throw new Error('Expected text') + expect(first.result.byteLength).toBe(65_536) + expect(Buffer.byteLength(first.result.text)).toBe(first.result.byteLength) + await writeFile(join(directory, 'replacement'), '\\documentclass{article}') + await rename(join(directory, 'replacement'), path) + expect(await executeFsCommand('fs-read', { path, cursor: first.result.nextCursor })).toMatchObject({ ok: false, error: { code: 'stale_cursor' } }) + expect(await executeFsCommand('fs-read', { path })).toMatchObject({ ok: true, result: { text: '\\documentclass{article}' } }) + }) + + it('validates command-specific limits and cursors', async () => { + for (const args of [{ limit: 101 }, { limit: 0 }, { limitBytes: 4 }, { cursor: 'not-json' }]) { + expect(await executeFsCommand('fs-list', { path: directory, ...args })).toMatchObject({ ok: false, error: { code: 'invalid_request' } }) + } + await writeFile(join(directory, 'text'), 'hello') + for (const args of [{ limitBytes: 65_537 }, { limitBytes: 1 }, { showHidden: true }, { cursor: 'not-json' }]) { + expect(await executeFsCommand('fs-read', { path: join(directory, 'text'), ...args })).toMatchObject({ ok: false, error: { code: 'invalid_request' } }) + } + }) + + it('caps directory output at 100 entries without omitting the continuation', async () => { + await Promise.all(Array.from({ length: 105 }, (_, index) => writeFile(join(directory, `entry-${index.toString().padStart(3, '0')}`), ''))) + const first = await executeFsCommand('fs-list', { path: directory }) + if (!first.ok || !('entries' in first.result)) throw new Error('Expected list') + expect(first.result.entries).toHaveLength(100) + const next = await executeFsCommand('fs-list', { path: directory, cursor: first.result.nextCursor }) + if (!next.ok || !('entries' in next.result)) throw new Error('Expected continuation') + expect(next.result.entries).toHaveLength(5) + expect(next.result.nextCursor).toBeUndefined() + }) + + it('returns cancellation explicitly', async () => { + const controller = new AbortController() + controller.abort() + expect(await executeFsCommand('fs-list', { path: directory }, controller.signal)).toMatchObject({ ok: false, error: { code: 'cancelled' } }) + }) +}) diff --git a/external/agentlet/packages/local/tests/fs-proxy.test.ts b/external/agentlet/packages/local/tests/fs-proxy.test.ts new file mode 100644 index 000000000..48873961b --- /dev/null +++ b/external/agentlet/packages/local/tests/fs-proxy.test.ts @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createInterface } from 'node:readline' +import { fileURLToPath } from 'node:url' +import { expect, it } from 'vitest' + +it('speaks ACP over stdio with discoverable deterministic filesystem commands', async () => { + const directory = await mkdtemp(join(tmpdir(), 'proxy-acp-')) + const child = spawn(process.execPath, [process.env.AGENTLET_FS_PROXY_TEST_ENTRY ?? fileURLToPath(new URL('../dist/fs-proxy.js', import.meta.url))], { stdio: 'pipe' }) + const lines = createInterface({ input: child.stdout }) + const notifications: Array> = [] + const pending = new Map) => void>() + let sequence = 0 + lines.on('line', (line) => { + const packet: Record = JSON.parse(line) + if (typeof packet.id === 'number') pending.get(packet.id)?.(packet) + else notifications.push(packet) + }) + const request = (method: string, params: unknown) => new Promise((resolve, reject) => { + const id = ++sequence + const timer = setTimeout(() => { pending.delete(id); reject(new Error(`ACP timeout: ${method}`)) }, 5000) + pending.set(id, (packet) => { + clearTimeout(timer) + pending.delete(id) + if (packet.error) reject(new Error(JSON.stringify(packet.error))) + else resolve(packet.result) + }) + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`) + }) + try { + await writeFile(join(directory, 'source.tex'), '\\documentclass{article}') + expect(await request('initialize', { protocolVersion: 1, clientCapabilities: {} })).toMatchObject({ + protocolVersion: 1, agentInfo: { name: 'proxy' }, + }) + const session = await request('session/new', { cwd: directory, mcpServers: [] }) + if (!session || typeof session !== 'object' || !('sessionId' in session)) throw new Error('Missing ACP session') + expect(JSON.stringify(notifications)).toContain('available_commands_update') + expect(JSON.stringify(notifications)).toContain('fs-list') + expect(JSON.stringify(notifications)).toContain('fs-read') + expect(await request('session/prompt', { sessionId: session.sessionId, prompt: [ + { type: 'text', text: `/fs-list ${JSON.stringify({ path: directory })}` }, + ] })).toEqual({ stopReason: 'end_turn' }) + expect(JSON.stringify(notifications)).toContain('source.tex') + expect(await request('session/prompt', { sessionId: session.sessionId, prompt: [ + { type: 'text', text: `/fs-read ${JSON.stringify({ path: join(directory, 'source.tex') })}` }, + ] })).toEqual({ stopReason: 'end_turn' }) + expect(JSON.stringify(notifications)).toContain('documentclass') + await expect(request('session/prompt', { sessionId: session.sessionId, prompt: [ + { type: 'text', text: '/shell touch forbidden' }, + ] })).rejects.toThrow('Unknown filesystem command') + } finally { + const closed = once(child, 'close') + child.kill() + await closed + lines.close() + await rm(directory, { recursive: true, force: true }) + } +}) diff --git a/external/agentlet/packages/local/tests/harnesses.test.ts b/external/agentlet/packages/local/tests/harnesses.test.ts index 33225a9b8..167d76460 100644 --- a/external/agentlet/packages/local/tests/harnesses.test.ts +++ b/external/agentlet/packages/local/tests/harnesses.test.ts @@ -72,8 +72,8 @@ describe('trusted harness discovery', () => { it('returns the complete stable catalogue without creating workspaces by default', async () => { const { harnesses } = await discoverHarnesses() expect(harnesses.map((entry) => entry.id)).toEqual(HARNESS_DEFINITIONS.map((entry) => entry.id)) - expect(harnesses).toHaveLength(11) - expect(harnesses.every((entry) => entry.status === 'ready')).toBe(true) + expect(harnesses).toHaveLength(12) + expect(harnesses.filter((entry) => entry.id !== 'proxy').every((entry) => entry.status === 'ready')).toBe(true) expect(harnesses.at(-1)).toMatchObject({ id: 'custom', capabilities: { customLaunchCommand: true, autoApprove: false }, @@ -149,7 +149,7 @@ describe('trusted harness discovery', () => { : '1.0\r\n', })) const { harnesses } = await discoverHarnesses() - expect(harnesses.filter((entry) => entry.id !== 'custom').every((entry) => entry.status === 'ready')).toBe(true) + expect(harnesses.filter((entry) => entry.id !== 'custom' && entry.id !== 'proxy').every((entry) => entry.status === 'ready')).toBe(true) expect(harnesses[0]).toMatchObject({ status: 'ready', capabilities: { autoApprove: true, customLaunchCommand: false }, @@ -229,7 +229,7 @@ describe('daemon discovery RPC', () => { const response = await createDaemon().request(ServerMethods.DISCOVER_HARNESSES, params) expect(response).toHaveProperty('result') const result = ('result' in response ? response.result : undefined) as HarnessDiscoveryResult - expect(result.harnesses).toHaveLength(11) + expect(result.harnesses).toHaveLength(12) expect(mocks.mkdir).toHaveBeenCalledTimes(params && 'prepareWorkspaces' in params && params.prepareWorkspaces ? 10 : 0) expect(start).not.toHaveBeenCalled() } finally { diff --git a/external/agentlet/packages/protocol/src/filesystem.ts b/external/agentlet/packages/protocol/src/filesystem.ts new file mode 100644 index 000000000..07a58347d --- /dev/null +++ b/external/agentlet/packages/protocol/src/filesystem.ts @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export const FS_PROTOCOL_VERSION = 1 +export const FS_MAX_TEXT_BYTES = 65_536 +export const FS_MAX_RESPONSE_BYTES = 524_288 +export const FS_MAX_ENTRIES = 100 + +export type FsOperation = 'fs-list' | 'fs-read' +export interface FsRequest { + path: string + cursor?: string + limit?: number + limitBytes?: number + showHidden?: boolean +} +export interface FsEntry { + name: string + path: string + kind: 'directory' | 'file' | 'symlink' | 'other' +} +export interface FsListResult { + path: string + parentPath: string + entries: FsEntry[] + nextCursor?: string +} +export interface FsReadResult { + path: string + encoding: 'utf-8' + text: string + byteLength: number + size: number + contentVersion: string + nextCursor?: string +} +export type FsResult = FsListResult | FsReadResult +export const FS_ERROR_CODES = [ + 'invalid_request', 'permission_denied', 'not_found', 'not_directory', + 'unsupported_file', 'unsupported_encoding', 'directory_too_large', + 'stale_cursor', 'cancelled', 'busy', 'io_error', +] as const +export type FsErrorCode = typeof FS_ERROR_CODES[number] +export type FsResponse = + | { version: 1; operation: FsOperation; ok: true; result: FsResult } + | { version: 1; operation: FsOperation; ok: false; error: { code: FsErrorCode; message: string } } + +export function parseFsRequest(operation: FsOperation, value: unknown): FsRequest { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Expected a JSON object') + } + const record = value as Record + const keys = operation === 'fs-list' + ? ['path', 'cursor', 'limit', 'showHidden'] + : ['path', 'cursor', 'limitBytes'] + if (Object.keys(record).some((key) => !keys.includes(key)) || + typeof record.path !== 'string' || record.path.length === 0 || + record.path.length > 4096 || /[\u0000-\u001f]/.test(record.path) || + (record.cursor !== undefined && (typeof record.cursor !== 'string' || record.cursor.length > 16_384)) || + (record.showHidden !== undefined && typeof record.showHidden !== 'boolean')) { + throw new Error('Invalid filesystem request') + } + const limitKey = operation === 'fs-list' ? 'limit' : 'limitBytes' + const maximum = operation === 'fs-list' ? FS_MAX_ENTRIES : FS_MAX_TEXT_BYTES + const limit = record[limitKey] + if (limit !== undefined && (typeof limit !== 'number' || !Number.isSafeInteger(limit) || limit < (operation === 'fs-list' ? 1 : 4) || limit > maximum)) { + throw new Error('Invalid filesystem request limit') + } + return record as unknown as FsRequest +} diff --git a/external/agentlet/packages/protocol/src/index.ts b/external/agentlet/packages/protocol/src/index.ts index 8438fc09e..806cf0680 100644 --- a/external/agentlet/packages/protocol/src/index.ts +++ b/external/agentlet/packages/protocol/src/index.ts @@ -11,6 +11,8 @@ export { ErrorCodes, } from './constants.js' +export * from './filesystem.js' + export { parseAcpHarnessLaunch, parseAgentProfileLaunch } from './harness.js' export { CUSTOM_COMMAND_WRAPPER_ID, CUSTOM_COMMAND_CAPABILITIES } from './harness.js' export type { From c877e671f906cd9eb41dbbe28eb65c556435e02c Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Wed, 28 Oct 2026 09:20:33 +0000 Subject: [PATCH 2/5] feat(agenetes): persist hidden Profiles outside selectable catalogues Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- external/agenetes/README.md | 2 ++ .../packages/agent-profile/src/registry.test.ts | 11 +++++++++++ .../agenetes/packages/agent-profile/src/registry.ts | 6 +++++- external/agenetes/packages/agent-profile/src/store.ts | 1 + external/agenetes/packages/agent-profile/src/types.ts | 2 ++ 5 files changed, 21 insertions(+), 1 deletion(-) diff --git a/external/agenetes/README.md b/external/agenetes/README.md index ed610de98..5a1d757f1 100644 --- a/external/agenetes/README.md +++ b/external/agenetes/README.md @@ -69,6 +69,8 @@ Each surface has an in-process programmatic form today, used when Agenetes is mo ## Structured ACP Profiles +Profiles may carry optional `visible: false` to exclude host-managed runtime identities from `listSelectableProfileIds()` while preserving normal registry lookup, storage and frozen snapshots. Missing visibility remains visible without migration. This is a catalogue/presentation property, not an authorization boundary; the embedding host owns which hidden Profiles it provisions and invokes. + The ACP driver accepts both `Job` and `Deployment`. They share session bootstrap, prompt streaming, and controls, but have different ownership: each Job handle gets a unique private session key, may run once, and is automatically closed by Agenetes when its generator completes, throws, or is returned early; Deployment session keys remain their thread IDs and stay live until explicit close or idle suspension. Job close removes the local ACP entry and waits for the exact Agentlet `{ agentletId, sessionId }` process to be confirmed stopped. Concurrent closes share one operation, an already-absent process is success, and an unconfirmed stop rejects rather than reporting successful completion. Agenetes logs threaded Jobs and skips persistence for empty-thread Jobs. ACP image content blocks require an explicit `agentCapabilities.promptCapabilities.image: true` in the initialized Agent's response; absent or unsupported capability fails before `session/prompt` rather than dropping pixels. Image bytes and MIME types pass through unchanged. Hosts may provide `AcpTurnCtx.drainHostEvents` to include validated side-channel results before the next driver event and before termination; these events pass through normal Agenetes logging and transcript folding. This does not alter session retention or add another transport. diff --git a/external/agenetes/packages/agent-profile/src/registry.test.ts b/external/agenetes/packages/agent-profile/src/registry.test.ts index 7d8fd211c..d65ab333e 100644 --- a/external/agenetes/packages/agent-profile/src/registry.test.ts +++ b/external/agenetes/packages/agent-profile/src/registry.test.ts @@ -23,6 +23,17 @@ const input: CreateAgentProfileInput = { }; describe('AgentProfileRegistry', () => { + it('persists hidden Profiles without selecting them and preserves legacy visibility', () => { + const store = new InMemoryAgentProfileRegistryStore(); + const registry = new AgentProfileRegistry(store); + registry.createProfile(input); + registry.createProfile({ ...input, id: 'hidden', visible: false }); + const restored = new AgentProfileRegistry(store); + expect(restored.listProfiles()).toHaveLength(2); + expect(restored.getProfile('hidden')?.visible).toBe(false); + expect(restored.listSelectableProfileIds()).toEqual(['ordinary']); + expect(restored.snapshotProfile('hidden').profileId).toBe('hidden'); + }); it('persists structured launch separately from editable metadata and clones immutable snapshots', () => { const store = new InMemoryAgentProfileRegistryStore(); const registry = new AgentProfileRegistry(store); diff --git a/external/agenetes/packages/agent-profile/src/registry.ts b/external/agenetes/packages/agent-profile/src/registry.ts index 4d77c1bcd..19a701c0e 100644 --- a/external/agenetes/packages/agent-profile/src/registry.ts +++ b/external/agenetes/packages/agent-profile/src/registry.ts @@ -24,6 +24,7 @@ export function commandProfile( return parseProfile({ id: input.id ?? generateId(), alias: input.alias, + ...(input.visible === undefined ? {} : { visible: input.visible }), agentletId: input.agentletId, workingDirPath: input.workingDirPath, launch: { kind: 'acp-command', command: input.command }, @@ -39,6 +40,7 @@ export function harnessProfile( return parseProfile({ id: input.id ?? generateId(), alias: input.alias, + ...(input.visible === undefined ? {} : { visible: input.visible }), agentletId: input.agentletId, workingDirPath: input.workingDirPath, launch: { @@ -82,7 +84,9 @@ export class AgentProfileRegistry { return structuredClone(this.state.profiles); } listSelectableProfileIds(): string[] { - return this.state.profiles.map(({ id }) => id); + return this.state.profiles + .filter((profile) => profile.visible !== false) + .map(({ id }) => id); } snapshotProfile(id: string): AgentProfileSnapshot { const { agentletId, workingDirPath, launch, executionRevision } = diff --git a/external/agenetes/packages/agent-profile/src/store.ts b/external/agenetes/packages/agent-profile/src/store.ts index 3b083afe1..6f24a6591 100644 --- a/external/agenetes/packages/agent-profile/src/store.ts +++ b/external/agenetes/packages/agent-profile/src/store.ts @@ -25,6 +25,7 @@ export const profileSchema = z .object({ id: identity, alias: identity, + visible: z.boolean().optional(), agentletId: nonempty, workingDirPath: nonempty, launch: agentProfileLaunchSchema, diff --git a/external/agenetes/packages/agent-profile/src/types.ts b/external/agenetes/packages/agent-profile/src/types.ts index a83c464b4..5cc84fb63 100644 --- a/external/agenetes/packages/agent-profile/src/types.ts +++ b/external/agenetes/packages/agent-profile/src/types.ts @@ -9,6 +9,7 @@ export type JsonValue = export interface AgentProfile { id: string; alias: string; + visible?: boolean; agentletId: string; workingDirPath: string; launch: AgentProfileLaunch; @@ -35,6 +36,7 @@ export interface AgentProfileSnapshot { export interface CreateAcpCommandProfileInput { id?: string; alias: string; + visible?: boolean; agentletId: string; command: string; workingDirPath: string; From 560ee142d0f0dacda2517a8929b000b01501924b Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Wed, 28 Oct 2026 09:32:54 +0000 Subject: [PATCH 3/5] fix(agentlet): reject unstable file reads and narrow validated requests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- external/agentlet/packages/local/src/filesystem.ts | 2 ++ external/agentlet/packages/local/tests/filesystem.test.ts | 4 ++++ external/agentlet/packages/protocol/src/filesystem.ts | 7 ++++++- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/external/agentlet/packages/local/src/filesystem.ts b/external/agentlet/packages/local/src/filesystem.ts index 70b4f4dde..648b307f3 100644 --- a/external/agentlet/packages/local/src/filesystem.ts +++ b/external/agentlet/packages/local/src/filesystem.ts @@ -114,6 +114,8 @@ async function read(request: FsRequest, signal: AbortSignal) { } signal.throwIfAborted() if (version(await handle.stat()) !== contentVersion) throw new FsFailure('stale_cursor', 'File changed during reading') + if (offset + bytesRead > before.size) throw new FsFailure('unsupported_file', 'File does not provide stable size metadata') + if (bytesRead < Math.min(buffer.length, before.size - offset)) throw new FsFailure('io_error', 'File ended before its reported size') let length = Math.min(bytesRead, limit) if (bytesRead > limit) { while (length > 0 && (buffer[length]! & 0xc0) === 0x80) length-- diff --git a/external/agentlet/packages/local/tests/filesystem.test.ts b/external/agentlet/packages/local/tests/filesystem.test.ts index 91d0a4fa7..16dcd5739 100644 --- a/external/agentlet/packages/local/tests/filesystem.test.ts +++ b/external/agentlet/packages/local/tests/filesystem.test.ts @@ -123,4 +123,8 @@ describe('stateless filesystem commands', () => { controller.abort() expect(await executeFsCommand('fs-list', { path: directory }, controller.signal)).toMatchObject({ ok: false, error: { code: 'cancelled' } }) }) + + it.skipIf(process.platform !== 'linux')('rejects generated procfs files without stable size metadata rather than silently truncating', async () => { + expect(await executeFsCommand('fs-read', { path: '/proc/self/status' })).toMatchObject({ ok: false, error: { code: 'unsupported_file' } }) + }) }) diff --git a/external/agentlet/packages/protocol/src/filesystem.ts b/external/agentlet/packages/protocol/src/filesystem.ts index 07a58347d..faaa934df 100644 --- a/external/agentlet/packages/protocol/src/filesystem.ts +++ b/external/agentlet/packages/protocol/src/filesystem.ts @@ -66,5 +66,10 @@ export function parseFsRequest(operation: FsOperation, value: unknown): FsReques if (limit !== undefined && (typeof limit !== 'number' || !Number.isSafeInteger(limit) || limit < (operation === 'fs-list' ? 1 : 4) || limit > maximum)) { throw new Error('Invalid filesystem request limit') } - return record as unknown as FsRequest + return { + path: record.path, + ...(typeof record.cursor === 'string' ? { cursor: record.cursor } : {}), + ...(typeof record.showHidden === 'boolean' ? { showHidden: record.showHidden } : {}), + ...(typeof limit === 'number' ? operation === 'fs-list' ? { limit } : { limitBytes: limit } : {}), + } } From 277cd537d1d72348dfaa7f22f7f5e8af4c4f7c26 Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Wed, 28 Oct 2026 09:32:54 +0000 Subject: [PATCH 4/5] feat(files): browse connected devices with read-only Canvas references Reuse private ACP Jobs, Profile discovery, Common controls, Canvas commands, virtualized lists, and read-only CodeMirror Preview for issue #349. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/server/src/app.ts | 2 + .../src/modules/agent/acp/agent-cli.route.ts | 6 +- .../modules/agent/acp/device-files.route.ts | 54 +++ .../modules/agent/acp/device-files.test.ts | 305 +++++++++++++ .../src/modules/agent/acp/device-files.ts | 376 +++++++++++++++++ .../acp/harness-profile-discovery.test.ts | 25 ++ .../agent/acp/harness-profile-discovery.ts | 6 +- .../modules/agent/acp/profiles.route.test.ts | 32 ++ .../src/modules/agent/acp/profiles.route.ts | 15 +- .../modules/agent/acp/threads.route.test.ts | 1 + .../src/modules/agent/acp/threads.route.ts | 3 + .../src/modules/agent/agent-defaults.test.ts | 14 + .../src/modules/agent/agent-defaults.ts | 4 +- .../agent/selectable-agent-profile.test.ts | 19 +- .../modules/agent/selectable-agent-profile.ts | 6 +- .../modules/canvas/canvas-content-cas.test.ts | 88 ++++ .../modules/canvas/canvas-executor.test.ts | 78 ++++ .../src/modules/canvas/canvas-executor.ts | 1 + .../server/src/modules/canvas/canvas.route.ts | 29 ++ .../src/modules/preprocessing/profiles.ts | 6 + apps/server/tsup.config.ts | 3 + apps/web/src/api/_routes.ts | 1 + apps/web/src/api/device-files.ts | 50 +++ .../CodeMirror/RawMarkdownEditor.test.tsx | 25 ++ .../CodeMirror/RawMarkdownEditor.tsx | 7 +- .../Nodes/fileReference/FileReferenceNode.tsx | 63 +++ .../FileReferencePreview.test.tsx | 109 +++++ .../fileReference/FileReferencePreview.tsx | 165 ++++++++ apps/web/src/components/Nodes/previews.ts | 2 + apps/web/src/components/Nodes/types.ts | 2 + .../src/components/Panels/Canvas/Canvas.tsx | 14 + .../Panels/CanvasLayerPanel/layerFilterKey.ts | 2 + .../src/components/Panels/CanvasSidebar.tsx | 60 +++ .../WorkingDirectoryOverride.test.tsx | 25 ++ .../ChatPanel/WorkingDirectoryOverride.tsx | 18 + .../src/components/Panels/ChatPanel/index.tsx | 12 +- .../src/components/Panels/FilesPanel.test.tsx | 160 +++++++ apps/web/src/components/Panels/FilesPanel.tsx | 399 ++++++++++++++++++ apps/web/src/config/nodeIcons.ts | 2 + apps/web/src/i18n/resources/en/common.json | 26 ++ apps/web/src/i18n/resources/zh-CN/common.json | 26 ++ apps/web/src/pages/CanvasPage/CanvasPage.tsx | 4 +- .../playground/NodeToolbarPlaygroundPage.tsx | 1 + .../canvasStore/save/nodeContentFields.ts | 1 + apps/web/src/store/conversationPreparation.ts | 1 + apps/web/src/store/panelStore.ts | 14 + apps/web/src/utils/io/dragDrop.ts | 31 +- apps/web/src/utils/io/payloadToMarkdown.ts | 2 + docs/README.md | 2 + docs/architecture/agent-profiles.md | 4 +- docs/architecture/canvas-storage.md | 2 + docs/architecture/device-files.md | 54 +++ docs/architecture/node-preprocessing.md | 2 + docs/architecture/preview-workspace.md | 2 + .../src/canvas-engine/commands/createNodes.ts | 10 + .../canvas-engine/commands/mergeNodeData.ts | 8 + packages/shared/src/index.ts | 1 + packages/shared/src/types/api/acp.ts | 1 + packages/shared/src/types/api/agent-node.ts | 16 + .../shared/src/types/api/agent-profile.ts | 11 +- packages/shared/src/types/api/device-files.ts | 138 ++++++ packages/shared/src/types/api/index.ts | 1 + packages/shared/src/types/canvas/index.ts | 1 + packages/shared/src/types/canvas/node.ts | 8 + .../shared/src/utils/file-reference.test.ts | 31 ++ packages/shared/src/utils/file-reference.ts | 34 ++ 66 files changed, 2599 insertions(+), 22 deletions(-) create mode 100644 apps/server/src/modules/agent/acp/device-files.route.ts create mode 100644 apps/server/src/modules/agent/acp/device-files.test.ts create mode 100644 apps/server/src/modules/agent/acp/device-files.ts create mode 100644 apps/web/src/api/device-files.ts create mode 100644 apps/web/src/components/Nodes/fileReference/FileReferenceNode.tsx create mode 100644 apps/web/src/components/Nodes/fileReference/FileReferencePreview.test.tsx create mode 100644 apps/web/src/components/Nodes/fileReference/FileReferencePreview.tsx create mode 100644 apps/web/src/components/Panels/CanvasSidebar.tsx create mode 100644 apps/web/src/components/Panels/FilesPanel.test.tsx create mode 100644 apps/web/src/components/Panels/FilesPanel.tsx create mode 100644 docs/architecture/device-files.md create mode 100644 packages/shared/src/types/api/device-files.ts create mode 100644 packages/shared/src/utils/file-reference.test.ts create mode 100644 packages/shared/src/utils/file-reference.ts diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 28f48b4e4..961c5b641 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -15,6 +15,7 @@ import { fastify, type FastifyBaseLogger } from 'fastify'; import { getConnectionToken } from './connection-token.js'; import { getDataDir } from './data-dir.js'; import { setHostServerPort } from './host-port.js'; +import deviceFilesRoutes from './modules/agent/acp/device-files.route.js'; import { createHarnessDiscoveryRoutes } from './modules/agent/acp/harness-discovery.route.js'; import { registerHarnessProfileDiscovery } from './modules/agent/acp/harness-profile-discovery.js'; import { @@ -367,6 +368,7 @@ app.addHook('onListen', async () => { // (M3). See modules/agent/acp/profile-cache-port.ts. installAcpProfileCachePort(); app.register(acpProfilesRoutes, { prefix: '/api/acp' }); +app.register(deviceFilesRoutes, { prefix: '/api/acp' }); app.register(agentDefaultsRoutes, { prefix: '/api/agent/defaults' }); app.register(acpAgentletRoutes, { prefix: '/api/acp' }); app.register(acpAgentCliRoutes, { prefix: '/api/acp' }); diff --git a/apps/server/src/modules/agent/acp/agent-cli.route.ts b/apps/server/src/modules/agent/acp/agent-cli.route.ts index 023d20bbd..5b41bbbf5 100644 --- a/apps/server/src/modules/agent/acp/agent-cli.route.ts +++ b/apps/server/src/modules/agent/acp/agent-cli.route.ts @@ -103,7 +103,11 @@ export function createAcpAgentCliRoutes( message: 'Agent Profile is unavailable', }); } - return { agents: await detect(parsed.data) }; + return { + agents: (await detect(parsed.data)).filter( + (entry) => entry.id !== 'proxy', + ), + }; } catch (error) { request.log.warn( { err: error }, diff --git a/apps/server/src/modules/agent/acp/device-files.route.ts b/apps/server/src/modules/agent/acp/device-files.route.ts new file mode 100644 index 000000000..061e1fb68 --- /dev/null +++ b/apps/server/src/modules/agent/acp/device-files.route.ts @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { deviceFilesBodySchema } from '@huabu/shared'; + +import { DeviceFilesError, DeviceFilesService } from './device-files.js'; +import { isOwnerRequest } from '../../security/owner.js'; + +import type { FastifyPluginAsync } from 'fastify'; + +const deviceFilesRoutes: FastifyPluginAsync = async (app) => { + let service: DeviceFilesService | undefined; + app.addHook('preClose', async () => service?.dispose()); + app.post('/files', async (request, reply) => { + if (!isOwnerRequest(request)) + return reply + .status(403) + .send({ message: 'Owner authorization required' }); + const parsed = deviceFilesBodySchema.safeParse(request.body); + if (!parsed.success) + return reply.status(400).send({ + code: 'invalid_request', + message: 'Invalid filesystem request', + }); + reply.header('Cache-Control', 'no-store'); + try { + service ??= new DeviceFilesService(app.log); + return await service.execute(parsed.data); + } catch (error) { + request.log.warn( + { + code: + error instanceof DeviceFilesError + ? error.code + : 'files_unavailable', + }, + 'Device file request failed', + ); + return reply + .status(error instanceof DeviceFilesError ? error.status : 503) + .send({ + code: + error instanceof DeviceFilesError + ? error.code + : 'files_unavailable', + message: + error instanceof DeviceFilesError + ? error.message + : 'Filesystem service unavailable', + }); + } + }); +}; +export default deviceFilesRoutes; diff --git a/apps/server/src/modules/agent/acp/device-files.test.ts b/apps/server/src/modules/agent/acp/device-files.test.ts new file mode 100644 index 000000000..82ac98779 --- /dev/null +++ b/apps/server/src/modules/agent/acp/device-files.test.ts @@ -0,0 +1,305 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import Fastify from 'fastify'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DeviceFilesService } from './device-files.js'; +import deviceFilesRoutes from './device-files.route.js'; + +import type { AcpAgentClient } from '@agenetes/acp-driver'; + +const mocks = vi.hoisted(() => ({ + ensure: vi.fn(), + release: vi.fn(), + remove: vi.fn(), + shutdown: vi.fn(), + prompt: vi.fn(), + permission: vi.fn(), + listener: undefined as + | undefined + | ((event: { + agentletId: string; + status: 'connected' | 'disconnected'; + }) => void), + connected: true, + profiles: [] as Array>, +})); +vi.mock('@agenetes/acp-driver', () => ({ + ensureAcpSession: mocks.ensure, + releaseThread: mocks.release, + acpSessionRegistry: { remove: mocks.remove }, +})); +vi.mock('@agenetes/agentlet-host', () => ({ + getAgentProfileRegistry: () => ({ listProfiles: () => mocks.profiles }), + getAgentletGateway: () => ({ + getAgentlet: (id: string) => + id === 'device' && mocks.connected ? { status: 'connected' } : undefined, + onAgentletsChanged: (listener: typeof mocks.listener) => { + mocks.listener = listener; + return vi.fn(); + }, + }), +})); + +const listing = { + version: 1, + operation: 'fs-list', + ok: true, + result: { path: '/work', parentPath: '/', entries: [] }, +}; +const request = { + agentletId: 'device', + operation: 'fs-list' as const, + args: { path: '/work' }, +}; +let service: DeviceFilesService; +beforeEach(() => { + vi.clearAllMocks(); + mocks.connected = true; + mocks.profiles = [ + { + id: 'proxy-profile', + alias: 'Filesystem Proxy', + agentletId: 'device', + visible: false, + launch: { kind: 'acp-harness', harnessId: 'proxy' }, + workingDirPath: '/neutral', + customData: { + discoveredAgent: { + version: 1, + agentletId: 'device', + harnessId: 'proxy', + }, + }, + }, + ]; + mocks.release.mockResolvedValue(undefined); + mocks.prompt.mockImplementation( + async (...args: Parameters) => { + args[2]({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: JSON.stringify(listing) }, + }); + return { stopReason: 'end_turn' }; + }, + ); + mocks.ensure.mockResolvedValue({ + sessionId: 'session', + availableCommands: [{ name: 'fs-list' }, { name: 'fs-read' }], + client: { + isClosed: false, + prompt: mocks.prompt, + shutdown: mocks.shutdown, + resolvePermission: mocks.permission, + }, + }); + service = new DeviceFilesService({ warn: vi.fn(), error: vi.fn() }); +}); +afterEach(async () => { + await service.dispose(); + vi.useRealTimers(); +}); + +describe('private filesystem ACP service', () => { + it('validates owner-only HTTP input and marks successful file responses no-store', async () => { + const app = Fastify(); + await app.register(deviceFilesRoutes, { prefix: '/api/acp' }); + try { + expect( + ( + await app.inject({ + method: 'POST', + url: '/api/acp/files', + remoteAddress: '203.0.113.5', + payload: request, + }) + ).statusCode, + ).toBe(403); + expect( + ( + await app.inject({ + method: 'POST', + url: '/api/acp/files', + payload: { ...request, args: { path: 'relative' } }, + }) + ).statusCode, + ).toBe(400); + expect(mocks.ensure).not.toHaveBeenCalled(); + const response = await app.inject({ + method: 'POST', + url: '/api/acp/files', + payload: request, + }); + expect(response.statusCode).toBe(200); + expect(response.headers['cache-control']).toBe('no-store'); + expect(response.json()).toEqual({ + operation: 'fs-list', + result: listing.result, + }); + } finally { + await app.close(); + } + }); + it('reuses one no-history session with fixed cwd and sends only the command', async () => { + expect(await service.execute(request)).toEqual({ + operation: 'fs-list', + result: listing.result, + }); + await service.execute(request); + expect(mocks.ensure).toHaveBeenCalledOnce(); + expect(mocks.ensure).toHaveBeenCalledWith( + expect.objectContaining({ + workloadType: 'Job', + namespace: { name: '' }, + env: {}, + cwd: '/neutral', + recipe: expect.objectContaining({ + autoRestart: false, + launch: { kind: 'acp-harness', harnessId: 'proxy' }, + }), + }), + ); + expect(mocks.prompt.mock.calls[0]?.[1]).toEqual([ + { type: 'text', text: '/fs-list {"path":"/work"}' }, + ]); + }); + + it('requires exact connected device and an automatic proxy identity', async () => { + await expect( + service.execute({ ...request, agentletId: 'hostname' }), + ).rejects.toMatchObject({ code: 'files_disconnected' }); + mocks.profiles[0]!.agentletId = 'other-device'; + await expect(service.execute(request)).rejects.toMatchObject({ + code: 'files_proxy_missing', + }); + expect(mocks.ensure).not.toHaveBeenCalled(); + }); + + it('serializes overlapping calls and denies unexpected permissions', async () => { + let active = 0; + mocks.prompt.mockImplementation( + async (...args: Parameters) => { + expect(++active).toBe(1); + args[4]?.({ requestId: 'unexpected', options: [], toolCall: {} }); + await new Promise((resolve) => setImmediate(resolve)); + args[2]({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: JSON.stringify(listing) }, + }); + active--; + return { stopReason: 'end_turn' }; + }, + ); + await Promise.all([service.execute(request), service.execute(request)]); + expect(mocks.permission).toHaveBeenCalledWith('unexpected', { + cancelled: true, + }); + }); + + it('rejects late replies after connection generation changes', async () => { + let finish!: () => void; + mocks.prompt.mockImplementation( + () => + new Promise((resolve) => { + finish = () => resolve({ stopReason: 'end_turn' }); + }), + ); + const pending = service.execute(request); + await vi.waitFor(() => expect(mocks.prompt).toHaveBeenCalledOnce()); + mocks.listener?.({ agentletId: 'device', status: 'disconnected' }); + finish(); + await expect(pending).rejects.toMatchObject({ code: 'files_disconnected' }); + expect(mocks.shutdown).toHaveBeenCalled(); + expect(mocks.release).toHaveBeenCalled(); + }); + + it.each([ + 'prose', + JSON.stringify({ ...listing, version: 2 }), + JSON.stringify({ ...listing, operation: 'fs-read' }), + ])('rejects malformed or mismatched output %s', async (text) => { + mocks.prompt.mockImplementation( + async (...args: Parameters) => { + args[2]({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text }, + }); + return { stopReason: 'end_turn' }; + }, + ); + await expect(service.execute(request)).rejects.toMatchObject({ + status: 502, + }); + }); + it('does not interpret end_turn as filesystem success', async () => { + mocks.prompt.mockImplementation( + async (...args: Parameters) => { + args[2]({ + sessionUpdate: 'agent_message_chunk', + content: { + type: 'text', + text: JSON.stringify({ + version: 1, + operation: 'fs-list', + ok: false, + error: { + code: 'permission_denied', + message: 'Permission denied', + }, + }), + }, + }); + return { stopReason: 'end_turn' }; + }, + ); + await expect(service.execute(request)).rejects.toMatchObject({ + code: 'permission_denied', + status: 403, + }); + }); + + it('retains admission ownership for timed-out startup and reclaims a late process', async () => { + vi.useFakeTimers(); + const entry = await mocks.ensure(); + mocks.ensure.mockClear(); + let complete!: (value: typeof entry) => void; + mocks.ensure.mockImplementationOnce( + () => + new Promise((resolve) => { + complete = resolve; + }), + ); + const pending = service.execute(request); + const rejected = expect(pending).rejects.toMatchObject({ + code: 'files_timeout', + }); + await vi.advanceTimersByTimeAsync(60_000); + await rejected; + await expect(service.execute(request)).rejects.toMatchObject({ + code: 'files_busy', + }); + expect(mocks.ensure).toHaveBeenCalledOnce(); + complete(entry); + await vi.advanceTimersByTimeAsync(0); + expect(mocks.shutdown).toHaveBeenCalled(); + expect(mocks.release).toHaveBeenCalled(); + await expect(service.execute(request)).resolves.toMatchObject({ + operation: 'fs-list', + }); + }); + + it('surfaces cleanup failure and retries reclamation before admitting another process', async () => { + await service.execute(request); + mocks.release.mockRejectedValueOnce(new Error('Device stop failed')); + mocks.prompt.mockRejectedValueOnce(new Error('Transport lost')); + await expect(service.execute(request)).rejects.toMatchObject({ + code: 'files_cleanup_failed', + }); + await expect(service.execute(request)).resolves.toMatchObject({ + operation: 'fs-list', + }); + expect(mocks.release).toHaveBeenCalledTimes(2); + expect(mocks.ensure).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/server/src/modules/agent/acp/device-files.ts b/apps/server/src/modules/agent/acp/device-files.ts new file mode 100644 index 000000000..6bcb6e1e7 --- /dev/null +++ b/apps/server/src/modules/agent/acp/device-files.ts @@ -0,0 +1,376 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { randomUUID } from 'node:crypto'; + +import { + ensureAcpSession, + acpSessionRegistry, + releaseThread, +} from '@agenetes/acp-driver'; +import { + getAgentProfileRegistry, + getAgentletGateway, +} from '@agenetes/agentlet-host'; +import { FS_MAX_RESPONSE_BYTES } from '@agentlet/protocol'; + +import { proxyFsResponseSchema } from '@huabu/shared'; + +import type { AcpSessionEntry } from '@agenetes/acp-driver'; +import type { DeviceFilesBody, DeviceFilesResponse } from '@huabu/shared'; +import type { FastifyBaseLogger } from 'fastify'; + +export class DeviceFilesError extends Error { + constructor( + readonly code: string, + readonly status: number, + message: string, + ) { + super(message); + } +} + +interface DeviceSession { + agentletId: string; + threadId: string; + entry?: AcpSessionEntry; + pending: number; + tail: Promise; + retired: boolean; + starting: boolean; + lastUsed: number; + cleanup?: Promise; +} + +export class DeviceFilesService { + private readonly sessions = new Map(); + private disposed = false; + private readonly unsubscribe: () => void; + private readonly idleTimer: ReturnType; + private readonly logger: Parameters[0]['logger']; + + constructor(private readonly log: Pick) { + const gateway = getAgentletGateway(); + if (!gateway) + throw new DeviceFilesError( + 'files_unavailable', + 503, + 'Device service is not ready', + ); + this.unsubscribe = gateway.onAgentletsChanged(({ agentletId }) => { + const session = this.sessions.get(agentletId); + if (session) + void this.retire(session).catch(() => + this.log.warn('Filesystem proxy cleanup failed'), + ); + }); + this.idleTimer = setInterval(() => { + for (const session of this.sessions.values()) { + if (!session.pending && Date.now() - session.lastUsed > 60_000) + void this.retire(session).catch(() => + this.log.warn('Filesystem proxy cleanup failed'), + ); + } + }, 15_000); + this.idleTimer.unref(); + // Lower-level logs can include raw remote output; retain only fixed diagnostics. + this.logger = { + debug: () => undefined, + info: () => undefined, + warn: () => log.warn('Filesystem proxy runtime warning'), + error: () => log.error('Filesystem proxy runtime error'), + }; + } + + private assertCurrent(session: DeviceSession) { + if ( + this.disposed || + session.retired || + this.sessions.get(session.agentletId) !== session || + getAgentletGateway()?.getAgentlet(session.agentletId)?.status !== + 'connected' + ) { + throw new DeviceFilesError( + 'files_disconnected', + 409, + 'Device connection changed; try again', + ); + } + } + + private async retire(session: DeviceSession) { + session.retired = true; + // Keep admission ownership until late startup settles and can be reclaimed. + if (session.starting) return; + session.entry?.client.shutdown('Filesystem session retired'); + acpSessionRegistry.remove(session.agentletId, session.threadId); + session.cleanup ??= releaseThread(session.agentletId, session.threadId) + .then(() => { + if (this.sessions.get(session.agentletId) === session) + this.sessions.delete(session.agentletId); + }) + .catch(() => { + session.cleanup = undefined; + this.log.warn('Filesystem proxy cleanup failed'); + throw new DeviceFilesError( + 'files_cleanup_failed', + 503, + 'Could not stop the previous filesystem proxy; retry', + ); + }); + await session.cleanup; + } + + async dispose() { + this.disposed = true; + this.unsubscribe(); + clearInterval(this.idleTimer); + await Promise.all( + [...this.sessions.values()].map((session) => this.retire(session)), + ); + } + + async execute(body: DeviceFilesBody): Promise { + if ( + this.disposed || + getAgentletGateway()?.getAgentlet(body.agentletId)?.status !== 'connected' + ) { + throw new DeviceFilesError( + 'files_disconnected', + 409, + 'Device is not connected', + ); + } + let session = this.sessions.get(body.agentletId); + if (session?.retired || session?.entry?.client.isClosed) { + await this.retire(session); + if (this.sessions.get(body.agentletId) === session) + throw new DeviceFilesError( + 'files_busy', + 503, + 'Previous filesystem proxy is still stopping', + ); + session = this.sessions.get(body.agentletId); + } + if (!session) { + if (this.sessions.size >= 4) + throw new DeviceFilesError( + 'files_busy', + 429, + 'Too many active file devices', + ); + session = { + agentletId: body.agentletId, + threadId: `files-${randomUUID()}`, + pending: 0, + tail: Promise.resolve(), + retired: false, + starting: false, + lastUsed: Date.now(), + }; + this.sessions.set(body.agentletId, session); + } + if (session.pending >= 8) + throw new DeviceFilesError( + 'files_busy', + 429, + 'Too many pending filesystem requests', + ); + const current = session; + current.pending++; + const task = current.tail.then(() => this.run(current, body)); + current.tail = task.then( + () => undefined, + () => undefined, + ); + try { + return await task; + } finally { + current.pending--; + current.lastUsed = Date.now(); + } + } + + private async run( + session: DeviceSession, + body: DeviceFilesBody, + ): Promise { + this.assertCurrent(session); + let timer: ReturnType | undefined; + let timedOut = false; + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => { + timedOut = true; + void this.retire(session).catch(() => + this.log.warn('Filesystem proxy cleanup failed after timeout'), + ); + reject( + new DeviceFilesError( + 'files_timeout', + 504, + 'Filesystem request timed out', + ), + ); + }, + session.entry ? 10_000 : 60_000, + ); + }); + const operation = async (): Promise => { + if (!session.entry || session.entry.client.isClosed) { + const profile = getAgentProfileRegistry() + ?.listProfiles() + .find( + (candidate) => + candidate.agentletId === body.agentletId && + candidate.visible === false && + candidate.launch.kind === 'acp-harness' && + candidate.launch.harnessId === 'proxy' && + candidate.customData?.discoveredAgent && + typeof candidate.customData.discoveredAgent === 'object' && + !Array.isArray(candidate.customData.discoveredAgent) && + candidate.customData.discoveredAgent.version === 1 && + candidate.customData.discoveredAgent.agentletId === + body.agentletId && + candidate.customData.discoveredAgent.harnessId === 'proxy', + ); + if (!profile || profile.launch.kind !== 'acp-harness') { + throw new DeviceFilesError( + 'files_proxy_missing', + 503, + 'Update Agentlet and detect installed Agents to enable Files', + ); + } + session.starting = true; + try { + session.entry = await ensureAcpSession({ + agentletId: body.agentletId, + threadId: session.threadId, + workloadType: 'Job', + binding: { alias: profile.alias, profileId: profile.id }, + recipe: { + launch: profile.launch, + cwd: profile.workingDirPath, + autoRestart: false, + alias: profile.alias, + }, + cwd: profile.workingDirPath, + namespace: { name: '' }, + env: {}, + idleTimeoutSecs: 90, + logger: this.logger, + }); + } finally { + session.starting = false; + if (session.retired) await this.retire(session); + } + if (timedOut || session.retired) { + this.assertCurrent(session); + } + if ( + !['fs-list', 'fs-read'].every((name) => + session.entry?.availableCommands.some( + (command) => command.name === name, + ), + ) + ) { + throw new DeviceFilesError( + 'files_proxy_incompatible', + 503, + 'Filesystem proxy does not advertise the required commands', + ); + } + } + this.assertCurrent(session); + const entry = session.entry; + let text = ''; + let bytes = 0; + let invalid = false; + const result = await entry.client.prompt( + entry.sessionId, + [ + { + type: 'text', + text: `/${body.operation} ${JSON.stringify(body.args)}`, + }, + ], + (update) => { + if ( + update.sessionUpdate === 'agent_message_chunk' && + update.content.type === 'text' + ) { + bytes += Buffer.byteLength(update.content.text); + if (bytes > FS_MAX_RESPONSE_BYTES) { + invalid = true; + entry.client.shutdown('Filesystem response exceeds limit'); + } else text += update.content.text; + } else if (update.sessionUpdate !== 'available_commands_update') + invalid = true; + }, + undefined, + (request) => { + entry.client.resolvePermission(request.requestId, { + cancelled: true, + }); + }, + ); + this.assertCurrent(session); + if (invalid || result.stopReason !== 'end_turn') + throw new DeviceFilesError( + 'files_invalid_response', + 502, + 'Invalid filesystem proxy response', + ); + let decoded: unknown; + try { + decoded = JSON.parse(text); + } catch { + throw new DeviceFilesError( + 'files_invalid_response', + 502, + 'Invalid filesystem proxy JSON', + ); + } + const parsed = proxyFsResponseSchema.safeParse(decoded); + if (!parsed.success || parsed.data.operation !== body.operation) + throw new DeviceFilesError( + 'files_invalid_response', + 502, + 'Incompatible filesystem proxy response', + ); + const response = parsed.data; + if (!response.ok) { + const status = + response.error.code === 'permission_denied' + ? 403 + : response.error.code === 'not_found' + ? 404 + : response.error.code === 'io_error' + ? 502 + : 400; + throw new DeviceFilesError( + response.error.code, + status, + response.error.message, + ); + } + if (response.operation === 'fs-list') + return { operation: response.operation, result: response.result }; + return { operation: response.operation, result: response.result }; + }; + try { + return await Promise.race([operation(), timeout]); + } catch (error) { + await this.retire(session); + if (error instanceof DeviceFilesError) throw error; + this.log.warn('Filesystem proxy request failed'); + throw new DeviceFilesError( + 'files_unavailable', + 503, + 'Filesystem proxy is unavailable', + ); + } finally { + clearTimeout(timer); + } + } +} diff --git a/apps/server/src/modules/agent/acp/harness-profile-discovery.test.ts b/apps/server/src/modules/agent/acp/harness-profile-discovery.test.ts index 6dbb84490..71371bc76 100644 --- a/apps/server/src/modules/agent/acp/harness-profile-discovery.test.ts +++ b/apps/server/src/modules/agent/acp/harness-profile-discovery.test.ts @@ -50,6 +50,7 @@ function setup(initialMachines = ['machine-a']) { const profile: AgentProfile = { id: `profile-${++nextId}`, alias: input.alias, + visible: input.visible, agentletId: input.agentletId, workingDirPath: input.workingDirPath, launch: @@ -113,6 +114,30 @@ function setup(initialMachines = ['machine-a']) { } describe('automatic ordinary Profile provisioning', () => { + it('provisions an idempotent exact-device hidden proxy outside the manual picker result', async () => { + const context = setup([]); + context.gateway.discoverHarnesses.mockResolvedValue({ + harnesses: [ + { + ...observation.harnesses[0]!, + id: 'proxy', + displayName: 'Filesystem Proxy', + }, + ], + }); + const dispose = context.start(); + context.emit({ agentletId: 'machine-a', status: 'connected' }); + await flush(); + await context.discover(); + expect(context.profiles).toHaveLength(1); + expect(context.profiles[0]).toMatchObject({ + agentletId: 'machine-a', + visible: false, + launch: { kind: 'acp-harness', harnessId: 'proxy' }, + }); + expect((await context.discover()).harnesses).toHaveLength(0); + dispose(); + }); it('shares automatic/manual overlap, then performs fresh idempotent discovery', async () => { const context = setup(); let finish!: (result: HarnessDiscoveryResult) => void; diff --git a/apps/server/src/modules/agent/acp/harness-profile-discovery.ts b/apps/server/src/modules/agent/acp/harness-profile-discovery.ts index 6fc47455a..8b93d056b 100644 --- a/apps/server/src/modules/agent/acp/harness-profile-discovery.ts +++ b/apps/server/src/modules/agent/acp/harness-profile-discovery.ts @@ -254,6 +254,7 @@ export function registerHarnessProfileDiscovery({ alias: profiles.some((profile) => profile.alias === defaultAlias) ? `${defaultAlias} [${harness.id}]` : defaultAlias, + ...(harness.id === 'proxy' ? { visible: false } : {}), workingDirPath: harness.workingDirPath, metadata: { cliId: harness.id }, customData: { @@ -287,7 +288,10 @@ export function registerHarnessProfileDiscovery({ { cause: error }, ); } - return response; + return { + ...response, + harnesses: response.harnesses.filter((entry) => entry.id !== 'proxy'), + }; }; const discover = (agentletId: string) => { diff --git a/apps/server/src/modules/agent/acp/profiles.route.test.ts b/apps/server/src/modules/agent/acp/profiles.route.test.ts index 8eb68f821..992632376 100644 --- a/apps/server/src/modules/agent/acp/profiles.route.test.ts +++ b/apps/server/src/modules/agent/acp/profiles.route.test.ts @@ -99,6 +99,38 @@ afterEach(async () => { }); describe('ordinary Profile catalog routes', () => { + it('hides the proxy from normal catalogues without hiding its connected device', async () => { + mocks.registry.listProfiles.mockReturnValue([ + commandProfile, + { ...commandProfile, id: 'hidden', visible: false }, + ]); + const server = await setup(); + const response = await server.inject({ + method: 'GET', + url: '/api/acp/profiles', + }); + expect(response.statusCode).toBe(200); + expect(response.json().profiles).toEqual([commandProfile]); + expect(response.json().selectableProfileIds).not.toContain('hidden'); + expect(response.json().connectedDevices).toContainEqual( + expect.objectContaining({ agentletId: 'machine-a', profileCount: 1 }), + ); + }); + it('reserves the bundled proxy harness for automatic hidden provisioning', async () => { + const server = await setup(); + const response = await server.inject({ + method: 'POST', + url: '/api/acp/profiles', + payload: { + alias: 'Proxy', + agentletId: 'machine-a', + workingDirPath: '/work', + launch: { kind: 'acp-harness', harnessId: 'proxy' }, + }, + }); + expect(response.statusCode).toBe(400); + expect(mocks.registry.createProfile).not.toHaveBeenCalled(); + }); it.each([ ['machine-a', 'machine-a'], ['remote-machine-host', null], diff --git a/apps/server/src/modules/agent/acp/profiles.route.ts b/apps/server/src/modules/agent/acp/profiles.route.ts index 283e197e2..b8acb64fa 100644 --- a/apps/server/src/modules/agent/acp/profiles.route.ts +++ b/apps/server/src/modules/agent/acp/profiles.route.ts @@ -209,6 +209,7 @@ const acpProfilesRoutes: FastifyPluginAsync = async (app) => { } const profileCounts = new Map(); for (const profile of profiles) { + if (profile.visible === false) continue; const agentletId = resolveConnectedAgentletId(profile.agentletId); if (!agentletId) continue; profileCounts.set(agentletId, (profileCounts.get(agentletId) ?? 0) + 1); @@ -239,9 +240,13 @@ const acpProfilesRoutes: FastifyPluginAsync = async (app) => { ) || left.agentletId.localeCompare(right.agentletId), ); return { - profiles, + profiles: profiles.filter((profile) => profile.visible !== false), selectableProfileIds: profiles - .filter((profile) => resolveConnectedAgentletId(profile.agentletId)) + .filter( + (profile) => + profile.visible !== false && + resolveConnectedAgentletId(profile.agentletId), + ) .map((profile) => profile.id), connectedDevices, folderPickerAgentletId, @@ -284,6 +289,12 @@ const acpProfilesRoutes: FastifyPluginAsync = async (app) => { }); } const launch = parsed.data.launch; + if (launch.kind === 'acp-harness' && launch.harnessId === 'proxy') { + return reply.status(400).send({ + code: 'reserved_profile', + message: 'The filesystem proxy is automatically managed', + }); + } if (!(await validateHarnessLaunch(launch, agentletId, request, reply))) return; const common = { diff --git a/apps/server/src/modules/agent/acp/threads.route.test.ts b/apps/server/src/modules/agent/acp/threads.route.test.ts index e29542dc2..79042ece9 100644 --- a/apps/server/src/modules/agent/acp/threads.route.test.ts +++ b/apps/server/src/modules/agent/acp/threads.route.test.ts @@ -158,6 +158,7 @@ describe('ACP cached capability route', () => { alias: 'Removed Profile', }, workingDirPath: '/frozen/project', + agentletId: 'machine', }, }); expect(mocks.create).not.toHaveBeenCalled(); diff --git a/apps/server/src/modules/agent/acp/threads.route.ts b/apps/server/src/modules/agent/acp/threads.route.ts index fe646f602..a9e32b4ff 100644 --- a/apps/server/src/modules/agent/acp/threads.route.ts +++ b/apps/server/src/modules/agent/acp/threads.route.ts @@ -239,6 +239,9 @@ const acpThreadsRoutes: FastifyPluginAsync = async (app) => { profileId: spec.binding.profileId, }, ...(spec.cwd !== undefined ? { workingDirPath: spec.cwd } : {}), + ...(resolveThreadAgentletId(record) + ? { agentletId: resolveThreadAgentletId(record) } + : {}), ...(spec.workingDirectoryOverride !== undefined ? { workingDirectoryOverride: spec.workingDirectoryOverride } : {}), diff --git a/apps/server/src/modules/agent/agent-defaults.test.ts b/apps/server/src/modules/agent/agent-defaults.test.ts index 0610c7cda..9d9109742 100644 --- a/apps/server/src/modules/agent/agent-defaults.test.ts +++ b/apps/server/src/modules/agent/agent-defaults.test.ts @@ -56,6 +56,20 @@ describe('installation Agent defaults', () => { } }); + it('excludes hidden Profiles from absent-only Utility initialization', () => { + const hidden = { ...profile('proxy'), visible: false }; + const empty = setup(); + expect( + empty.service.initializeAgentDefaults([hidden]).profileId, + ).toBeNull(); + expect(empty.storage.write).not.toHaveBeenCalled(); + const ready = setup(); + expect( + ready.service.initializeAgentDefaults([hidden, profile('ordinary')]) + .profileId, + ).toBe('ordinary'); + }); + it('offers a uniquely resolved hostname-era candidate to initialization', () => { const initialize = vi .spyOn(AgentDefaultsService.prototype, 'initializeAgentDefaults') diff --git a/apps/server/src/modules/agent/agent-defaults.ts b/apps/server/src/modules/agent/agent-defaults.ts index 0accc5686..63f8ceb6a 100644 --- a/apps/server/src/modules/agent/agent-defaults.ts +++ b/apps/server/src/modules/agent/agent-defaults.ts @@ -103,7 +103,9 @@ export class AgentDefaultsService { const stored = this.storage.read(); const current = this.parseDefaults(stored); if (stored !== undefined) return current; - const candidates = profiles.filter((profile) => profile.id !== 'huabu'); + const candidates = profiles.filter( + (profile) => profile.id !== 'huabu' && profile.visible !== false, + ); const key = (profile: AgentProfileView): string[] => [ profile.agentletId, 'harnessId' in profile.launch diff --git a/apps/server/src/modules/agent/selectable-agent-profile.test.ts b/apps/server/src/modules/agent/selectable-agent-profile.test.ts index 0ce944a7c..9f04d3d8a 100644 --- a/apps/server/src/modules/agent/selectable-agent-profile.test.ts +++ b/apps/server/src/modules/agent/selectable-agent-profile.test.ts @@ -4,7 +4,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; const host = vi.hoisted(() => ({ - profiles: [] as Array<{ id: string; alias: string; agentletId: string }>, + profiles: [] as Array<{ + id: string; + alias: string; + agentletId: string; + visible?: boolean; + }>, connectedIds: [] as string[], })); @@ -34,6 +39,18 @@ afterEach(() => { }); describe('listAvailableAgentProfiles', () => { + it('does not offer or invoke a hidden connected Profile as an ordinary Agent', () => { + host.profiles = [ + { id: 'proxy', alias: 'Proxy', agentletId: 'device', visible: false }, + ]; + host.connectedIds = ['device']; + expect(listAvailableAgentProfiles()).toEqual([ + { id: 'huabu', alias: 'Built-In Pi' }, + ]); + expect(() => requireAvailableAgentProfile('proxy')).toThrow( + expect.objectContaining({ code: 'profile_not_selectable' }), + ); + }); it('prepends Huabu and projects available Profile identities', () => { const profiles = new Map([ [ diff --git a/apps/server/src/modules/agent/selectable-agent-profile.ts b/apps/server/src/modules/agent/selectable-agent-profile.ts index a67e1d315..bb6ac17a4 100644 --- a/apps/server/src/modules/agent/selectable-agent-profile.ts +++ b/apps/server/src/modules/agent/selectable-agent-profile.ts @@ -29,7 +29,11 @@ function getConnectedProfileRegistry(): AgentProfileRegistryPort | null { listSelectableProfileIds: () => registry .listProfiles() - .filter((profile) => resolveConnectedAgentletId(profile.agentletId)) + .filter( + (profile) => + profile.visible !== false && + resolveConnectedAgentletId(profile.agentletId), + ) .map((profile) => profile.id), }; } diff --git a/apps/server/src/modules/canvas/canvas-content-cas.test.ts b/apps/server/src/modules/canvas/canvas-content-cas.test.ts index 029c74423..5b4b5447a 100644 --- a/apps/server/src/modules/canvas/canvas-content-cas.test.ts +++ b/apps/server/src/modules/canvas/canvas-content-cas.test.ts @@ -87,6 +87,94 @@ afterEach(() => { }); describe('PUT /nodes/:nodeId/content — content CAS', () => { + it('rejects source/body writes and type spoofing on metadata-only file references', async () => { + seedCanvas('c1', 'ref', 'Source'); + const store = getCanvasStore('c1'); + const canvas = store.read()!; + canvas.state.nodes = [ + { + id: 'ref', + type: 'fileReference', + position: { x: 0, y: 0 }, + data: { + label: 'Source', + fileReference: { + agentletId: 'offline-device', + path: '/private/source.txt', + }, + }, + }, + ]; + store.write(canvas); + const app = await buildApp(); + try { + for (const body of [ + { nodeType: 'fileReference', content: 'secret bytes' }, + { nodeType: 'fileReference', src: 'file:///private/source.txt' }, + { nodeType: 'note', content: 'type spoof' }, + ]) + expect((await putContent(app, 'c1', 'ref', body)).statusCode).toBe(400); + expect( + ( + await putContent(app, 'c1', 'ref', { + nodeType: 'fileReference', + label: 'Renamed', + }) + ).statusCode, + ).toBe(200); + expect(store.readNode('ref')).toMatchObject({ + content: '', + label: 'Renamed', + }); + const read = await app.inject({ method: 'GET', url: '/canvas/c1' }); + expect(read.statusCode).toBe(200); + const node = read.json().state.nodes[0]; + expect(node.data.fileReference).toEqual({ + agentletId: 'offline-device', + path: '/private/source.txt', + }); + expect(node.data).not.toHaveProperty('content'); + expect(node.data).not.toHaveProperty('src'); + const move = await app.inject({ + method: 'PUT', + url: '/canvas/c1', + payload: { + version: store.read()?.version, + state: { + nodes: [ + { id: 'ref', type: 'fileReference', position: { x: 5, y: 5 } }, + ], + edges: [], + }, + }, + }); + expect(move.statusCode).toBe(200); + expect(store.read()?.state.nodes).toMatchObject([ + { + data: { + fileReference: { + agentletId: 'offline-device', + path: '/private/source.txt', + }, + }, + }, + ]); + const invalid = await app.inject({ + method: 'PUT', + url: '/canvas/c1', + payload: { + version: store.read()?.version, + state: { + nodes: [{ id: 'ref', data: { content: 'type-omitted bypass' } }], + edges: [], + }, + }, + }); + expect(invalid.statusCode).toBe(400); + } finally { + await app.close(); + } + }); it.each(['user', 'agent'])( 'preserves a current %s Question label against a late preprocessing save', async (labelSource) => { diff --git a/apps/server/src/modules/canvas/canvas-executor.test.ts b/apps/server/src/modules/canvas/canvas-executor.test.ts index 2a4f53278..408ea0a10 100644 --- a/apps/server/src/modules/canvas/canvas-executor.test.ts +++ b/apps/server/src/modules/canvas/canvas-executor.test.ts @@ -514,6 +514,84 @@ describe('executeOnServer — MERGE_NODE_DATA CAS', () => { }); describe('executeOnServer — CREATE_NODES id echo', () => { + it('persists and duplicates inert file references with frontmatter-only labels', async () => { + const store = getCanvasStore('c1'); + store.write({ + canvasId: 'c1', + title: null, + version: 1, + state: { nodes: [], edges: [] }, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + const reference = { + agentletId: 'offline-device', + path: '/private/file.tex', + deviceName: 'Remote', + }; + const create = await executeOnServer({ + canvasId: 'c1', + originator: { source: 'ui' }, + commands: [ + { + type: 'CREATE_NODES', + nodes: [ + { + id: 'node-ref', + nodeType: 'fileReference', + position: { x: 0, y: 0 }, + data: { label: 'file.tex', fileReference: reference }, + }, + ], + }, + ], + }); + expect(create.results[0]?.applied).toBe(true); + expect(store.read()?.state.nodes).toMatchObject([ + { data: { fileReference: reference } }, + ]); + expect(store.readNode('node-ref')).toMatchObject({ + label: 'file.tex', + content: '', + }); + await executeOnServer({ + canvasId: 'c1', + originator: { source: 'ui' }, + commands: [ + { + type: 'CREATE_NODES', + nodes: [ + { + id: 'node-ref-copy', + nodeType: 'fileReference', + position: { x: 40, y: 40 }, + data: { label: 'file.tex', fileReference: reference }, + }, + ], + }, + ], + }); + const nodes = store.read()?.state.nodes ?? []; + expect(nodes).toHaveLength(2); + expect(nodes).toMatchObject([ + { data: { fileReference: reference } }, + { data: { fileReference: reference } }, + ]); + const rejected = await executeOnServer({ + canvasId: 'c1', + originator: { source: 'ui' }, + commands: [ + { + type: 'MERGE_NODE_DATA', + patches: [ + { nodeId: 'node-ref', patch: { content: 'must not be imported' } }, + ], + }, + ], + }); + expect(rejected.results[0]?.applied).toBe(false); + expect(store.readNode('node-ref')?.content).toBe(''); + }); it('echoes the server-assigned id and label of every created node', async () => { const store = getCanvasStore('c1'); store.write({ diff --git a/apps/server/src/modules/canvas/canvas-executor.ts b/apps/server/src/modules/canvas/canvas-executor.ts index 5f3621779..42dee2ee3 100644 --- a/apps/server/src/modules/canvas/canvas-executor.ts +++ b/apps/server/src/modules/canvas/canvas-executor.ts @@ -112,6 +112,7 @@ function insertedNodeIds(deltas: readonly Delta[]): Set { // surfaces the discrepancy. See `docs/node-content-api-split.md`. const MD_BACKED_NODE_TYPES = new Set([ + 'fileReference', 'note', 'text', 'web', diff --git a/apps/server/src/modules/canvas/canvas.route.ts b/apps/server/src/modules/canvas/canvas.route.ts index a8f6030df..3ab80b592 100644 --- a/apps/server/src/modules/canvas/canvas.route.ts +++ b/apps/server/src/modules/canvas/canvas.route.ts @@ -31,6 +31,7 @@ import { stripLegacyPortalTopology, recentCanvasConversationParamsSchema, deleteNodeParamsSchema, + fileReferenceSchema, } from '@huabu/shared'; import { nodeRevisionOf } from '@huabu/shared/canvas-engine'; import { @@ -222,6 +223,7 @@ function openInFileManager(targetPath: string): void { * frontmatter. */ const MD_BACKED_NODE_TYPES = new Set([ + 'fileReference', 'note', 'text', 'web', @@ -825,6 +827,20 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { provenance, expectRev, } = parsed.data; + const storedNode = (canvas.state.nodes as NodeLike[] | undefined)?.find( + (node) => node.id === nodeId, + ); + if ( + (storedNode?.type === 'fileReference' || nodeType === 'fileReference') && + (nodeType !== 'fileReference' || + (incomingContent !== undefined && incomingContent !== '') || + incomingSrc !== undefined) + ) { + return reply.code(400).send({ + message: 'File references store metadata only', + code: 'invalid_file_reference', + }); + } if (!MD_BACKED_NODE_TYPES.has(nodeType)) { return reply.code(400).send({ @@ -1390,6 +1406,19 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { data: { ...current?.data, ...node.data }, }; }); + if ( + identityNodes.some( + (node) => + node.type === 'fileReference' && + (!fileReferenceSchema.safeParse(node.data?.fileReference).success || + ['content', 'src'].some((key) => key in (node.data ?? {}))), + ) + ) { + return reply.code(400).send({ + message: 'Invalid file reference', + code: 'invalid_file_reference', + }); + } try { await assertChildSpaceEntriesAllowed( canvasId, diff --git a/apps/server/src/modules/preprocessing/profiles.ts b/apps/server/src/modules/preprocessing/profiles.ts index ab8a24cec..270de39d5 100644 --- a/apps/server/src/modules/preprocessing/profiles.ts +++ b/apps/server/src/modules/preprocessing/profiles.ts @@ -12,6 +12,12 @@ import type { NodePreprocessProfile } from './types.js'; import type { CanvasNodeType } from '@huabu/shared'; export const profiles: Record = { + fileReference: { + nodeType: 'fileReference', + bodyOwnership: 'derived', + capabilities: ['build_patch'], + watchFields: ['label', 'labelSource'], + }, note: { nodeType: 'note', contentKind: 'note', diff --git a/apps/server/tsup.config.ts b/apps/server/tsup.config.ts index 256b44bfc..f7e3ea027 100644 --- a/apps/server/tsup.config.ts +++ b/apps/server/tsup.config.ts @@ -124,6 +124,9 @@ export default defineConfig([ 'device-info': path.resolve( '../../external/agentlet/packages/local/src/device-info.ts', ), + 'fs-proxy': path.resolve( + '../../external/agentlet/packages/local/src/fs-proxy.ts', + ), }, format: ['esm'], outDir: 'dist-bundle/agentlet', diff --git a/apps/web/src/api/_routes.ts b/apps/web/src/api/_routes.ts index 8f4f6478c..8cb6afbf4 100644 --- a/apps/web/src/api/_routes.ts +++ b/apps/web/src/api/_routes.ts @@ -14,6 +14,7 @@ const enc = encodeURIComponent; export const routes = { + deviceFiles: '/acp/files', // ── Deployment ──────────────────────────────────────────────────── deploymentReadiness: '/deployment/readiness', canaryRedeployStatus: '/deployment/canary', diff --git a/apps/web/src/api/device-files.ts b/apps/web/src/api/device-files.ts new file mode 100644 index 000000000..f387fcf6e --- /dev/null +++ b/apps/web/src/api/device-files.ts @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { apiFetch } from './_client'; +import { routes } from './_routes'; + +import type { + DeviceFilesBody, + DeviceFilesResponse, + FileListResult, + FileReadResult, +} from '@huabu/shared'; + +async function requestFiles( + body: DeviceFilesBody, + signal?: AbortSignal, +): Promise { + return apiFetch(routes.deviceFiles, { + method: 'POST', + json: body, + signal, + fallbackMessage: 'Filesystem request failed', + }); +} +export async function listDeviceFiles( + agentletId: string, + args: Extract['args'], + signal?: AbortSignal, +): Promise { + const response = await requestFiles( + { agentletId, operation: 'fs-list', args }, + signal, + ); + if (response.operation !== 'fs-list') + throw new Error('Invalid filesystem response'); + return response.result; +} +export async function readDeviceFile( + agentletId: string, + args: Extract['args'], + signal?: AbortSignal, +): Promise { + const response = await requestFiles( + { agentletId, operation: 'fs-read', args }, + signal, + ); + if (response.operation !== 'fs-read') + throw new Error('Invalid filesystem response'); + return response.result; +} diff --git a/apps/web/src/components/CodeMirror/RawMarkdownEditor.test.tsx b/apps/web/src/components/CodeMirror/RawMarkdownEditor.test.tsx index 779ecf979..4684575bf 100644 --- a/apps/web/src/components/CodeMirror/RawMarkdownEditor.test.tsx +++ b/apps/web/src/components/CodeMirror/RawMarkdownEditor.test.tsx @@ -38,6 +38,31 @@ afterEach(async () => { vi.unstubAllGlobals(); }); +it('does not indent a readonly plain-text source with Tab', async () => { + const onChange = vi.fn(); + await act(async () => + root.render( + , + ), + ); + const content = host.querySelector('.cm-content'); + if (!content) throw new Error('Missing source viewer'); + const view = EditorView.findFromDOM(content); + if (!view) throw new Error('Missing CodeMirror view'); + await act(async () => + content.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', code: 'Tab', bubbles: true }), + ), + ); + expect(view.state.doc.toString()).toBe('source text'); + expect(onChange).not.toHaveBeenCalled(); +}); + it.each([false, true])( 'reveals exact raw-source URL text without editing or focusing (%s)', async (readOnly) => { diff --git a/apps/web/src/components/CodeMirror/RawMarkdownEditor.tsx b/apps/web/src/components/CodeMirror/RawMarkdownEditor.tsx index 2f99f9c98..cc790aea7 100644 --- a/apps/web/src/components/CodeMirror/RawMarkdownEditor.tsx +++ b/apps/web/src/components/CodeMirror/RawMarkdownEditor.tsx @@ -65,6 +65,7 @@ const tabIndentKeymap = Prec.highest( { key: 'Tab', run: (view) => { + if (view.state.readOnly) return false; view.dispatch(view.state.replaceSelection(TAB_INDENT_STRING), { scrollIntoView: true, userEvent: 'input.type', @@ -83,6 +84,7 @@ export interface RawMarkdownEditorProps { /** Accessible label forwarded to CM's content DOM. */ ariaLabel?: string; searchNodeId?: string; + plainText?: boolean; } export default function RawMarkdownEditor({ @@ -92,6 +94,7 @@ export default function RawMarkdownEditor({ className, ariaLabel, searchNodeId, + plainText = false, }: RawMarkdownEditorProps): React.JSX.Element { const hostRef = useRef(null); const viewRef = useRef(null); @@ -183,7 +186,9 @@ export default function RawMarkdownEditor({ doc: value, extensions: [ basicSetup, - markdown({ base: markdownLanguage, codeLanguages: [] }), + ...(plainText + ? [] + : [markdown({ base: markdownLanguage, codeLanguages: [] })]), EditorView.lineWrapping, huabuLightTheme, tabIndentKeymap, diff --git a/apps/web/src/components/Nodes/fileReference/FileReferenceNode.tsx b/apps/web/src/components/Nodes/fileReference/FileReferenceNode.tsx new file mode 100644 index 000000000..40f24adc7 --- /dev/null +++ b/apps/web/src/components/Nodes/fileReference/FileReferenceNode.tsx @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { FileText, Maximize2 } from 'lucide-react'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { isFileReference } from '@huabu/shared'; + +import { FloatingToolbar } from '@/components/Common/FloatingToolbar'; +import { openPreviewNode } from '@/store/previewWorkspace/actions'; + +import { NodeWrapper } from '../NodeWrapper'; + +import type { FileReferenceNodeData } from '@huabu/shared'; +import type { Node, NodeProps } from '@xyflow/react'; + +export const FileReferenceNode = memo( + ({ + id, + data, + selected, + }: NodeProps< + Node, 'fileReference'> + >) => { + const { t } = useTranslation(); + const source = isFileReference(data.fileReference) + ? data.fileReference + : null; + return ( + openPreviewNode(id)} + > + + + } + > +
+ +
+ {data.label || t('files.file')} +
+
+ {source?.deviceName ?? source?.agentletId} +
+
+ {source?.path ?? t('files.invalidReference')} +
+
+ {t('files.referenceOnly')} +
+
+
+ ); + }, +); diff --git a/apps/web/src/components/Nodes/fileReference/FileReferencePreview.test.tsx b/apps/web/src/components/Nodes/fileReference/FileReferencePreview.test.tsx new file mode 100644 index 000000000..cdebc6fd1 --- /dev/null +++ b/apps/web/src/components/Nodes/fileReference/FileReferencePreview.test.tsx @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; + +import { FileReferencePreview } from './FileReferencePreview'; + +import type { FileReadResult } from '@huabu/shared'; + +const mocks = vi.hoisted(() => ({ + read: vi.fn(), + viewer: vi.fn(), + t: (key: string) => key, +})); +vi.mock('@/api/device-files', () => ({ readDeviceFile: mocks.read })); +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: mocks.t }) })); +vi.mock('@/components/CodeMirror/RawMarkdownEditor', () => ({ + default: (props: { + value: string; + readOnly: boolean; + plainText: boolean; + }) => { + mocks.viewer(props); + return
{props.value}
; + }, +})); +const reference = { agentletId: 'remote-device', path: '/source.tex' }; +const result = (text: string): FileReadResult => ({ + path: '/source.tex', + text, + encoding: 'utf-8', + byteLength: text.length, + size: 400_000, + contentVersion: 'a'.repeat(64), + nextCursor: 'next', +}); +let host: HTMLDivElement; +let root: Root; +beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); +}); +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + vi.unstubAllGlobals(); +}); +const button = (label: string) => { + const found = [...host.querySelectorAll('button')].find( + (candidate) => candidate.textContent === label, + ); + if (!found) throw new Error(`Missing ${label}`); + return found; +}; +async function open() { + await act(async () => + root.render( + , + ), + ); + await act(async () => { + await vi.waitFor(() => expect(host.querySelector('pre')).not.toBeNull()); + }); +} + +it('reads source into a plain-text readonly viewer and never exceeds 256 KiB across UTF-8-sized chunks', async () => { + mocks.read.mockImplementation( + async (_device: string, args: { limitBytes?: number }) => + result('a'.repeat(Math.min(args.limitBytes ?? 65_536, 65_533))), + ); + await open(); + expect(mocks.read.mock.calls[0]?.[0]).toBe('remote-device'); + for (let i = 0; i < 4; i++) + await act(async () => button('files.readMore').click()); + expect(host.querySelector('pre')?.textContent?.length).toBe(262_144); + expect(host.textContent).toContain('files.previewLimit'); + expect(mocks.read.mock.calls.at(-1)?.[1]).toMatchObject({ limitBytes: 12 }); + expect(mocks.viewer.mock.calls.at(-1)?.[0]).toMatchObject({ + readOnly: true, + plainText: true, + }); +}); + +it('keeps stale text visibly errored until manual refresh and drops the continuation cursor', async () => { + mocks.read + .mockResolvedValueOnce(result('\\documentclass{article}')) + .mockRejectedValueOnce(new Error('File changed')) + .mockResolvedValueOnce({ ...result('replacement'), nextCursor: undefined }); + await open(); + await act(async () => button('files.readMore').click()); + expect(host.querySelector('[role="alert"]')?.textContent).toBe( + 'File changed', + ); + expect(button('files.readMore').disabled).toBe(true); + expect(host.querySelector('pre')?.textContent).toBe( + '\\documentclass{article}', + ); + await act(async () => button('files.refresh').click()); + expect(mocks.read.mock.calls.at(-1)?.[1]).toEqual({ path: '/source.tex' }); + expect(host.querySelector('pre')?.textContent).toBe('replacement'); +}); diff --git a/apps/web/src/components/Nodes/fileReference/FileReferencePreview.tsx b/apps/web/src/components/Nodes/fileReference/FileReferencePreview.tsx new file mode 100644 index 000000000..938f0ef61 --- /dev/null +++ b/apps/web/src/components/Nodes/fileReference/FileReferencePreview.tsx @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { lazy, Suspense, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { isFileReference } from '@huabu/shared'; + +import { readDeviceFile } from '@/api/device-files'; +import { Button } from '@/components/Common/Button'; + +import type { PreviewComponentProps } from '../previews'; +import type { FileReadResult } from '@huabu/shared'; + +const SourceViewer = lazy( + () => import('@/components/CodeMirror/RawMarkdownEditor'), +); +const MAX_PREVIEW_BYTES = 262_144; + +export function FileReferencePreview({ id, data }: PreviewComponentProps) { + const { t } = useTranslation(); + const reference = isFileReference(data.fileReference) + ? data.fileReference + : null; + const agentletId = reference?.agentletId; + const path = reference?.path; + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + const [refresh, setRefresh] = useState(0); + const generation = useRef(0); + const controller = useRef(null); + const loadedBytes = useRef(0); + useEffect(() => { + const current = ++generation.current; + controller.current?.abort(); + const abort = new AbortController(); + controller.current = abort; + loadedBytes.current = 0; + setResult(null); + setError(null); + if (!agentletId || !path) return; + setPending(true); + void readDeviceFile(agentletId, { path }, abort.signal) + .then((value) => { + if (current !== generation.current) return; + loadedBytes.current = value.byteLength; + setResult(value); + }) + .catch((failure: unknown) => { + if (current === generation.current && !abort.signal.aborted) + setError( + failure instanceof Error ? failure.message : t('files.failed'), + ); + }) + .finally(() => { + if (current === generation.current) setPending(false); + }); + return () => { + generation.current = current + 1; + abort.abort(); + }; + }, [agentletId, path, refresh, t]); + + const more = async () => { + if ( + !result?.nextCursor || + !agentletId || + !path || + pending || + MAX_PREVIEW_BYTES - loadedBytes.current < 4 + ) + return; + const current = generation.current; + setPending(true); + setError(null); + try { + const next = await readDeviceFile( + agentletId, + { + path, + cursor: result.nextCursor, + limitBytes: Math.min(65_536, MAX_PREVIEW_BYTES - loadedBytes.current), + }, + controller.current?.signal, + ); + if (current !== generation.current) return; + if (next.contentVersion !== result.contentVersion) + throw new Error(t('files.changed')); + loadedBytes.current += next.byteLength; + setResult({ ...next, text: result.text + next.text }); + } catch (failure) { + if (current === generation.current) + setError( + failure instanceof Error ? failure.message : t('files.failed'), + ); + } finally { + if (current === generation.current) setPending(false); + } + }; + if (!reference) + return ( +
+ {t('files.invalidReference')} +
+ ); + return ( +
+
+ + {reference.deviceName ?? agentletId} · {path} + + +
+ {error ? ( +
+ {error} +
+ ) : null} + {pending ? ( +
+ {t('files.loading')} +
+ ) : null} + {result ? ( + {t('files.loading')}
}> + + + ) : null} + {result?.nextCursor ? ( +
+ {MAX_PREVIEW_BYTES - loadedBytes.current >= 4 ? ( + + ) : ( + + {t('files.previewLimit')} + + )} +
+ ) : null} + + ); +} diff --git a/apps/web/src/components/Nodes/previews.ts b/apps/web/src/components/Nodes/previews.ts index 99b832f88..0d976a141 100644 --- a/apps/web/src/components/Nodes/previews.ts +++ b/apps/web/src/components/Nodes/previews.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import { FileReferencePreview } from './fileReference/FileReferencePreview'; import { ImagePreview } from './image/ImagePreview'; import { NotePreview } from './note/NotePreview'; import { OfficePreview } from './office/OfficePreview'; @@ -15,6 +16,7 @@ export const NodePreviews: Record< string, React.ComponentType > = { + fileReference: FileReferencePreview, note: NotePreview, web: WebPreview, pdf: PDFPreview, diff --git a/apps/web/src/components/Nodes/types.ts b/apps/web/src/components/Nodes/types.ts index a6aaec200..e3272f97b 100644 --- a/apps/web/src/components/Nodes/types.ts +++ b/apps/web/src/components/Nodes/types.ts @@ -17,6 +17,7 @@ import type { SpacePreviewNodeData as SharedSpacePreviewNodeData, SketchNodeData as SharedSketchNodeData, QuestionNodeData as SharedQuestionNodeData, + FileReferenceNodeData, } from '@huabu/shared'; import type { Node } from '@xyflow/react'; @@ -61,6 +62,7 @@ export type CanvasQuestionNodeData = SharedQuestionNodeData & { /** Union type for all canvas node data */ export type CanvasNodeData = + | (FileReferenceNodeData & Record) | CanvasNoteNodeData | CanvasTextNodeData | CanvasWebNodeData diff --git a/apps/web/src/components/Panels/Canvas/Canvas.tsx b/apps/web/src/components/Panels/Canvas/Canvas.tsx index 133ebffe0..d7986f141 100644 --- a/apps/web/src/components/Panels/Canvas/Canvas.tsx +++ b/apps/web/src/components/Panels/Canvas/Canvas.tsx @@ -36,6 +36,7 @@ import { resolveArtifactUrl } from '@/api/artifact'; import { cn } from '@/components/Common/cn'; import { Loading } from '@/components/Common/Loading'; import { AudioNode } from '@/components/Nodes/audio/AudioNode'; +import { FileReferenceNode } from '@/components/Nodes/fileReference/FileReferenceNode'; import { FrameZoomController, FrameZoomProvider, @@ -161,6 +162,7 @@ const mainToolbarPosition: React.CSSProperties = { }; const nodeTypes = { + fileReference: FileReferenceNode, image: ImageNode, text: TextNode, note: NoteNode, @@ -240,6 +242,7 @@ const FrameFitPreviewOverlay: React.FC<{ /** Node types that support expand-on-double-click. */ const EXPANDABLE_TYPES = new Set([ + 'fileReference', 'image', 'video', 'web', @@ -1195,6 +1198,17 @@ const CanvasContent: React.FC = ({ ) moveNoteExcerpt({ sourceNodeId, sourceContentAfterMove, newNote }); else addNode(newNote); + } else if (payload.kind === 'fileReference') { + addNode({ + nodeType: 'fileReference', + placementPoint: dropPos, + data: { + fileReference: payload.data.fileReference, + label: payload.data.label, + origin: payload.origin, + }, + size: { width: 400, height: 180 }, + }); } else { const { src, label } = payload.data; const doAdd = (width: number, height: number) => diff --git a/apps/web/src/components/Panels/CanvasLayerPanel/layerFilterKey.ts b/apps/web/src/components/Panels/CanvasLayerPanel/layerFilterKey.ts index 7177918c5..1b754db57 100644 --- a/apps/web/src/components/Panels/CanvasLayerPanel/layerFilterKey.ts +++ b/apps/web/src/components/Panels/CanvasLayerPanel/layerFilterKey.ts @@ -31,6 +31,7 @@ import type { LucideIcon } from 'lucide-react'; export type OfficeFilterKey = `office:${OfficeFormat}`; export type LayerFilterKey = CanvasNodeType | OfficeFilterKey; export type LayerFilterLabelKey = + | 'layers.filterLabels.fileReference' | 'layers.filterLabels.note' | 'layers.filterLabels.text' | 'layers.filterLabels.image' @@ -82,6 +83,7 @@ export function getFilterKeyMeta(key: LayerFilterKey): { } const FILTER_LABEL_KEY_BY_TYPE: Record = { + fileReference: 'layers.filterLabels.fileReference', note: 'layers.filterLabels.note', text: 'layers.filterLabels.text', image: 'layers.filterLabels.image', diff --git a/apps/web/src/components/Panels/CanvasSidebar.tsx b/apps/web/src/components/Panels/CanvasSidebar.tsx new file mode 100644 index 000000000..326ce1573 --- /dev/null +++ b/apps/web/src/components/Panels/CanvasSidebar.tsx @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { useTranslation } from 'react-i18next'; + +import { Button } from '@/components/Common/Button'; +import { usePanelStore } from '@/store/panelStore'; + +import { CanvasLayerPanel } from './CanvasLayerPanel'; +import { FilesPanel } from './FilesPanel'; + +export function CanvasSidebar({ + isCollapsed, + isContentMounted = true, + onToggle, +}: { + isCollapsed?: boolean; + isContentMounted?: boolean; + onToggle?: () => void; +}) { + const { t } = useTranslation(); + const mode = usePanelStore((state) => state.leftMode); + const setMode = usePanelStore((state) => state.setLeftMode); + return ( +
+ {!isCollapsed ? ( +
+ + +
+ ) : null} +
+ +
+ {mode === 'files' && !isCollapsed && isContentMounted ? ( + + ) : null} +
+ ); +} diff --git a/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.test.tsx b/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.test.tsx index 89e810011..fc4fe98d4 100644 --- a/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.test.tsx +++ b/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.test.tsx @@ -15,6 +15,8 @@ import { import { i18n } from '@/i18n'; import { useAcpProfilesStore } from '@/store/acpProfilesStore'; +import useCanvasStore from '@/store/canvasStore'; +import { usePanelStore } from '@/store/panelStore'; import { useWorkspaceStore } from '@/store/workspaceStore'; import { WorkingDirectoryOverride } from './WorkingDirectoryOverride'; @@ -99,6 +101,29 @@ describe('WorkingDirectoryOverride', () => { editable: true, saving: false, }; + it('browses the exact effective device/path without changing the execution directory', async () => { + useCanvasStore.setState({ canvasId: 'canvas-files' }); + const onSave = vi.fn(); + await render({ + ...defaultProps, + agentletId: 'frozen-device', + effectiveWorkingDirPath: '/frozen/path', + editable: false, + onSave, + }); + await openPopover(); + await clickAction('Browse files'); + expect(usePanelStore.getState()).toMatchObject({ + leftMode: 'files', + isLeftCollapsed: false, + filesLocation: { + canvasId: 'canvas-files', + agentletId: 'frozen-device', + path: '/frozen/path', + }, + }); + expect(onSave).not.toHaveBeenCalled(); + }); it('offers picking only while editing on the confirmed device and saves only on Done', async () => { useWorkspaceStore.setState({ diff --git a/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.tsx b/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.tsx index 94a6ee322..f718265a7 100644 --- a/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.tsx +++ b/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.tsx @@ -13,6 +13,8 @@ import { TextArea } from '@/components/Common/TextArea'; import { Tooltip } from '@/components/Common/Tooltip'; import { useAgentletFolderPicker } from '@/hooks/useAgentletFolderPicker'; import { useAutoSizeTextArea } from '@/hooks/useAutoSizeTextArea'; +import useCanvasStore from '@/store/canvasStore'; +import { usePanelStore } from '@/store/panelStore'; interface WorkingDirectoryOverrideProps { agentletId?: string; @@ -345,6 +347,22 @@ export function WorkingDirectoryOverride({ {deviceName ?? t('chat.workingDirectoryDeviceUnknown')} + ) : null} diff --git a/apps/web/src/components/Panels/ChatPanel/index.tsx b/apps/web/src/components/Panels/ChatPanel/index.tsx index 2c01029b2..a179aff8b 100644 --- a/apps/web/src/components/Panels/ChatPanel/index.tsx +++ b/apps/web/src/components/Panels/ChatPanel/index.tsx @@ -808,11 +808,11 @@ export const ChatPanel = ({ agentBinding.kind === 'external' ? acpProfiles.find((profile) => profile.id === agentBinding.profileId) : undefined; - const selectedExternalDevice = selectedExternalProfile - ? resolveConnectedDevice( - selectedExternalProfile.agentletId, - connectedDevices, - ) + const directoryAgentletId = preparation.realized + ? preparation.agentletId + : selectedExternalProfile?.agentletId; + const selectedExternalDevice = directoryAgentletId + ? resolveConnectedDevice(directoryAgentletId, connectedDevices) : undefined; const workingDirectoryOverride = preparation.workingDirectoryOverride; const showWorkingDirectoryOverride = @@ -1137,7 +1137,7 @@ export const ChatPanel = ({ /> {showWorkingDirectoryOverride ? ( ({ + list: vi.fn(), + add: vi.fn(), + t: (key: string) => key, +})); +vi.mock('@/api/device-files', () => ({ listDeviceFiles: mocks.list })); +vi.mock('react-i18next', async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: mocks.t }), +})); +vi.mock('react-virtuoso', () => ({ + Virtuoso: ({ + data, + itemContent, + }: { + data: T[]; + itemContent: (index: number, row: T) => ReactNode; + }) => ( +
+ {data.map((row, index) => ( +
{itemContent(index, row)}
+ ))} +
+ ), +})); +let host: HTMLDivElement; +let root: Root; +const page = ( + entries: FileListResult['entries'], + nextCursor?: string, +): FileListResult => ({ + path: '/root', + parentPath: '/', + entries, + ...(nextCursor ? { nextCursor } : {}), +}); +const file = { + name: 'file.txt', + path: '/root/file.txt', + kind: 'file' as const, +}; +beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + useCanvasStore.setState({ canvasId: 'canvas-files', addNode: mocks.add }); + usePanelStore.getState().openFiles({ + canvasId: 'canvas-files', + agentletId: 'device', + path: '/root', + }); + useAcpProfilesStore.setState({ + error: null, + refresh: vi.fn().mockResolvedValue(undefined), + connectedDevices: [ + { + agentletId: 'device', + displayName: 'Remote', + connectedAt: '', + version: '1', + profileCount: 0, + }, + ], + }); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); +}); +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + vi.unstubAllGlobals(); +}); +const click = async (label: string) => { + const found = [...host.querySelectorAll('button')].find( + (button) => + button.textContent === label || + button.title === label || + button.getAttribute('aria-label') === label, + ); + if (!found) throw new Error(`Missing ${label}`); + await act(async () => found.click()); +}; + +it('loads directories lazily, deduplicates mutation-overlapping pages and creates metadata-only cards', async () => { + mocks.list + .mockResolvedValueOnce( + page( + [{ name: 'folder', path: '/root/folder', kind: 'directory' }, file], + 'page2', + ), + ) + .mockResolvedValueOnce(page([file])) + .mockResolvedValueOnce(page([])); + await act(async () => root.render()); + expect(mocks.list).toHaveBeenCalledOnce(); + await click('files.more'); + expect(host.textContent?.match(/file.txt/g)).toHaveLength(1); + await click('folder'); + expect(mocks.list.mock.calls.at(-1)?.[1]).toEqual({ + path: '/root/folder', + showHidden: false, + }); + await click('files.add'); + expect(mocks.add).toHaveBeenCalledWith( + expect.objectContaining({ + nodeType: 'fileReference', + data: { + label: 'file.txt', + origin: { type: 'user-created' }, + fileReference: { + agentletId: 'device', + path: '/root/file.txt', + deviceName: 'Remote', + }, + }, + }), + ); + usePanelStore.getState().requestFocusCanvasSearch(); + expect(usePanelStore.getState().leftMode).toBe('layers'); +}); + +it('discards late directory replies after the browsing target changes', async () => { + let resolve!: (value: FileListResult) => void; + mocks.list + .mockImplementationOnce( + () => + new Promise((done) => { + resolve = done; + }), + ) + .mockResolvedValueOnce({ path: '/new', parentPath: '/', entries: [] }); + await act(async () => root.render()); + const signal = mocks.list.mock.calls[0]?.[2] as AbortSignal; + await act(async () => + usePanelStore.getState().openFiles({ + canvasId: 'canvas-files', + agentletId: 'device', + path: '/new', + }), + ); + expect(signal.aborted).toBe(true); + await act(async () => resolve(page([file]))); + expect(host.textContent).not.toContain('file.txt'); +}); diff --git a/apps/web/src/components/Panels/FilesPanel.tsx b/apps/web/src/components/Panels/FilesPanel.tsx new file mode 100644 index 000000000..17e75dd90 --- /dev/null +++ b/apps/web/src/components/Panels/FilesPanel.tsx @@ -0,0 +1,399 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + ChevronRight, + ChevronDown, + FileText, + Folder, + Plus, + ArrowUp, + RefreshCw, +} from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Virtuoso } from 'react-virtuoso'; + +import { listDeviceFiles } from '@/api/device-files'; +import { Button } from '@/components/Common/Button'; +import { Select } from '@/components/Common/Select'; +import { TextInput } from '@/components/Common/TextInput'; +import { useAcpProfilesStore } from '@/store/acpProfilesStore'; +import useCanvasStore from '@/store/canvasStore'; +import { usePanelStore } from '@/store/panelStore'; +import { setDragPayload } from '@/utils/io/dragDrop'; + +import type { FileListResult } from '@huabu/shared'; + +type Entry = FileListResult['entries'][number]; +type Row = { entry: Entry; depth: number } | { more: string; depth: number }; +const MAX_TREE_ENTRIES = 2000; + +export function FilesPanel() { + const { t } = useTranslation(); + const canvasId = useCanvasStore((state) => state.canvasId); + const location = usePanelStore((state) => state.filesLocation); + const openFiles = usePanelStore((state) => state.openFiles); + const devices = useAcpProfilesStore((state) => state.connectedDevices); + const profilesError = useAcpProfilesStore((state) => state.error); + const source = location?.canvasId === canvasId ? location : null; + const sourceAgentletId = source?.agentletId; + const sourcePath = source?.path; + const [device, setDevice] = useState(source?.agentletId ?? ''); + const [path, setPath] = useState(source?.path ?? ''); + const [showHidden, setShowHidden] = useState(false); + const [refresh, setRefresh] = useState(0); + const [pages, setPages] = useState>({}); + const [expanded, setExpanded] = useState>(new Set()); + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + const pendingRef = useRef(false); + const abortRef = useRef(null); + const generation = useRef(0); + const entriesLoaded = useRef(0); + const selectedDevice = devices.find( + (candidate) => candidate.agentletId === source?.agentletId, + ); + useEffect(() => { + void useAcpProfilesStore.getState().refresh(); + }, []); + useEffect(() => { + setDevice(source?.agentletId ?? ''); + setPath(source?.path ?? ''); + }, [source?.agentletId, source?.path]); + + useEffect(() => { + const current = ++generation.current; + abortRef.current?.abort(); + const abort = new AbortController(); + abortRef.current = abort; + setPages({}); + setExpanded(new Set()); + setError(null); + entriesLoaded.current = 0; + if (!sourceAgentletId || !sourcePath) { + pendingRef.current = false; + setPending(false); + return; + } + pendingRef.current = true; + setPending(true); + void listDeviceFiles( + sourceAgentletId, + { path: sourcePath, showHidden }, + abort.signal, + ) + .then((result) => { + if (current !== generation.current) return; + entriesLoaded.current = result.entries.length; + setPages({ [sourcePath]: result }); + }) + .catch((failure: unknown) => { + if (current === generation.current && !abort.signal.aborted) + setError( + failure instanceof Error ? failure.message : t('files.failed'), + ); + }) + .finally(() => { + if (current === generation.current) { + pendingRef.current = false; + setPending(false); + } + }); + return () => { + generation.current = current + 1; + abort.abort(); + }; + }, [sourceAgentletId, sourcePath, showHidden, refresh, t]); + + const load = async (directory: string, cursor?: string) => { + if (!source || pendingRef.current) return; + if (entriesLoaded.current >= MAX_TREE_ENTRIES) { + setError(t('files.treeLimit')); + return; + } + const current = generation.current; + pendingRef.current = true; + setPending(true); + setError(null); + try { + const result = await listDeviceFiles( + source.agentletId, + { path: directory, showHidden, ...(cursor ? { cursor } : {}) }, + abortRef.current?.signal, + ); + if (current !== generation.current) return; + if (entriesLoaded.current + result.entries.length > MAX_TREE_ENTRIES) + throw new Error(t('files.treeLimit')); + entriesLoaded.current += result.entries.length; + setPages((previous) => ({ + ...previous, + [directory]: { + ...result, + entries: cursor + ? [ + ...new Map( + [ + ...(previous[directory]?.entries ?? []), + ...result.entries, + ].map((entry) => [entry.path, entry]), + ).values(), + ] + : result.entries, + }, + })); + setExpanded((previous) => new Set(previous).add(directory)); + } catch (failure) { + if (current === generation.current) + setError( + failure instanceof Error ? failure.message : t('files.failed'), + ); + } finally { + if (current === generation.current) { + pendingRef.current = false; + setPending(false); + } + } + }; + const rows = useMemo(() => { + const result: Row[] = []; + const visited = new Set(); + const visit = (directory: string, depth: number) => { + if (visited.has(directory) || depth > 32) return; + visited.add(directory); + const page = pages[directory]; + for (const entry of page?.entries ?? []) { + result.push({ entry, depth }); + if (entry.kind === 'directory' && expanded.has(entry.path)) + visit(entry.path, depth + 1); + } + if (page?.nextCursor) result.push({ more: directory, depth }); + }; + if (sourcePath) visit(sourcePath, 0); + return result; + }, [pages, expanded, sourcePath]); + + const add = (entry: Entry) => { + if (!source) return; + useCanvasStore.getState().addNode({ + nodeType: 'fileReference', + data: { + label: entry.name, + origin: { type: 'user-created' }, + fileReference: { + agentletId: source.agentletId, + path: entry.path, + deviceName: selectedDevice?.displayName, + }, + }, + size: { width: 400, height: 180 }, + }); + }; + const root = source ? pages[source.path] : undefined; + return ( +
+
{ + event.preventDefault(); + if (canvasId && device && path) { + openFiles({ canvasId, agentletId: device, path }); + setRefresh((value) => value + 1); + } + }} + > + setShowHidden(event.target.checked)} + /> + {t('files.showHidden')} + + +
+ {profilesError || error ? ( +
+ {error ?? profilesError?.message} +
+ ) : null} + {source && !selectedDevice ? ( +
+ {t('files.offline')} +
+ ) : null} + {pending ? ( +
+ {t('files.loading')} +
+ ) : null} + {!source ? ( +

{t('files.choosePath')}

+ ) : null} + {root && !rows.length ? ( +

{t('files.empty')}

+ ) : null} + + 'entry' in row ? `${row.depth}:${row.entry.path}` : `more:${row.more}` + } + itemContent={(_, row) => { + if ('more' in row) + return ( + + ); + const { entry, depth } = row; + const directory = entry.kind === 'directory'; + const Icon = directory ? Folder : FileText; + return ( +
+ {directory ? ( + + ) : ( + <> + { + if (!source || entry.kind !== 'file') return; + setDragPayload(event, { + kind: 'fileReference', + origin: { type: 'user-created' }, + data: { + label: entry.name, + fileReference: { + agentletId: source.agentletId, + path: entry.path, + deviceName: selectedDevice?.displayName, + }, + }, + }); + }} + > + + + {entry.name} + + + {entry.kind === 'file' ? ( + + ) : null} + + )} +
+ ); + }} + /> +
+ ); +} diff --git a/apps/web/src/config/nodeIcons.ts b/apps/web/src/config/nodeIcons.ts index 6fbf3c107..f89db99ec 100644 --- a/apps/web/src/config/nodeIcons.ts +++ b/apps/web/src/config/nodeIcons.ts @@ -40,6 +40,7 @@ import type { LucideIcon } from 'lucide-react'; * ``` */ export const NODE_ICON: Record = { + fileReference: FileText, note: Clipboard, text: Type, image: ImageIcon, @@ -81,6 +82,7 @@ export const OFFICE_FORMAT_ICON: Record = { * Human-readable display name for each node type. */ export const NODE_TYPE_LABEL: Record = { + fileReference: 'File Reference', note: 'Note', text: 'Text', image: 'Image', diff --git a/apps/web/src/i18n/resources/en/common.json b/apps/web/src/i18n/resources/en/common.json index 96f579e4d..3f4cb0f1e 100644 --- a/apps/web/src/i18n/resources/en/common.json +++ b/apps/web/src/i18n/resources/en/common.json @@ -1,4 +1,29 @@ { + "files": { + "title": "Files", + "file": "File", + "browse": "Browse files", + "path": "Absolute directory path", + "selectDevice": "Choose a device", + "open": "Open", + "up": "Parent directory", + "refresh": "Refresh", + "showHidden": "Hidden files", + "choosePath": "Choose a device and enter a directory, or use Browse files in Chat.", + "loading": "Loading files…", + "failed": "Filesystem request failed", + "empty": "This directory is empty.", + "more": "Load more entries", + "add": "Add {{name}} to Canvas", + "offline": "This device is offline. Reconnect it and refresh.", + "treeLimit": "Tree limit reached. Open a narrower directory or refresh.", + "invalidReference": "Invalid file reference", + "referenceOnly": "File reference · content is not saved in this Space", + "readOnlyText": "Read-only file source", + "readMore": "Read more", + "changed": "File changed. Refresh to read it again.", + "previewLimit": "Preview limit reached (256 KiB). Open the file on its device for more." + }, "sigil": { "chooseResource": "Choose a resource", "bindingHint": "Click a canvas object or choose a resource from the list", @@ -987,6 +1012,7 @@ "multiDragSameParentOnly": "Select layers from the same parent to move them together.", "invalidFrameDrop": "These layers cannot be moved to this destination. Check that the frame is available, unlocked, and not inside a selected layer.", "filterLabels": { + "fileReference": "File Reference", "note": "Note", "text": "Text", "image": "Image", diff --git a/apps/web/src/i18n/resources/zh-CN/common.json b/apps/web/src/i18n/resources/zh-CN/common.json index 94201fabc..08ad8cc80 100644 --- a/apps/web/src/i18n/resources/zh-CN/common.json +++ b/apps/web/src/i18n/resources/zh-CN/common.json @@ -1,4 +1,29 @@ { + "files": { + "title": "文件", + "file": "文件", + "browse": "浏览文件", + "path": "目录绝对路径", + "selectDevice": "选择设备", + "open": "打开", + "up": "上级目录", + "refresh": "刷新", + "showHidden": "隐藏文件", + "choosePath": "选择设备并输入目录,或从聊天中的“浏览文件”进入。", + "loading": "正在加载文件…", + "failed": "文件访问失败", + "empty": "此目录为空。", + "more": "加载更多条目", + "add": "将 {{name}} 添加到画布", + "offline": "设备已离线,请重新连接后刷新。", + "treeLimit": "已达到文件树上限,请打开更小的目录或刷新。", + "invalidReference": "文件引用无效", + "referenceOnly": "文件引用 · 正文不会保存在此空间", + "readOnlyText": "只读文件源码", + "readMore": "继续读取", + "changed": "文件已变化,请刷新后重新读取。", + "previewLimit": "已达到预览上限(256 KiB),请在所属设备上打开文件查看其余内容。" + }, "sigil": { "chooseResource": "选择资源", "bindingHint": "点击画布对象,或从列表选择资源", @@ -987,6 +1012,7 @@ "multiDragSameParentOnly": "请选择同一父级的图层后一起移动。", "invalidFrameDrop": "无法将这些图层移到此目标。请确认框架仍存在、未被锁定,且不在选中的图层内部。", "filterLabels": { + "fileReference": "文件引用", "note": "笔记", "text": "文本", "image": "图片", diff --git a/apps/web/src/pages/CanvasPage/CanvasPage.tsx b/apps/web/src/pages/CanvasPage/CanvasPage.tsx index 7d39d4771..588cfc39e 100644 --- a/apps/web/src/pages/CanvasPage/CanvasPage.tsx +++ b/apps/web/src/pages/CanvasPage/CanvasPage.tsx @@ -19,7 +19,7 @@ import { isVisibleWorldNode } from '@/utils/spaceHierarchy'; import { Loading } from '../../components/Common/Loading'; import { toast } from '../../components/Common/Toast'; import { hasNodePreview } from '../../components/Nodes/previews'; -import { CanvasLayerPanel } from '../../components/Panels/CanvasLayerPanel'; +import { CanvasSidebar } from '../../components/Panels/CanvasSidebar'; import { CanvasHeader } from '../../components/Panels/Header/CanvasHeader.tsx'; import { PreviewWorkspacePanel } from '../../components/Panels/PreviewWorkspace/PreviewWorkspacePanel'; import { useGlobalSearchHotkey } from '../../hooks/useGlobalSearchHotkey'; @@ -444,7 +444,7 @@ export default function CanvasPage() { } - leftPanel={} + leftPanel={} rightPanel={} > diff --git a/apps/web/src/pages/playground/NodeToolbarPlaygroundPage.tsx b/apps/web/src/pages/playground/NodeToolbarPlaygroundPage.tsx index 3639ffdc0..b2d40846a 100644 --- a/apps/web/src/pages/playground/NodeToolbarPlaygroundPage.tsx +++ b/apps/web/src/pages/playground/NodeToolbarPlaygroundPage.tsx @@ -70,6 +70,7 @@ import './NodeToolbarPlaygroundPage.css'; import type { CanvasNodeType } from '@huabu/shared'; export const TOOLBAR_SPECIMENS = { + fileReference: { title: 'README.md', category: 'Files' }, note: { title: 'Ideas worth keeping', category: '内容' }, text: { title: 'Make room for a new perspective.', category: '内容' }, image: { title: 'A little room for big ideas', category: '媒体' }, diff --git a/apps/web/src/store/canvasStore/save/nodeContentFields.ts b/apps/web/src/store/canvasStore/save/nodeContentFields.ts index a0a9e9e5b..ebbc54d16 100644 --- a/apps/web/src/store/canvasStore/save/nodeContentFields.ts +++ b/apps/web/src/store/canvasStore/save/nodeContentFields.ts @@ -53,6 +53,7 @@ export const NODE_CONTENT_KEYS: ReadonlySet = new Set([ * inline in structural state. */ export const MD_BACKED_NODE_TYPES: ReadonlySet = new Set([ + 'fileReference', 'note', 'text', 'web', diff --git a/apps/web/src/store/conversationPreparation.ts b/apps/web/src/store/conversationPreparation.ts index ca46f3fdd..37c4bdb11 100644 --- a/apps/web/src/store/conversationPreparation.ts +++ b/apps/web/src/store/conversationPreparation.ts @@ -115,6 +115,7 @@ export function resolveConversationPreparation( return { binding, realized: !!execution, + agentletId: execution?.agentletId, editable, ready: binding.kind === 'internal' || !!execution || state.locked[key] === false, diff --git a/apps/web/src/store/panelStore.ts b/apps/web/src/store/panelStore.ts index 026777dcc..3dad439a4 100644 --- a/apps/web/src/store/panelStore.ts +++ b/apps/web/src/store/panelStore.ts @@ -5,6 +5,14 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; interface PanelState { + leftMode: 'layers' | 'files'; + filesLocation: { canvasId: string; agentletId: string; path: string } | null; + openFiles: (location: { + canvasId: string; + agentletId: string; + path: string; + }) => void; + setLeftMode: (mode: 'layers' | 'files') => void; /** * Collapse state for the left (layer) side panel. * @@ -61,6 +69,11 @@ interface PanelState { export const usePanelStore = create()( persist( (set) => ({ + leftMode: 'layers', + filesLocation: null, + openFiles: (filesLocation) => + set({ filesLocation, leftMode: 'files', isLeftCollapsed: false }), + setLeftMode: (leftMode) => set({ leftMode }), isLeftCollapsed: true, setLeftCollapsed: (collapsed) => set({ isLeftCollapsed: collapsed }), toggleLeftPanel: () => @@ -70,6 +83,7 @@ export const usePanelStore = create()( requestFocusCanvasSearch: () => set((s) => ({ isLeftCollapsed: false, + leftMode: 'layers', focusCanvasSearchRequest: (s.focusCanvasSearchRequest ?? 0) + 1, })), diff --git a/apps/web/src/utils/io/dragDrop.ts b/apps/web/src/utils/io/dragDrop.ts index ebf10eaf2..a7d31b5a0 100644 --- a/apps/web/src/utils/io/dragDrop.ts +++ b/apps/web/src/utils/io/dragDrop.ts @@ -1,9 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { normalizeOrigin } from '@huabu/shared'; +import { isFileReference, normalizeOrigin } from '@huabu/shared'; -import type { NodeOrigin } from '@huabu/shared'; +import type { FileReference, NodeOrigin } from '@huabu/shared'; export const HUABU_DND_MIME = 'application/x-huabu-dnd'; @@ -50,7 +50,15 @@ export type DragPayload = { dragId: string; // Where the drag originated from, e.g. 'user-from-chat', 'user-from-library', 'user-excerpt'. origin: NodeOrigin; -} & (WebDragPayload | NoteDragPayload | ImageDragPayload); +} & ( + | WebDragPayload + | NoteDragPayload + | ImageDragPayload + | { + kind: 'fileReference'; + data: { fileReference: FileReference; label: string }; + } +); export const createDragId = () => { const uuid = globalThis.crypto?.randomUUID?.(); @@ -273,6 +281,23 @@ export const getHuabuPayload = (dt: DataTransfer): DragPayload | null => { }; } + if ( + kind === 'fileReference' && + data && + typeof data === 'object' && + 'fileReference' in data && + isFileReference(data.fileReference) && + 'label' in data && + typeof data.label === 'string' + ) { + return { + kind, + data: { fileReference: data.fileReference, label: data.label }, + dragId: normalizedDragId, + origin: normalizedOrigin, + }; + } + return null; } catch { return null; diff --git a/apps/web/src/utils/io/payloadToMarkdown.ts b/apps/web/src/utils/io/payloadToMarkdown.ts index 93d96e235..2d7e510f8 100644 --- a/apps/web/src/utils/io/payloadToMarkdown.ts +++ b/apps/web/src/utils/io/payloadToMarkdown.ts @@ -79,6 +79,8 @@ export function dragPayloadToMarkdown( options: PayloadToMarkdownOptions = {}, ): string | null { switch (payload.kind) { + case 'fileReference': + return null; case 'note': { const content = payload.data.content.trim(); return content === '' ? null : content; diff --git a/docs/README.md b/docs/README.md index 11f947a43..d17a54522 100644 --- a/docs/README.md +++ b/docs/README.md @@ -43,6 +43,8 @@ docs/ ## Architecture — current system reference +Connected-device browsing and the bundled ACP proxy are documented in [device-files.md](./architecture/device-files.md). + | Doc | What it covers | | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | --- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | --- | ----------------------------------------------- | -------------------------------------------------------------------------------------------- | | [agent-architecture.md](./architecture/agent-architecture.md) | Server-side agent runtime, tools, skills, SSE protocol. | diff --git a/docs/architecture/agent-profiles.md b/docs/architecture/agent-profiles.md index 577746f18..0567108e9 100644 --- a/docs/architecture/agent-profiles.md +++ b/docs/architecture/agent-profiles.md @@ -2,7 +2,7 @@ Ordinary external agents use persisted Profiles and the existing ACP runtime. Agentlet owns the supported harness catalogue, detection, capability descriptions, and structured launch compilation; Agenetes owns the generic Profile registry; Huabu owns automatic Profile creation, the application default, and the Settings/API projection. -Profiles describe Agent harness providers, not standalone service APIs. Optional OCR, web search, and image generation integrations retain their own configuration and server-held credentials independently of the Utility Agent or conversational recency preference. Submission-time Ink OCR supplies auxiliary evidence to the chosen Agent; it is not an internal-Agent fallback. A future model-provider or function-provider abstraction is separate from the Profile contract. +Profiles describe ACP harness launch identities, not standalone service APIs. The bundled deterministic `proxy` harness is an automatic hidden Profile (`visible: false`) used by the [connected-device File Explorer](./device-files.md). Missing visibility remains visible for legacy Profiles. Hidden Profiles are excluded from normal catalogues, selectors and Utility-default initialization, and do not change conversational recency. Visibility is host-owned, not a user create/patch field; the proxy harness is reserved from manual creation. Optional OCR, web search, and image generation integrations retain their own configuration and server-held credentials independently of the Utility Agent or conversational recency preference. Submission-time Ink OCR supplies auxiliary evidence to the chosen Agent; it is not an internal-Agent fallback. A future model-provider or function-provider abstraction is separate from the Profile contract. ## Discovery and provisioning @@ -27,7 +27,7 @@ Connection-triggered provisioning and owner-triggered `POST /api/acp/agentlets/: Manual detection targets the exact online device identity, without hostname placement fallback, Server-local probing, connection changes, Agentlet restart, or session realization. It reuses `server/discoverHarnesses` v2 with workspace preparation; no new Agentlet protocol is introduced. Detection observes the running daemon's inherited PATH, not another shell's changed environment. Newly installed commands must be visible on that PATH. Readiness confirms command discovery, not authentication, model availability, or successful ACP session startup. -The manual response excludes Custom and reports each known harness's existing readiness, installation hint and diagnostics, plus `profileStatus: created | existing | skipped` and a Profile ID when created or matched. Existing means a matching ready automatic typed Profile, not an arbitrary manual or legacy command Profile. Only ready entries with a prepared workspace can create a default. Version-probe warnings do not revoke readiness. Registry commits remain atomic per Profile, not across a complete catalogue: a reconciliation failure returns an explicit error with already-created Profile IDs, retains those additions, and permits duplicate-safe retry. Successful reconciliation retains the existing absent-only Utility-default initialization; saved Utility and conversational choices never change. +The manual response excludes Custom and the internal proxy and reports each user-facing harness's existing readiness, installation hint and diagnostics, plus `profileStatus: created | existing | skipped` and a Profile ID when created or matched. Existing means a matching ready automatic typed Profile, not an arbitrary manual or legacy command Profile. Only ready entries with a prepared workspace can create a default. Version-probe warnings do not revoke readiness. Registry commits remain atomic per Profile, not across a complete catalogue: a reconciliation failure returns an explicit error with already-created Profile IDs, retains those additions, and permits duplicate-safe retry. Successful reconciliation retains the existing absent-only Utility-default initialization; saved Utility and conversational choices never change. The Agentlet CLI owns its default device identity. On first start it atomically creates `~/.agentlet/device.json` containing `{ "version": 1, "deviceId": "" }`; later starts reuse that UUID, so identity survives daemon, Huabu, OS-session, binary, upgrade, and worktree restarts while the home directory remains. A malformed or unreadable identity file fails explicitly instead of rotating identity. `--agentlet-id` remains an advanced exact override and does not rewrite the persisted UUID. Hello metadata reports the real hostname, platform, and architecture separately for display; none of those fields participates in identity. Windows and WSL naturally receive distinct identities because they use separate home directories. diff --git a/docs/architecture/canvas-storage.md b/docs/architecture/canvas-storage.md index ded2edfc1..69a600672 100644 --- a/docs/architecture/canvas-storage.md +++ b/docs/architecture/canvas-storage.md @@ -16,6 +16,8 @@ The recent-conversation GET includes its Space existence check inside that same ## 2. Disk Layout +`fileReference` nodes retain only device/path/display metadata in structural state and use a frontmatter-only `.md` sidecar for labels. External bytes are never stored as node content or artifacts; copy and bundle export preserve only the reference. Source/body mutations are rejected for this type. See [connected-device files](./device-files.md). + Space ownership is logical metadata, not directory nesting. Ordinary Space records optionally store `parentSpaceId` and `parentEntryNodeId`; missing/null fields mean top-level. SQLite and PostgreSQL add `parent_space_id` / `parent_entry_node_id` and a Workspace/parent index in migration v2. The collection's trusted `setParent` primitive persists both fields, while the canvas hierarchy service owns existence, cycle, and canonical-entry checks under its Workspace-leased process lock. Ordinary structural writes preserve stored hierarchy instead of accepting it from clients. Disk summaries capture hierarchy in the existing directory-index scan without extra record reads or write-back. Imported single-Space bundles clear ownership and remain top-level; they do not recursively import children. See [Space ownership and lifecycle](./space-preview.md#space-ownership-and-lifecycle) for API, navigation, deletion, and concurrency guarantees. ``` diff --git a/docs/architecture/device-files.md b/docs/architecture/device-files.md new file mode 100644 index 000000000..2d66ce450 --- /dev/null +++ b/docs/architecture/device-files.md @@ -0,0 +1,54 @@ +# Connected-device File Explorer + +The Files mode in the left Canvas sidebar browses files on an explicitly selected connected Agentlet. The working-directory popover beside Chat's Profile selector offers **Browse files** for its effective device and directory without editing either. Realized conversations use the frozen execution device and cwd, not the current Profile template. Legacy executions without a known frozen device cannot guess a replacement. + +## Transport and ownership + +```text +Files sidebar / file-reference Preview + -> owner-only POST /api/acp/files + -> private queued ACP Job on the exact Agentlet + -> bundled proxy harness, session/prompt + -> native read-only filesystem operations +``` + +Agentlet bundles the deterministic `proxy` ACP executable beside its daemon. Discovery provisions one automatic Profile per device with `harnessId: "proxy"` and `visible: false`. Missing `visible` means visible for existing Profiles. Normal Profile lists, selectors and absent-only Utility defaults exclude hidden Profiles; hiding is presentation, not an authorization boundary. Manual creation reserves the proxy harness. Discovery and launch reuse existing Agentlet messages and the ACP relay; there is no new filesystem WSS method, shell command dispatcher, downloaded runtime or model call. + +The Server lazily maintains at most four device sessions, with at most eight queued operations per device and FIFO execution within each session. These are private Jobs with no Chat node, durable conversation transcript, RFS environment or conversational recency update. Their neutral Profile cwd does not change with browsing. A cold request has a 60-second deadline and a warm request 10 seconds. Idle sessions retire after 60 seconds, checked every 15 seconds. Disconnect, reconnect, failure and shutdown retire the old generation, shut down the client and reclaim its Agentlet process. Late startup retains admission ownership until it settles and can be reclaimed; failed reclamation is explicit and retryable rather than admitting replacement processes silently. + +## Command contract + +The proxy advertises `fs-list` and `fs-read` through ACP `available_commands_update`. A prompt contains exactly one text block: `/COMMAND ` followed by one strict JSON object. Unknown commands and fields are rejected. Each call is self-contained; ACP session reuse is an optimization, not filesystem command state. There are no root IDs, open/close operations, retained file handles or per-browser authorization roots. + +| Command | Input | Result | +| --------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `fs-list` | Absolute `path`; optional `cursor`, `limit` (1–100), `showHidden` | Canonical `path`, `parentPath`, bounded `entries` (`name`, absolute `path`, `kind`), optional `nextCursor` | +| `fs-read` | Absolute `path`; optional `cursor`, `limitBytes` (4–65,536) | `path`, `encoding: "utf-8"`, source `text`, `byteLength`, `size`, `contentVersion`, optional `nextCursor` | + +The response is one JSON document: `{ version: 1, operation, ok: true, result }` or `{ version: 1, operation, ok: false, error: { code, message } }`. The Server assembles bounded text notifications and accepts a result only after successful `end_turn`, response schema validation and matching operation. ACP completion alone is not filesystem success. Permission requests are rejected. Raw file output is not written to runtime diagnostics or conversation history; HTTP results use `Cache-Control: no-store`. + +Paths are native absolute paths on the selected Agentlet, not the Huabu Server or browser. The process OS account is the authority: all readable paths are accessible, normal symlinks are followed, and no privilege elevation or project-root confinement is added. Only directory listing and stable-sized regular UTF-8 text reads are implemented. Binary/invalid text, special or generated files without stable size metadata, inaccessible paths and malformed requests fail explicitly. The proxy uses nonblocking read-only opens to avoid blocking on a substituted FIFO. + +List cursors encode the canonical directory, hidden-file option and last name. Pages are best-effort under directory mutation, not snapshots; the UI deduplicates entries. Scans are capped at 20,000 entries. Read cursors encode path, byte offset and a version derived from filesystem identity, size and timestamps. Reads preserve UTF-8 boundaries and reject changes between or during chunks; they do not retain file descriptors across calls. Responses are capped at 524,288 serialized bytes. Cursors are continuation positions, not secrets or authorization grants. + +## Canvas and Preview + +Files uses existing Common controls and `react-virtuoso` for a lazy expandable directory disclosure list. It loads directories on demand, supports hidden-file filtering and pagination, and caps a mounted tree at 2,000 entries and 32 nested levels. Opening a path or refreshing rereads the tree; there is no watcher, polling or rename tracking. Layers remains the other sidebar mode, and Canvas search switches back to Layers. + +Adding or dragging a file uses existing Canvas commands to create a `fileReference` node. Structural data stores only `{ agentletId, path, deviceName? }` under `data.fileReference`, plus ordinary presentation fields. The label uses a frontmatter-only node sidecar. Content/source mutation endpoints reject source bytes on these nodes. Duplication and bundle export preserve inert references, not external file contents. Loading a Canvas or processing/searching its nodes does not read remote files or import them into artifacts. + +Opening a file-reference Preview reads supported UTF-8 text into component-local state. The view reuses read-only CodeMirror in plain-text mode; `.tex` remains source and is never compiled. Manual refresh restarts from the path, including a replacement made by atomic save. Read more continues up to a 256 KiB display cap; stale content and offline devices remain explicit errors until retry. No edit, download-as-import, AI ingestion, shell execution or automatic reference repair is provided. + +## Code entry points + +| File | Responsibility | +| ------------------------------------------------------------------------------------ | ----------------------------------------------------------------- | +| [filesystem protocol](../../external/agentlet/packages/protocol/src/filesystem.ts) | Command vocabulary, request validation and resource constants | +| [native filesystem reader](../../external/agentlet/packages/local/src/filesystem.ts) | Read-only list/read, encoding, symlinks and stateless cursors | +| [ACP proxy](../../external/agentlet/packages/local/src/fs-proxy.ts) | Capability advertisement and deterministic ACP command dispatch | +| [proxy harness](../../external/agentlet/packages/local/src/harnesses/proxy.ts) | Bundled discovery and trusted launch compilation | +| [HTTP schemas](../../packages/shared/src/types/api/device-files.ts) | Canonical request/result and reference metadata schemas | +| [private session service](../../apps/server/src/modules/agent/acp/device-files.ts) | Exact-device routing, lifecycle, queueing and response validation | +| [HTTP route](../../apps/server/src/modules/agent/acp/device-files.route.ts) | Owner authorization and explicit HTTP errors | +| [Files panel](../../apps/web/src/components/Panels/FilesPanel.tsx) | Lazy directory disclosure, pagination, Add and copy-only drag | +| [file-reference renderers](../../apps/web/src/components/Nodes/fileReference/) | Metadata card and bounded read-only source Preview | diff --git a/docs/architecture/node-preprocessing.md b/docs/architecture/node-preprocessing.md index a590cd94e..8cd3f4e68 100644 --- a/docs/architecture/node-preprocessing.md +++ b/docs/architecture/node-preprocessing.md @@ -16,6 +16,8 @@ A node is processed by asking "which capabilities are dirty for this change", not "is this an ingest or a label request". +The `fileReference` type is metadata-only, with no extraction, enrichment, content fetch or watched fields. Its label is persisted through normal Canvas sidecar writes; opening its read-only Preview uses the separate [device-files](./device-files.md) service and never triggers preprocessing. + --- ## 2. Pipeline (6 stages) diff --git a/docs/architecture/preview-workspace.md b/docs/architecture/preview-workspace.md index e68334fc4..c77e8825f 100644 --- a/docs/architecture/preview-workspace.md +++ b/docs/architecture/preview-workspace.md @@ -2,6 +2,8 @@ Preview Workspace is the only right-side presentation surface on a Canvas page. It hosts node previews, webpage URL tabs, and unbound Chat conversations in one or two tab groups while the Canvas remains mounted in the centre. +File-reference nodes use a bounded, read-only plain-text renderer in this same Preview surface. The Chat folder popover's Browse files action opens the left Files sidebar without changing cwd; routing uses the frozen device for realized conversations. File bytes remain renderer-local, are loaded only for Preview, and are not imported into the Space. See [connected-device files](./device-files.md). + The implementation originated in the [Unified Preview Workspace proposal](../proposals/unified-preview-workspace.md). This document is authoritative for the shipped system; the proposal is retained as design and migration history. ## 1. Ownership boundaries diff --git a/packages/shared/src/canvas-engine/commands/createNodes.ts b/packages/shared/src/canvas-engine/commands/createNodes.ts index ac4ac1dd9..3837fae77 100644 --- a/packages/shared/src/canvas-engine/commands/createNodes.ts +++ b/packages/shared/src/canvas-engine/commands/createNodes.ts @@ -3,6 +3,7 @@ import { noop, type CommandDefinition } from './types.js'; import { createId, type CanvasCommand } from '../../index.js'; +import { isFileReference } from '../../utils/file-reference.js'; import { projectAgentNodeEditableData } from '../agentNodeOwnership.js'; import { materializeAutoHeight } from '../height/materialize.js'; import { getHeightPolicy } from '../height/policy.js'; @@ -42,6 +43,15 @@ const createNodes: CommandDefinition = { handler(cmd, state) { if (cmd.nodes.length === 0) return noop(state); + if ( + cmd.nodes.some( + (node) => + node.nodeType === 'fileReference' && + (!isFileReference(node.data?.fileReference) || + ['content', 'src'].some((key) => key in (node.data ?? {}))), + ) + ) + return noop(state, 'invalid-target'); if ( cmd.nodes.some( (node) => diff --git a/packages/shared/src/canvas-engine/commands/mergeNodeData.ts b/packages/shared/src/canvas-engine/commands/mergeNodeData.ts index 6caa65cfc..436c7772e 100644 --- a/packages/shared/src/canvas-engine/commands/mergeNodeData.ts +++ b/packages/shared/src/canvas-engine/commands/mergeNodeData.ts @@ -2,6 +2,7 @@ // Licensed under the MIT license. import { noop, type CommandDefinition } from './types.js'; +import { isFileReference } from '../../utils/file-reference.js'; import { hasAgentNodeOwnedData, changesAgentNodePreparation, @@ -25,6 +26,13 @@ const mergeNodeData: CommandDefinition = { const node = state.nodes.find( (candidate) => candidate.id === entry.nodeId, ); + if ( + node?.type === 'fileReference' && + (['content', 'src'].some((key) => key in entry.patch) || + ('fileReference' in entry.patch && + !isFileReference(entry.patch.fileReference))) + ) + return noop(state, 'invalid-target'); if (node?.type === 'sketch' && !hasValidInkResourceBindings(entry.patch)) return noop(state, 'invalid-target'); if (node?.type === 'question' && hasAgentNodeOwnedData(entry.patch)) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a32c8d30c..ecded47c5 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -14,3 +14,4 @@ export * from './utils/acp-session-selectors.js'; export { stripLegacyPortalTopology } from './utils/strip-legacy-portal-topology.js'; export * from './llm/image-capabilities.js'; export * from './llm/model-roles.js'; +export { isFileReference } from './utils/file-reference.js'; diff --git a/packages/shared/src/types/api/acp.ts b/packages/shared/src/types/api/acp.ts index af77c1ffe..55f310235 100644 --- a/packages/shared/src/types/api/acp.ts +++ b/packages/shared/src/types/api/acp.ts @@ -457,6 +457,7 @@ export const acpThreadCachedMetaResponseSchema = z.object({ profileId: z.string().min(1), }), workingDirPath: z.string().optional(), + agentletId: z.string().optional(), /** Null denotes inheritance; absent on legacy execution snapshots. */ workingDirectoryOverride: z.string().nullable().optional(), }) diff --git a/packages/shared/src/types/api/agent-node.ts b/packages/shared/src/types/api/agent-node.ts index 2712b6312..862be206d 100644 --- a/packages/shared/src/types/api/agent-node.ts +++ b/packages/shared/src/types/api/agent-node.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { agentBindingSchema } from './agent.js'; +import { fileReferenceSchema } from './device-files.js'; import { sketchResourceBindingDataSchema } from './ink-sigil.js'; export const agentNodeBindingStateSchema = z.enum(['editing', 'bound']); @@ -49,6 +50,21 @@ const canvasNodeEditBaseSchema = z .catchall(z.unknown()); export const canvasEditableNodeSchema = canvasNodeEditBaseSchema.superRefine( (node, ctx) => { + if (node.type === 'fileReference') { + if ( + (node.data && + 'fileReference' in node.data && + !fileReferenceSchema.safeParse(node.data.fileReference).success) || + ['content', 'src'].some((key) => key in (node.data ?? {})) + ) { + ctx.addIssue({ + code: 'custom', + message: 'Invalid metadata-only file reference', + path: ['data'], + }); + } + return; + } if (!node.data || (node.type !== 'question' && node.type !== 'sketch')) return; const parsed = ( diff --git a/packages/shared/src/types/api/agent-profile.ts b/packages/shared/src/types/api/agent-profile.ts index 2511497de..ffb82c470 100644 --- a/packages/shared/src/types/api/agent-profile.ts +++ b/packages/shared/src/types/api/agent-profile.ts @@ -69,6 +69,7 @@ export const AGENT_ICON_CUSTOM_DATA_KEY = 'icon'; const profileBaseSchema = z.object({ id: trimmedString(255), alias: trimmedString(255), + visible: z.boolean().optional(), agentletId: trimmedString(255), workingDirPath: pathSchema, customData: customDataSchema.optional(), @@ -114,6 +115,7 @@ export const agentProfileSchema = profileBaseSchema export type AgentProfileView = z.infer; export const createAgentProfileBodySchema = agentProfileSchema.omit({ + visible: true, id: true, revision: true, executionRevision: true, @@ -123,7 +125,13 @@ export type CreateAgentProfileBody = z.infer< >; export const createAcpCommandProfileBodySchema = profileBaseSchema - .omit({ id: true, agentletId: true, revision: true, executionRevision: true }) + .omit({ + id: true, + visible: true, + agentletId: true, + revision: true, + executionRevision: true, + }) .extend({ launch: commandLaunchSchema, metadata: commandMetadataSchema.optional(), @@ -134,6 +142,7 @@ export type CreateAcpCommandProfileBody = z.infer< >; export const createAcpProfileBodySchema = agentProfileSchema.omit({ + visible: true, id: true, revision: true, executionRevision: true, diff --git a/packages/shared/src/types/api/device-files.ts b/packages/shared/src/types/api/device-files.ts new file mode 100644 index 000000000..563213705 --- /dev/null +++ b/packages/shared/src/types/api/device-files.ts @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { z } from 'zod'; + +import { isDeviceFilePath } from '../../utils/file-reference.js'; + +const absolutePathSchema = z + .string() + .min(1) + .max(4096) + .refine(isDeviceFilePath, 'Expected an absolute path'); +const cursorSchema = z.string().min(1).max(16_384); +export const fileReferenceSchema = z + .object({ + agentletId: z.string().min(1).max(255), + path: absolutePathSchema, + deviceName: z.string().max(512).optional(), + }) + .strict(); +export type FileReference = z.infer; + +const listArgsSchema = z + .object({ + path: absolutePathSchema, + cursor: cursorSchema.optional(), + limit: z.number().int().min(1).max(100).optional(), + showHidden: z.boolean().optional(), + }) + .strict(); +const readArgsSchema = z + .object({ + path: absolutePathSchema, + cursor: cursorSchema.optional(), + limitBytes: z.number().int().min(4).max(65_536).optional(), + }) + .strict(); +export const deviceFilesBodySchema = z.discriminatedUnion('operation', [ + z + .object({ + agentletId: fileReferenceSchema.shape.agentletId, + operation: z.literal('fs-list'), + args: listArgsSchema, + }) + .strict(), + z + .object({ + agentletId: fileReferenceSchema.shape.agentletId, + operation: z.literal('fs-read'), + args: readArgsSchema, + }) + .strict(), +]); +export type DeviceFilesBody = z.infer; + +export const fileListResultSchema = z + .object({ + path: absolutePathSchema, + parentPath: absolutePathSchema, + entries: z + .array( + z + .object({ + name: z.string().min(1).max(4096), + path: absolutePathSchema, + kind: z.enum(['directory', 'file', 'symlink', 'other']), + }) + .strict(), + ) + .max(100), + nextCursor: cursorSchema.optional(), + }) + .strict(); +export type FileListResult = z.infer; +export const fileReadResultSchema = z + .object({ + path: absolutePathSchema, + encoding: z.literal('utf-8'), + text: z.string().max(65_536), + byteLength: z.number().int().min(0).max(65_536), + size: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + contentVersion: z.string().regex(/^[a-f0-9]{64}$/), + nextCursor: cursorSchema.optional(), + }) + .strict(); +export type FileReadResult = z.infer; +export const deviceFilesResponseSchema = z.discriminatedUnion('operation', [ + z + .object({ operation: z.literal('fs-list'), result: fileListResultSchema }) + .strict(), + z + .object({ operation: z.literal('fs-read'), result: fileReadResultSchema }) + .strict(), +]); +export type DeviceFilesResponse = z.infer; +export const proxyFsResponseSchema = z.union([ + z + .object({ + version: z.literal(1), + operation: z.literal('fs-list'), + ok: z.literal(true), + result: fileListResultSchema, + }) + .strict(), + z + .object({ + version: z.literal(1), + operation: z.literal('fs-read'), + ok: z.literal(true), + result: fileReadResultSchema, + }) + .strict(), + z + .object({ + version: z.literal(1), + operation: z.enum(['fs-list', 'fs-read']), + ok: z.literal(false), + error: z + .object({ + code: z.enum([ + 'invalid_request', + 'permission_denied', + 'not_found', + 'not_directory', + 'unsupported_file', + 'unsupported_encoding', + 'directory_too_large', + 'stale_cursor', + 'cancelled', + 'busy', + 'io_error', + ]), + message: z.string().max(512), + }) + .strict(), + }) + .strict(), +]); diff --git a/packages/shared/src/types/api/index.ts b/packages/shared/src/types/api/index.ts index 8b9cc91e7..e3c867db4 100644 --- a/packages/shared/src/types/api/index.ts +++ b/packages/shared/src/types/api/index.ts @@ -42,3 +42,4 @@ export * from './interactive-view.js'; export * from './deployment.js'; export * from './agent-node.js'; export * from './service-package.js'; +export * from './device-files.js'; diff --git a/packages/shared/src/types/canvas/index.ts b/packages/shared/src/types/canvas/index.ts index 192d19fc7..25b04f9e4 100644 --- a/packages/shared/src/types/canvas/index.ts +++ b/packages/shared/src/types/canvas/index.ts @@ -42,6 +42,7 @@ export type { NodeFontStyle, NodeTextDecoration, BaseNodeData, + FileReferenceNodeData, AutoHeightHint, HeightMode, BlockProvenance, diff --git a/packages/shared/src/types/canvas/node.ts b/packages/shared/src/types/canvas/node.ts index b44e7fa95..5effe0058 100644 --- a/packages/shared/src/types/canvas/node.ts +++ b/packages/shared/src/types/canvas/node.ts @@ -14,6 +14,7 @@ import type { AgentNodeLaunchOverrides, } from '../api/agent-node.js'; import type { AgentIcon } from '../api/agent-profile.js'; +import type { FileReference } from '../api/device-files.js'; import type { InkResourceBinding } from '../api/ink-sigil.js'; import type { InteractiveViewDefinitionV1 } from '../api/interactive-view.js'; @@ -37,6 +38,7 @@ export const CANVAS_NODE_TYPES = [ 'spacePreview', 'sketch', 'question', + 'fileReference', ] as const; export type CanvasNodeType = (typeof CANVAS_NODE_TYPES)[number]; @@ -807,7 +809,13 @@ export interface QuestionNodeData extends BaseNodeData { * Discriminated union of all node data types. * Use the 'type' field to narrow down to specific node type. */ +export interface FileReferenceNodeData extends BaseNodeData { + type: 'fileReference'; + fileReference: FileReference; +} + export type NodeData = + | FileReferenceNodeData | NoteNodeData | TextNodeData | WebNodeData diff --git a/packages/shared/src/utils/file-reference.test.ts b/packages/shared/src/utils/file-reference.test.ts new file mode 100644 index 000000000..a273f695f --- /dev/null +++ b/packages/shared/src/utils/file-reference.test.ts @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { describe, expect, it } from 'vitest'; + +import { isFileReference } from './file-reference.js'; +import { fileReferenceSchema } from '../types/api/device-files.js'; + +describe('zod-free file-reference validation', () => { + it.each([ + { agentletId: 'device', path: '/home/user/source.tex' }, + { + agentletId: 'device', + path: 'C:\\Users\\source.txt', + deviceName: 'Windows', + }, + { agentletId: 'device', path: '\\\\host\\share\\file' }, + { agentletId: '', path: '/file' }, + { agentletId: 'device', path: 'relative/path' }, + { agentletId: 'device', path: '/file\u0000' }, + { agentletId: 'device', path: '/file', deviceName: 'x'.repeat(513) }, + { agentletId: 'device', path: '/file', token: 'forbidden' }, + null, + [], + 'file', + ])('matches the canonical metadata-only wire schema for %j', (value) => { + expect(isFileReference(value)).toBe( + fileReferenceSchema.safeParse(value).success, + ); + }); +}); diff --git a/packages/shared/src/utils/file-reference.ts b/packages/shared/src/utils/file-reference.ts new file mode 100644 index 000000000..1818ee2b5 --- /dev/null +++ b/packages/shared/src/utils/file-reference.ts @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { FileReference } from '../types/api/device-files.js'; + +export function isDeviceFilePath(value: unknown): value is string { + if (typeof value !== 'string' || value.length === 0 || value.length > 4096) + return false; + for (let index = 0; index < value.length; index++) { + if (value.charCodeAt(index) < 32) return false; + } + return ( + value.startsWith('/') || + /^[a-zA-Z]:[\\/]/.test(value) || + /^\\\\[^\\]+\\[^\\]+/.test(value) + ); +} + +export function isFileReference(value: unknown): value is FileReference { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const reference = value as Record; + return ( + typeof reference.agentletId === 'string' && + reference.agentletId.length > 0 && + reference.agentletId.length <= 255 && + isDeviceFilePath(reference.path) && + (reference.deviceName === undefined || + (typeof reference.deviceName === 'string' && + reference.deviceName.length <= 512)) && + Object.keys(reference).every((key) => + ['agentletId', 'path', 'deviceName'].includes(key), + ) + ); +} From 17f9f20d507a7ec0067421d8a244a335710dbf6a Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Wed, 28 Oct 2026 13:22:52 +0000 Subject: [PATCH 5/5] test(acp): assert frozen device in cached thread metadata Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/modules/agent/acp/threads.route.cached-meta.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/server/src/modules/agent/acp/threads.route.cached-meta.test.ts b/apps/server/src/modules/agent/acp/threads.route.cached-meta.test.ts index 8c5bb8d09..f43e2dd30 100644 --- a/apps/server/src/modules/agent/acp/threads.route.cached-meta.test.ts +++ b/apps/server/src/modules/agent/acp/threads.route.cached-meta.test.ts @@ -131,6 +131,7 @@ describe('ACP cached-meta across awaited persistence', () => { expect(response.statusCode).toBe(200); expect(response.json()).toMatchObject({ source: 'thread', + execution: { agentletId: 'agentlet-from-record' }, availableCommands: [{ name: 'review' }], commandsUpdatedAt: 31, sessionMeta: { @@ -161,6 +162,7 @@ describe('ACP cached-meta across awaited persistence', () => { expect(response.statusCode).toBe(200); expect(response.json()).toMatchObject({ source: 'thread', + execution: { agentletId: 'device-uuid' }, availableCommands: [{ name: 'review' }], }); }); @@ -215,6 +217,7 @@ describe('ACP cached-meta across awaited persistence', () => { source: 'thread', execution: { binding: { kind: 'external', alias: 'A', profileId: 'profile-1' }, + agentletId: 'agentlet-from-record', }, availableCommands: [{ name: 'compact', description: 'Compact context' }], commandsUpdatedAt: 11,