mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 00:48:10 +00:00
852 lines
34 KiB
TypeScript
852 lines
34 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { lstat, mkdir, mkdtemp, open, realpath, rename, rm, stat, symlink, writeFile } from 'node:fs/promises';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { join, resolve } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { AcpSessionAccessRegistry } from '../../electron/services/acp-session-access-registry';
|
|
import {
|
|
StagedAttachmentRegistry,
|
|
createAttachmentAccess,
|
|
} from '../../electron/services/attachment-access';
|
|
import {
|
|
clearAcpTraceForTests,
|
|
getAcpTraceSnapshot,
|
|
} from '../../electron/services/acp-trace';
|
|
import {
|
|
resolveOpenClawConfigDir,
|
|
resolveOpenClawConfigPath,
|
|
resolveOpenClawStateDir,
|
|
} from '../../electron/utils/paths';
|
|
|
|
describe('attachment access boundary', () => {
|
|
const sessionKey = 'agent:main:session-a';
|
|
let testDir: string;
|
|
let workspaceRoot: string;
|
|
let stateDir: string;
|
|
let configDir: string;
|
|
let externalMediaRoot: string;
|
|
let outsideDir: string;
|
|
let registry: AcpSessionAccessRegistry;
|
|
let stagedAttachments: StagedAttachmentRegistry;
|
|
let openPath: ReturnType<typeof vi.fn>;
|
|
let openExternal: ReturnType<typeof vi.fn>;
|
|
let showItemInFolder: ReturnType<typeof vi.fn>;
|
|
let listOpenHandlers: ReturnType<typeof vi.fn>;
|
|
let openWithHandler: ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(async () => {
|
|
clearAcpTraceForTests();
|
|
testDir = await mkdtemp(join(tmpdir(), 'clawx-attachment-access-'));
|
|
workspaceRoot = join(testDir, 'workspace');
|
|
stateDir = join(testDir, 'state');
|
|
configDir = join(testDir, 'config');
|
|
externalMediaRoot = join(testDir, 'runtime-media');
|
|
outsideDir = join(testDir, 'outside');
|
|
await Promise.all([
|
|
mkdir(join(workspaceRoot, 'nested'), { recursive: true }),
|
|
mkdir(join(stateDir, 'media', 'outgoing', 'records'), { recursive: true }),
|
|
mkdir(join(stateDir, 'agents', 'main', 'sessions'), { recursive: true }),
|
|
mkdir(join(configDir, 'media'), { recursive: true }),
|
|
mkdir(externalMediaRoot, { recursive: true }),
|
|
mkdir(outsideDir, { recursive: true }),
|
|
]);
|
|
await Promise.all([
|
|
writeFile(join(workspaceRoot, 'notes.txt'), 'workspace notes'),
|
|
writeFile(join(workspaceRoot, 'binary.bin'), Buffer.from([1, 2, 3])),
|
|
writeFile(join(stateDir, 'media', 'state.png'), 'state image'),
|
|
writeFile(join(configDir, 'media', 'config.png'), 'config image'),
|
|
writeFile(join(externalMediaRoot, 'runtime.png'), 'runtime image'),
|
|
writeFile(join(stateDir, 'openclaw.json'), '{}'),
|
|
writeFile(join(configDir, 'openclaw.json'), '{}'),
|
|
writeFile(join(stateDir, 'media', 'outgoing', 'records', 'leak.json'), JSON.stringify({
|
|
original: { path: '/private/account/secret.png' },
|
|
})),
|
|
writeFile(join(stateDir, 'agents', 'main', 'sessions', 'secret.jsonl'), 'secret'),
|
|
writeFile(join(outsideDir, 'secret.txt'), 'outside secret'),
|
|
]);
|
|
registry = new AcpSessionAccessRegistry();
|
|
registry.commitGrant(await registry.prepareGrant({
|
|
sessionKey,
|
|
generation: 1,
|
|
workspaceRoot,
|
|
executionCwd: join(workspaceRoot, 'nested'),
|
|
}));
|
|
stagedAttachments = new StagedAttachmentRegistry();
|
|
openPath = vi.fn().mockResolvedValue('');
|
|
openExternal = vi.fn().mockResolvedValue(undefined);
|
|
showItemInFolder = vi.fn();
|
|
listOpenHandlers = vi.fn().mockResolvedValue([]);
|
|
openWithHandler = vi.fn().mockResolvedValue(undefined);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function getAccess() {
|
|
return createAttachmentAccess({
|
|
sessionAccessRegistry: registry,
|
|
stagedAttachments,
|
|
stateDir,
|
|
configDir,
|
|
shell: { openPath, openExternal, showItemInFolder },
|
|
openWith: {
|
|
platform: 'darwin',
|
|
list: listOpenHandlers,
|
|
open: openWithHandler,
|
|
},
|
|
});
|
|
}
|
|
|
|
function ref(uri: string, extra: Record<string, unknown> = {}) {
|
|
return { sessionKey, generation: 1, uri, ...extra };
|
|
}
|
|
|
|
it('resolves workspace files only through the exact registered session and generation', async () => {
|
|
const access = getAccess();
|
|
const workspaceFile = join(workspaceRoot, 'notes.txt');
|
|
|
|
await expect(access.resolveAttachment({ ref: ref(workspaceFile) })).resolves.toMatchObject({
|
|
ok: true,
|
|
displayName: 'notes.txt',
|
|
target: { kind: 'local', scope: 'workspace', ref: ref(workspaceFile) },
|
|
});
|
|
await expect(access.resolveAttachment({ ref: { ...ref(workspaceFile), sessionKey: 'agent:main:other' } }))
|
|
.resolves.toMatchObject({ ok: false, error: 'staleSession' });
|
|
await expect(access.resolveAttachment({ ref: { ...ref(workspaceFile), generation: 2 } }))
|
|
.resolves.toMatchObject({ ok: false, error: 'staleSession' });
|
|
await expect(access.resolveAttachment({
|
|
ref: ref(join(outsideDir, 'secret.txt')),
|
|
workspaceRoot: outsideDir,
|
|
} as never)).resolves.toMatchObject({ ok: true });
|
|
});
|
|
|
|
it('resolves OpenClaw media roots and files outside declared roots', async () => {
|
|
const access = getAccess();
|
|
|
|
await expect(access.resolveAttachment({ ref: ref(join(stateDir, 'media', 'state.png')) }))
|
|
.resolves.toMatchObject({ ok: true, target: { kind: 'local', scope: 'openclaw-media' } });
|
|
await expect(access.resolveAttachment({ ref: ref(join(configDir, 'media', 'config.png')) }))
|
|
.resolves.toMatchObject({ ok: true, target: { kind: 'local', scope: 'openclaw-media' } });
|
|
for (const allowed of [
|
|
join(stateDir, 'openclaw.json'),
|
|
join(configDir, 'openclaw.json'),
|
|
join(stateDir, 'agents', 'main', 'sessions', 'secret.jsonl'),
|
|
join(stateDir, 'media', 'outgoing', 'records', 'leak.json'),
|
|
join(externalMediaRoot, 'runtime.png'),
|
|
]) {
|
|
await expect(access.resolveAttachment({ ref: ref(allowed) }))
|
|
.resolves.toMatchObject({ ok: true });
|
|
}
|
|
});
|
|
|
|
it('resolves workspace root across rename and symlink replacement', async () => {
|
|
const originalWorkspace = join(testDir, 'workspace-original');
|
|
await rename(workspaceRoot, originalWorkspace);
|
|
await symlink(outsideDir, workspaceRoot);
|
|
|
|
await expect(getAccess().resolveAttachment({ ref: ref(join(workspaceRoot, 'secret.txt')) }))
|
|
.resolves.toMatchObject({ ok: true });
|
|
});
|
|
|
|
it.each(['state-external', 'state-sibling', 'config-external', 'config-sibling'])(
|
|
'resolves files through a symlinked managed media root targeting %s',
|
|
async (scenario) => {
|
|
const isState = scenario.startsWith('state');
|
|
const parent = isState ? stateDir : configDir;
|
|
const mediaPath = join(parent, 'media');
|
|
const sibling = join(parent, 'sensitive');
|
|
const target = scenario.endsWith('sibling') ? sibling : externalMediaRoot;
|
|
await rm(mediaPath, { recursive: true, force: true });
|
|
if (scenario.endsWith('sibling')) {
|
|
await mkdir(sibling);
|
|
await writeFile(join(sibling, 'secret.txt'), 'sibling secret');
|
|
}
|
|
await symlink(target, mediaPath);
|
|
|
|
const targetFile = join(target, scenario.endsWith('sibling') ? 'secret.txt' : 'runtime.png');
|
|
await expect(getAccess().resolveAttachment({ ref: ref(targetFile) }))
|
|
.resolves.toMatchObject({ ok: true });
|
|
},
|
|
);
|
|
|
|
it.each(['state', 'config'])('resolves through renamed and symlinked %s parent', async (kind) => {
|
|
const parent = kind === 'state' ? stateDir : configDir;
|
|
const originalParent = `${parent}-original`;
|
|
const replacementParent = join(testDir, `${kind}-replacement`);
|
|
const replacementMedia = join(replacementParent, 'media');
|
|
const attachmentId = `${kind}-parent-swap`;
|
|
const gatewayUrl = `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`;
|
|
const initialMediaPath = join(parent, 'media', `${kind}.png`);
|
|
const replacementMediaPath = join(replacementMedia, `${kind}.png`);
|
|
await writeFile(initialMediaPath, `${kind} initial`);
|
|
|
|
const recordPath = join(stateDir, 'media', 'outgoing', 'records', `${attachmentId}.json`);
|
|
await writeFile(recordPath, JSON.stringify({
|
|
attachmentId,
|
|
sessionKey,
|
|
original: { path: initialMediaPath, contentType: 'image/png' },
|
|
}));
|
|
const access = getAccess();
|
|
|
|
await expect(access.resolveAttachment({ ref: ref(initialMediaPath) }))
|
|
.resolves.toMatchObject({ ok: true });
|
|
await expect(access.resolveAttachment({ ref: ref(gatewayUrl) }))
|
|
.resolves.toMatchObject({ ok: true });
|
|
|
|
await mkdir(replacementMedia, { recursive: true });
|
|
await writeFile(replacementMediaPath, `${kind} replacement`);
|
|
if (kind === 'state') {
|
|
await mkdir(join(replacementMedia, 'outgoing', 'records'), { recursive: true });
|
|
await writeFile(
|
|
join(replacementMedia, 'outgoing', 'records', `${attachmentId}.json`),
|
|
JSON.stringify({
|
|
attachmentId,
|
|
sessionKey,
|
|
original: { path: replacementMediaPath, contentType: 'image/png' },
|
|
}),
|
|
);
|
|
}
|
|
await rename(parent, originalParent);
|
|
await symlink(replacementParent, parent);
|
|
|
|
await expect(access.resolveAttachment({ ref: ref(join(parent, 'media', `${kind}.png`)) }))
|
|
.resolves.toMatchObject({ ok: true });
|
|
await expect(access.resolveAttachment({ ref: ref(gatewayUrl) }))
|
|
.resolves.toMatchObject({ ok: false });
|
|
});
|
|
|
|
it('requires a Main-owned staging id and matching staged path', async () => {
|
|
const stagingDir = join(stateDir, 'media', 'outbound', 'clawx-staging');
|
|
const stagedPath = join(stagingDir, 'owned.txt');
|
|
const previousRunPath = join(stagingDir, 'previous-run.txt');
|
|
await mkdir(stagingDir, { recursive: true });
|
|
await writeFile(stagedPath, 'owned');
|
|
await writeFile(previousRunPath, 'unregistered');
|
|
stagedAttachments.register('stage-1', await realpath(stagedPath), '/Users/test/Documents/owned.txt');
|
|
const access = getAccess();
|
|
|
|
await expect(access.resolveAttachment({ ref: ref(stagedPath) }))
|
|
.resolves.toMatchObject({ ok: true });
|
|
await expect(access.resolveAttachment({ ref: ref(previousRunPath) }))
|
|
.resolves.toMatchObject({ ok: true });
|
|
await expect(access.resolveAttachment({ ref: ref(stagedPath, { stagingId: 'stage-1' }) }))
|
|
.resolves.toMatchObject({
|
|
ok: true,
|
|
displayPath: '/Users/test/Documents/owned.txt',
|
|
target: { kind: 'local', scope: 'staging' },
|
|
});
|
|
await expect(access.resolveAttachment({
|
|
ref: ref(join(outsideDir, 'secret.txt'), { stagingId: 'stage-1' }),
|
|
})).resolves.toMatchObject({ ok: false, error: 'invalidReference' });
|
|
});
|
|
|
|
it('resolves and system-opens directories while keeping file-only operations closed', async () => {
|
|
const directoryPath = join(workspaceRoot, 'invoices');
|
|
await mkdir(directoryPath);
|
|
const canonicalPath = await realpath(directoryPath);
|
|
stagedAttachments.register('stage-directory', canonicalPath, '/Users/test/Documents/invoices');
|
|
const directoryRef = ref(directoryPath, { stagingId: 'stage-directory' });
|
|
const access = getAccess();
|
|
|
|
await expect(access.resolveAttachment({
|
|
ref: directoryRef,
|
|
mimeType: 'text/plain',
|
|
size: 999,
|
|
})).resolves.toMatchObject({
|
|
ok: true,
|
|
displayName: 'invoices',
|
|
displayPath: '/Users/test/Documents/invoices',
|
|
mimeType: 'application/x-directory',
|
|
size: 0,
|
|
target: { kind: 'local', scope: 'staging', entryKind: 'directory', ref: directoryRef },
|
|
});
|
|
await expect(access.openAttachment(directoryRef)).resolves.toEqual({ ok: true });
|
|
expect(openPath).toHaveBeenCalledWith(canonicalPath);
|
|
|
|
await expect(access.readAttachmentText(directoryRef)).resolves.toEqual({ ok: false, error: 'notFile' });
|
|
await expect(access.readAttachmentBinary({ ref: directoryRef })).resolves.toEqual({
|
|
ok: false,
|
|
error: 'notFile',
|
|
});
|
|
await expect(access.listAttachmentOpenHandlers(directoryRef)).resolves.toEqual({
|
|
ok: false,
|
|
error: 'notFile',
|
|
});
|
|
await expect(access.openAttachmentWith({
|
|
ref: directoryRef,
|
|
handlerId: 'com.apple.Finder',
|
|
})).resolves.toEqual({ ok: false, error: 'notFile' });
|
|
await expect(access.revealAttachment(directoryRef)).resolves.toEqual({ ok: false, error: 'notFile' });
|
|
expect(listOpenHandlers).not.toHaveBeenCalled();
|
|
expect(openWithHandler).not.toHaveBeenCalled();
|
|
expect(showItemInFolder).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('falls back to regular resolution when stagingId is unknown after restart', async () => {
|
|
const stagingDir = join(stateDir, 'media', 'outbound', 'clawx-staging');
|
|
const orphanedPath = join(stagingDir, 'orphaned-from-prev-run.txt');
|
|
await mkdir(stagingDir, { recursive: true });
|
|
await writeFile(orphanedPath, 'persisted on disk');
|
|
const access = getAccess();
|
|
|
|
await expect(access.resolveAttachment({ ref: ref(orphanedPath, { stagingId: 'unknown-id' }) }))
|
|
.resolves.toMatchObject({ ok: true, displayName: 'orphaned-from-prev-run.txt' });
|
|
});
|
|
|
|
it('binds outgoing records to attachment, URL session, record session, and message ids', async () => {
|
|
const attachmentId = 'generated-1';
|
|
const originalPath = join(stateDir, 'media', 'state.png');
|
|
const recordPath = join(stateDir, 'media', 'outgoing', 'records', `${attachmentId}.json`);
|
|
const gatewayUrl = `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`;
|
|
const writeRecord = (record: Record<string, unknown>) => writeFile(recordPath, JSON.stringify(record));
|
|
const access = getAccess();
|
|
|
|
await writeRecord({
|
|
attachmentId,
|
|
sessionKey,
|
|
messageId: 'message-1',
|
|
original: { path: originalPath, contentType: 'image/png' },
|
|
});
|
|
await expect(access.resolveAttachment({
|
|
ref: ref(gatewayUrl, { transcriptMessageId: 'message-1' }),
|
|
})).resolves.toMatchObject({ ok: true, mimeType: 'image/png', target: { scope: 'openclaw-media' } });
|
|
|
|
const rejected = [
|
|
`/api/chat/media/outgoing/${encodeURIComponent('agent:main:other')}/${attachmentId}/full`,
|
|
`/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/different-id/full`,
|
|
];
|
|
for (const uri of rejected) {
|
|
await expect(access.resolveAttachment({ ref: ref(uri, { transcriptMessageId: 'message-1' }) }))
|
|
.resolves.toMatchObject({ ok: false });
|
|
}
|
|
await expect(access.resolveAttachment({
|
|
ref: ref(gatewayUrl, { transcriptMessageId: 'message-2' }),
|
|
})).resolves.toMatchObject({ ok: false, error: 'invalidReference' });
|
|
|
|
await writeRecord({
|
|
attachmentId,
|
|
sessionKey: 'agent:main:other',
|
|
original: { path: originalPath, contentType: 'image/png' },
|
|
});
|
|
await expect(access.resolveAttachment({ ref: ref(gatewayUrl) }))
|
|
.resolves.toMatchObject({ ok: false, error: 'invalidReference' });
|
|
});
|
|
|
|
it('treats global as a literal outgoing session rather than a wildcard', async () => {
|
|
const attachmentId = 'global-image';
|
|
const recordPath = join(stateDir, 'media', 'outgoing', 'records', `${attachmentId}.json`);
|
|
await writeFile(recordPath, JSON.stringify({
|
|
attachmentId,
|
|
sessionKey: 'global',
|
|
original: { path: join(stateDir, 'media', 'state.png') },
|
|
}));
|
|
const access = getAccess();
|
|
const globalUrl = `/api/chat/media/outgoing/global/${attachmentId}/full`;
|
|
|
|
await expect(access.resolveAttachment({ ref: ref(globalUrl) }))
|
|
.resolves.toMatchObject({ ok: false, error: 'invalidReference' });
|
|
|
|
registry.commitGrant(await registry.prepareGrant({
|
|
sessionKey: 'global',
|
|
generation: 2,
|
|
workspaceRoot,
|
|
executionCwd: workspaceRoot,
|
|
}));
|
|
await expect(access.resolveAttachment({
|
|
ref: { sessionKey: 'global', generation: 2, uri: globalUrl },
|
|
})).resolves.toMatchObject({ ok: true });
|
|
});
|
|
|
|
it.each([
|
|
['missing file', () => join(workspaceRoot, 'missing.txt'), 'unavailable'],
|
|
['traversal', () => `${workspaceRoot}/nested/../notes.txt`, 'invalidReference'],
|
|
['encoded traversal', () => `file://${workspaceRoot}/nested/%2e%2e/notes.txt`, 'invalidReference'],
|
|
['encoded NUL', () => `file://${workspaceRoot}/notes.txt%00`, 'invalidReference'],
|
|
['remote file authority', () => 'file://evil.example/etc/passwd', 'invalidReference'],
|
|
['UNC path', () => '\\\\server\\share\\file.txt', 'invalidReference'],
|
|
['URL credentials', () => 'https://user:pass@example.com/file.txt', 'unsafeUrl'],
|
|
['unknown scheme', () => 'ftp://example.com/file.txt', 'invalidReference'],
|
|
['overlong reference', () => `https://example.com/${'a'.repeat(4096)}`, 'invalidReference'],
|
|
])('fails closed for %s', async (_label, makeUri, error) => {
|
|
await expect(getAccess().resolveAttachment({ ref: ref(makeUri()) }))
|
|
.resolves.toMatchObject({ ok: false, error });
|
|
});
|
|
|
|
it('resolves files through symlink escapes', async () => {
|
|
await symlink(join(outsideDir, 'secret.txt'), join(workspaceRoot, 'escape.txt'));
|
|
|
|
await expect(getAccess().resolveAttachment({ ref: ref(join(workspaceRoot, 'escape.txt')) }))
|
|
.resolves.toMatchObject({ ok: true });
|
|
});
|
|
|
|
it('normalizes safe remote URLs without granting unsafe variants', async () => {
|
|
const access = getAccess();
|
|
const result = await access.resolveAttachment({
|
|
ref: ref('HTTPS://Example.COM:443/assets/report%20final.pdf?x=1'),
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
displayName: 'report final.pdf',
|
|
target: {
|
|
kind: 'remote',
|
|
url: 'https://example.com/assets/report%20final.pdf?x=1',
|
|
},
|
|
});
|
|
expect(result.ok && result.identity).toMatch(/^[a-f0-9]{64}$/);
|
|
expect(JSON.stringify(result)).not.toContain(workspaceRoot);
|
|
});
|
|
|
|
it('re-resolves text, binary, and open operations after prior success', async () => {
|
|
const access = getAccess();
|
|
const textRef = ref(join(workspaceRoot, 'notes.txt'));
|
|
const binaryRef = ref(join(workspaceRoot, 'binary.bin'));
|
|
await expect(access.resolveAttachment({ ref: textRef })).resolves.toMatchObject({ ok: true });
|
|
await expect(access.resolveAttachment({ ref: binaryRef })).resolves.toMatchObject({ ok: true });
|
|
|
|
registry.commitGrant(await registry.prepareGrant({
|
|
sessionKey,
|
|
generation: 2,
|
|
workspaceRoot,
|
|
executionCwd: workspaceRoot,
|
|
}));
|
|
|
|
await expect(access.readAttachmentText(textRef)).resolves.toEqual({ ok: false, error: 'staleSession' });
|
|
await expect(access.readAttachmentBinary({ ref: binaryRef })).resolves.toEqual({ ok: false, error: 'staleSession' });
|
|
await expect(access.openAttachment(textRef)).resolves.toEqual({ ok: false, error: 'staleSession' });
|
|
expect(openPath).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('reads bounded text and binary data through attachment refs', async () => {
|
|
const access = getAccess();
|
|
|
|
await expect(access.readAttachmentText(ref(join(workspaceRoot, 'notes.txt')))).resolves.toMatchObject({
|
|
ok: true,
|
|
content: 'workspace notes',
|
|
size: 15,
|
|
});
|
|
await expect(access.readAttachmentBinary({ ref: ref(join(workspaceRoot, 'binary.bin')), maxBytes: 3 }))
|
|
.resolves.toMatchObject({ ok: true, data: new Uint8Array([1, 2, 3]), size: 3 });
|
|
await expect(access.readAttachmentBinary({ ref: ref(join(workspaceRoot, 'binary.bin')), maxBytes: 2 }))
|
|
.resolves.toMatchObject({ ok: false, error: 'tooLarge', size: 3 });
|
|
});
|
|
|
|
it('delegates validated local and remote opens to the correct shell operation', async () => {
|
|
const access = getAccess();
|
|
const localPath = join(workspaceRoot, 'notes.txt');
|
|
const remoteUrl = 'https://example.com/download/report.pdf';
|
|
|
|
await expect(access.openAttachment(ref(localPath))).resolves.toEqual({ ok: true });
|
|
await expect(access.openAttachment(ref(remoteUrl))).resolves.toEqual({ ok: true });
|
|
expect(openPath).toHaveBeenCalledWith(await realpath(localPath));
|
|
expect(openExternal).toHaveBeenCalledWith(remoteUrl);
|
|
const trace = getAcpTraceSnapshot().entries;
|
|
expect(trace).toHaveLength(2);
|
|
expect(trace.every((entry) => entry.event === 'attachment/open:success')).toBe(true);
|
|
expect(trace.every((entry) => (
|
|
Object.keys(entry.details as object).sort().join(',') === 'identity,reason,sourceKind'
|
|
))).toBe(true);
|
|
expect(JSON.stringify(trace)).not.toContain(localPath);
|
|
expect(JSON.stringify(trace)).not.toContain(remoteUrl);
|
|
});
|
|
|
|
it('lists only safe public handler metadata for a validated local ref', async () => {
|
|
const localPath = await realpath(join(workspaceRoot, 'notes.txt'));
|
|
listOpenHandlers.mockResolvedValueOnce([{
|
|
id: 'com.apple.Preview',
|
|
name: 'Preview',
|
|
iconDataUrl: 'data:image/png;base64,c2FmZQ==',
|
|
isDefault: true,
|
|
applicationPath: '/Applications/Private.app',
|
|
commandLine: 'private --command',
|
|
}]);
|
|
|
|
await expect(getAccess().listAttachmentOpenHandlers(ref(localPath))).resolves.toEqual({
|
|
ok: true,
|
|
platform: 'darwin',
|
|
handlers: [{
|
|
handlerId: 'com.apple.Preview',
|
|
name: 'Preview',
|
|
iconDataUrl: 'data:image/png;base64,c2FmZQ==',
|
|
isDefault: true,
|
|
}],
|
|
});
|
|
expect(listOpenHandlers).toHaveBeenCalledWith(localPath);
|
|
});
|
|
|
|
it('returns a successful empty Linux list without invoking discovery', async () => {
|
|
const localPath = join(workspaceRoot, 'notes.txt');
|
|
const access = createAttachmentAccess({
|
|
sessionAccessRegistry: registry,
|
|
stagedAttachments,
|
|
stateDir,
|
|
configDir,
|
|
shell: { openPath, openExternal, showItemInFolder },
|
|
openWith: {
|
|
platform: 'linux',
|
|
list: listOpenHandlers,
|
|
open: openWithHandler,
|
|
},
|
|
});
|
|
|
|
await expect(access.listAttachmentOpenHandlers(ref(localPath))).resolves.toEqual({
|
|
ok: true,
|
|
platform: 'linux',
|
|
handlers: [],
|
|
});
|
|
expect(listOpenHandlers).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('preserves normalized empty discovery and unexpected failure result semantics', async () => {
|
|
const localRef = ref(join(workspaceRoot, 'notes.txt'));
|
|
const access = getAccess();
|
|
|
|
await expect(access.listAttachmentOpenHandlers(localRef)).resolves.toEqual({
|
|
ok: true,
|
|
platform: 'darwin',
|
|
handlers: [],
|
|
});
|
|
listOpenHandlers.mockRejectedValueOnce(new Error('unexpected platform service failure'));
|
|
await expect(access.listAttachmentOpenHandlers(localRef)).resolves.toEqual({
|
|
ok: false,
|
|
error: 'operationFailed',
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
['remote', () => ref('https://example.com/report.pdf'), 'invalidReference'],
|
|
['stale session', () => ({ ...ref(join(workspaceRoot, 'notes.txt')), generation: 2 }), 'staleSession'],
|
|
['missing file', () => ref(join(workspaceRoot, 'missing.txt')), 'unavailable'],
|
|
['non-file', () => ref(workspaceRoot), 'notFile'],
|
|
])('rejects %s refs for list, selected open, and reveal', async (_label, makeRef, error) => {
|
|
const access = getAccess();
|
|
const attachmentRef = makeRef();
|
|
|
|
await expect(access.listAttachmentOpenHandlers(attachmentRef)).resolves.toEqual({ ok: false, error });
|
|
await expect(access.openAttachmentWith({
|
|
ref: attachmentRef,
|
|
handlerId: 'com.apple.Preview',
|
|
})).resolves.toEqual({ ok: false, error });
|
|
await expect(access.revealAttachment(attachmentRef)).resolves.toEqual({ ok: false, error });
|
|
expect(listOpenHandlers).not.toHaveBeenCalled();
|
|
expect(openWithHandler).not.toHaveBeenCalled();
|
|
expect(showItemInFolder).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('allows listed and selected-handler opens outside the workspace after exact ref validation', async () => {
|
|
const outsidePath = await realpath(join(outsideDir, 'secret.txt'));
|
|
listOpenHandlers.mockResolvedValueOnce([{
|
|
id: 'com.apple.TextEdit',
|
|
name: 'TextEdit',
|
|
isDefault: true,
|
|
}]);
|
|
openWithHandler.mockImplementationOnce(async (_path, _handlerId, revalidateFile) => {
|
|
await expect(revalidateFile()).resolves.toBe(outsidePath);
|
|
});
|
|
const access = getAccess();
|
|
|
|
await expect(access.listAttachmentOpenHandlers(ref(outsidePath))).resolves.toMatchObject({ ok: true });
|
|
await expect(access.openAttachmentWith({
|
|
ref: ref(outsidePath),
|
|
handlerId: 'com.apple.TextEdit',
|
|
})).resolves.toEqual({ ok: true });
|
|
expect(listOpenHandlers).toHaveBeenCalledWith(outsidePath);
|
|
expect(openWithHandler).toHaveBeenCalledWith(
|
|
outsidePath,
|
|
'com.apple.TextEdit',
|
|
expect.any(Function),
|
|
);
|
|
});
|
|
|
|
it('delegates forged handler identity only to the platform membership check', async () => {
|
|
const localPath = await realpath(join(workspaceRoot, 'notes.txt'));
|
|
openWithHandler.mockRejectedValueOnce(new Error('attachment-open-with:unknown-handler'));
|
|
|
|
await expect(getAccess().openAttachmentWith({
|
|
ref: ref(localPath),
|
|
handlerId: 'com.attacker.Forged',
|
|
})).resolves.toEqual({ ok: false, error: 'operationFailed' });
|
|
expect(openWithHandler).toHaveBeenCalledWith(
|
|
localPath,
|
|
'com.attacker.Forged',
|
|
expect.any(Function),
|
|
);
|
|
});
|
|
|
|
it.each(['', 'x'.repeat(513)])('rejects invalid handler id %j before platform access', async (handlerId) => {
|
|
await expect(getAccess().openAttachmentWith({
|
|
ref: ref(join(workspaceRoot, 'notes.txt')),
|
|
handlerId,
|
|
})).resolves.toEqual({ ok: false, error: 'invalidReference' });
|
|
expect(openWithHandler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('re-resolves the original ref after platform handler readiness', async () => {
|
|
const localPath = await realpath(join(workspaceRoot, 'notes.txt'));
|
|
let initialResolutionComplete = false;
|
|
openWithHandler.mockImplementationOnce(async (initialPath, handlerId, revalidateFile) => {
|
|
expect(initialPath).toBe(localPath);
|
|
expect(handlerId).toBe('com.apple.Preview');
|
|
initialResolutionComplete = true;
|
|
await expect(revalidateFile()).resolves.toBe(localPath);
|
|
});
|
|
|
|
await expect(getAccess().openAttachmentWith({
|
|
ref: ref(localPath),
|
|
handlerId: 'com.apple.Preview',
|
|
})).resolves.toEqual({ ok: true });
|
|
expect(initialResolutionComplete).toBe(true);
|
|
});
|
|
|
|
it('rejects generation invalidation during delayed fresh handler enumeration', async () => {
|
|
const localPath = await realpath(join(workspaceRoot, 'notes.txt'));
|
|
let releaseEnumeration!: () => void;
|
|
let signalEnumeration!: () => void;
|
|
const enumerationReached = new Promise<void>((resolveSignal) => {
|
|
signalEnumeration = resolveSignal;
|
|
});
|
|
const enumerationRelease = new Promise<void>((resolveRelease) => {
|
|
releaseEnumeration = resolveRelease;
|
|
});
|
|
const nativeInvoke = vi.fn();
|
|
openWithHandler.mockImplementationOnce(async (_path, _handlerId, revalidateFile) => {
|
|
signalEnumeration();
|
|
await enumerationRelease;
|
|
await revalidateFile();
|
|
nativeInvoke();
|
|
});
|
|
const opening = getAccess().openAttachmentWith({
|
|
ref: ref(localPath),
|
|
handlerId: 'com.apple.Preview',
|
|
});
|
|
await enumerationReached;
|
|
registry.commitGrant(await registry.prepareGrant({
|
|
sessionKey,
|
|
generation: 2,
|
|
workspaceRoot,
|
|
executionCwd: workspaceRoot,
|
|
}));
|
|
releaseEnumeration();
|
|
|
|
await expect(opening).resolves.toEqual({ ok: false, error: 'staleSession' });
|
|
expect(nativeInvoke).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('re-resolves immediately before revealing and passes only the canonical path', async () => {
|
|
const localPath = await realpath(join(workspaceRoot, 'notes.txt'));
|
|
|
|
await expect(getAccess().revealAttachment(ref(join(workspaceRoot, 'notes.txt'))))
|
|
.resolves.toEqual({ ok: true });
|
|
expect(showItemInFolder).toHaveBeenCalledWith(localPath);
|
|
});
|
|
|
|
it('rejects generation invalidation during final reveal validation', async () => {
|
|
const localPath = await realpath(join(workspaceRoot, 'notes.txt'));
|
|
let targetStatCount = 0;
|
|
let releaseFinalValidation!: () => void;
|
|
let signalFinalValidation!: () => void;
|
|
const finalValidationReached = new Promise<void>((resolveSignal) => {
|
|
signalFinalValidation = resolveSignal;
|
|
});
|
|
const finalValidationRelease = new Promise<void>((resolveRelease) => {
|
|
releaseFinalValidation = resolveRelease;
|
|
});
|
|
const access = createAttachmentAccess({
|
|
sessionAccessRegistry: registry,
|
|
stagedAttachments,
|
|
stateDir,
|
|
configDir,
|
|
shell: { openPath, openExternal, showItemInFolder },
|
|
openWith: {
|
|
platform: 'darwin',
|
|
list: listOpenHandlers,
|
|
open: openWithHandler,
|
|
},
|
|
fs: {
|
|
lstat,
|
|
open,
|
|
realpath,
|
|
stat: async (path) => {
|
|
const result = await stat(path);
|
|
if (path === localPath && ++targetStatCount === 2) {
|
|
signalFinalValidation();
|
|
await finalValidationRelease;
|
|
}
|
|
return result;
|
|
},
|
|
},
|
|
});
|
|
|
|
const revealing = access.revealAttachment(ref(localPath));
|
|
await finalValidationReached;
|
|
registry.commitGrant(await registry.prepareGrant({
|
|
sessionKey,
|
|
generation: 2,
|
|
workspaceRoot,
|
|
executionCwd: workspaceRoot,
|
|
}));
|
|
releaseFinalValidation();
|
|
|
|
await expect(revealing).resolves.toEqual({ ok: false, error: 'staleSession' });
|
|
expect(showItemInFolder).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not place open-with or reveal sensitive values in diagnostics', async () => {
|
|
const sentinelPath = join(outsideDir, 'sentinel-canonical-file.txt');
|
|
const sentinels = [
|
|
sentinelPath,
|
|
'/Applications/Sentinel Bundle.app',
|
|
'/private/sentinel-icon-source.icns',
|
|
'sentinel-command --secret',
|
|
'data:image/png;base64,SENTINEL_ICON_DATA',
|
|
];
|
|
await writeFile(sentinelPath, 'sentinel');
|
|
listOpenHandlers.mockResolvedValueOnce([{
|
|
id: 'com.example.Sentinel',
|
|
name: 'Sentinel',
|
|
iconDataUrl: sentinels[4],
|
|
isDefault: true,
|
|
applicationPath: sentinels[1],
|
|
bundlePath: sentinels[1],
|
|
iconSourcePath: sentinels[2],
|
|
commandLine: sentinels[3],
|
|
}]);
|
|
openWithHandler.mockImplementationOnce(async (_path, _handlerId, revalidateFile) => {
|
|
await revalidateFile();
|
|
});
|
|
const consoleSpies = ['debug', 'error', 'info', 'log', 'warn'].map((method) => (
|
|
vi.spyOn(console, method as 'log').mockImplementation(() => undefined)
|
|
));
|
|
const access = getAccess();
|
|
|
|
try {
|
|
await access.listAttachmentOpenHandlers(ref(sentinelPath));
|
|
await access.openAttachmentWith({ ref: ref(sentinelPath), handlerId: 'com.example.Sentinel' });
|
|
await access.revealAttachment(ref(sentinelPath));
|
|
|
|
const diagnostics = JSON.stringify({
|
|
trace: getAcpTraceSnapshot(),
|
|
logs: consoleSpies.flatMap((spy) => spy.mock.calls),
|
|
});
|
|
for (const sentinel of sentinels) expect(diagnostics).not.toContain(sentinel);
|
|
} finally {
|
|
consoleSpies.forEach((spy) => spy.mockRestore());
|
|
}
|
|
});
|
|
|
|
it('rechecks generation after final local validation before shell.openPath', async () => {
|
|
const localPath = await realpath(join(workspaceRoot, 'notes.txt'));
|
|
let targetStatCount = 0;
|
|
let releaseFinalValidation!: () => void;
|
|
let signalFinalValidation!: () => void;
|
|
const finalValidationReached = new Promise<void>((resolveSignal) => {
|
|
signalFinalValidation = resolveSignal;
|
|
});
|
|
const finalValidationRelease = new Promise<void>((resolveRelease) => {
|
|
releaseFinalValidation = resolveRelease;
|
|
});
|
|
const access = createAttachmentAccess({
|
|
sessionAccessRegistry: registry,
|
|
stagedAttachments,
|
|
stateDir,
|
|
configDir,
|
|
shell: { openPath, openExternal, showItemInFolder },
|
|
openWith: {
|
|
platform: 'darwin',
|
|
list: listOpenHandlers,
|
|
open: openWithHandler,
|
|
},
|
|
fs: {
|
|
lstat,
|
|
open,
|
|
realpath,
|
|
stat: async (path) => {
|
|
const result = await stat(path);
|
|
if (path === localPath && ++targetStatCount === 2) {
|
|
signalFinalValidation();
|
|
await finalValidationRelease;
|
|
}
|
|
return result;
|
|
},
|
|
},
|
|
});
|
|
|
|
const opening = access.openAttachment(ref(localPath));
|
|
await finalValidationReached;
|
|
registry.commitGrant(await registry.prepareGrant({
|
|
sessionKey,
|
|
generation: 2,
|
|
workspaceRoot,
|
|
executionCwd: workspaceRoot,
|
|
}));
|
|
releaseFinalValidation();
|
|
|
|
await expect(opening).resolves.toEqual({ ok: false, error: 'staleSession' });
|
|
expect(openPath).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('records only bounded, redacted attachment open trace metadata', async () => {
|
|
openPath.mockResolvedValueOnce('application failed');
|
|
const localPath = join(workspaceRoot, 'notes.txt');
|
|
|
|
await expect(getAccess().openAttachment(ref(localPath))).resolves.toEqual({
|
|
ok: false,
|
|
error: 'operationFailed',
|
|
});
|
|
|
|
const entry = getAcpTraceSnapshot().entries.at(-1);
|
|
expect(entry).toMatchObject({
|
|
source: 'main',
|
|
event: 'attachment/open:failure',
|
|
sessionKey,
|
|
generation: 1,
|
|
details: {
|
|
reason: 'operationFailed',
|
|
sourceKind: 'local',
|
|
identity: expect.stringMatching(/^[a-f0-9]{64}$/),
|
|
},
|
|
});
|
|
expect(Object.keys(entry?.details as object).sort()).toEqual(['identity', 'reason', 'sourceKind']);
|
|
expect(JSON.stringify(entry)).not.toContain(localPath);
|
|
expect(JSON.stringify(entry)).not.toContain(pathToFileURL(localPath).href);
|
|
});
|
|
|
|
it('sanitizes hostile display labels to one bounded line', async () => {
|
|
const hostile = `/private/account/secrets/report\n\u202Egpj.exe\u0007 ${'x'.repeat(300)} `;
|
|
const result = await getAccess().resolveAttachment({
|
|
ref: ref(join(workspaceRoot, 'notes.txt')),
|
|
name: hostile,
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(Array.from(result.displayName).some((character) => {
|
|
const codePoint = character.codePointAt(0) ?? 0;
|
|
return codePoint <= 0x1f
|
|
|| (codePoint >= 0x7f && codePoint <= 0x9f)
|
|
|| (codePoint >= 0x202a && codePoint <= 0x202e);
|
|
})).toBe(false);
|
|
expect(result.displayName).not.toContain('\n');
|
|
expect(result.displayName).not.toContain('/private/account/secrets');
|
|
expect(result.displayName.length).toBeLessThanOrEqual(160);
|
|
});
|
|
});
|
|
|
|
describe('OpenClaw attachment path resolution', () => {
|
|
it('keeps state and config paths distinct and absolute', () => {
|
|
const stateDir = resolveOpenClawStateDir({ OPENCLAW_STATE_DIR: '~/custom-state' });
|
|
const configPath = resolveOpenClawConfigPath({
|
|
OPENCLAW_STATE_DIR: '~/custom-state',
|
|
OPENCLAW_CONFIG_PATH: './runtime/openclaw.json',
|
|
});
|
|
|
|
expect(stateDir).toBe(resolve(process.env.HOME!, 'custom-state'));
|
|
expect(configPath).toBe(resolve('runtime/openclaw.json'));
|
|
expect(resolveOpenClawConfigDir({ OPENCLAW_CONFIG_PATH: configPath })).toBe(resolve('runtime'));
|
|
expect(resolveOpenClawConfigPath({ OPENCLAW_STATE_DIR: stateDir })).toBe(join(stateDir, 'openclaw.json'));
|
|
});
|
|
});
|