Package the portable DSH profiles, local plugins, desktop integration, Firefox wrapper, and ownership-aware installer lifecycle. Bundle checksum-pinned OpenVSCode, editor extensions, translation assets, and cloudflared so unreliable upstream artifact downloads cannot break a clean installation.
415 lines
17 KiB
JavaScript
415 lines
17 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import { execFile } from 'node:child_process'
|
|
import { mkdtemp, mkdir, realpath, rm, symlink } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join, resolve } from 'node:path'
|
|
import { promisify } from 'node:util'
|
|
import test from 'node:test'
|
|
import { registerGitWriteGuard } from '../lib/git-write-guard.js'
|
|
|
|
const execute = promisify(execFile)
|
|
|
|
async function workspace(t) {
|
|
const root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-git-write-guard-')))
|
|
t.after(() => rm(root, { recursive: true, force: true }))
|
|
const repository = join(root, 'repository')
|
|
await mkdir(repository)
|
|
await execute('git', ['init', '--quiet', repository])
|
|
return { root, repository }
|
|
}
|
|
|
|
function createStore() {
|
|
const entries = new Map()
|
|
return {
|
|
register(sessionId, repository) {
|
|
entries.set(`${sessionId}:${repository}`, { ok: true, repository })
|
|
},
|
|
unregister(sessionId, repository) {
|
|
entries.delete(`${sessionId}:${repository}`)
|
|
},
|
|
async list(sessionId) {
|
|
return [...entries].filter(([key]) => key.startsWith(`${sessionId}:`)).map(([, value]) => value)
|
|
},
|
|
isRegistered(sessionId, repository) {
|
|
return entries.has(`${sessionId}:${repository}`)
|
|
},
|
|
}
|
|
}
|
|
|
|
function createHarness({ store = createStore(), onSuccessfulMutation } = {}) {
|
|
const hooks = new Map()
|
|
const guards = []
|
|
const resolved = []
|
|
const context = {
|
|
fs: {
|
|
async resolve(filePath, options) {
|
|
resolved.push({ filePath, options })
|
|
return { path: resolve(options.cwd, filePath) }
|
|
},
|
|
async processPath(target) {
|
|
return target.path
|
|
},
|
|
},
|
|
on(name, callback, prepend = false) {
|
|
const listeners = hooks.get(name) ?? []
|
|
hooks.set(name, listeners)
|
|
if (prepend === true || prepend.prepend) listeners.unshift(callback)
|
|
else listeners.push(callback)
|
|
return () => {
|
|
const index = listeners.indexOf(callback)
|
|
if (index >= 0) listeners.splice(index, 1)
|
|
}
|
|
},
|
|
tools: {
|
|
guard(callback) {
|
|
guards.push(callback)
|
|
return () => {
|
|
const index = guards.indexOf(callback)
|
|
if (index >= 0) guards.splice(index, 1)
|
|
}
|
|
},
|
|
},
|
|
}
|
|
const dispose = registerGitWriteGuard(context, { store, onSuccessfulMutation })
|
|
|
|
function waterfall(name, args, final) {
|
|
const callbacks = [...hooks.get(name) ?? []]
|
|
const next = () => (callbacks.shift() ?? final)(...args, next)
|
|
return next()
|
|
}
|
|
|
|
function denial(execution) {
|
|
return guards.map(guard => guard(execution)).find(reason => reason !== undefined)
|
|
}
|
|
|
|
function result(execution, outcome) {
|
|
for (const callback of hooks.get('tools/result') ?? []) callback(execution, outcome)
|
|
}
|
|
|
|
return {
|
|
context, store, hooks, guards, resolved, dispose, denial, result,
|
|
pre: (execution, decision = { kind: 'allow' }) => waterfall('tools/pre-execute', [execution], () => decision),
|
|
intent: (name, target, execution, final = () => undefined) => waterfall(name, [target, execution], final),
|
|
post: (execution, outcome, decision = { kind: 'accept' }) => waterfall('tools/post-execute', [execution, outcome], () => decision),
|
|
async dispatch(execution, body) {
|
|
try {
|
|
const decision = await this.pre(execution)
|
|
const reason = decision.kind === 'deny' ? decision.reason : denial(execution)
|
|
if (reason !== undefined) return { isError: true, error: { message: reason }, content: [{ type: 'text', text: reason }] }
|
|
return await body()
|
|
} catch (error) {
|
|
return { isError: true, error: { message: error.message }, content: [{ type: 'text', text: error.message }] }
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
function execution(cwd, name = 'write', filePath = 'new.txt') {
|
|
return {
|
|
token: Symbol(),
|
|
callId: 'call-1',
|
|
name,
|
|
arguments: { file_path: filePath },
|
|
signal: new AbortController().signal,
|
|
agent: { session: { header: { id: 'session-1', cwd } } },
|
|
}
|
|
}
|
|
|
|
function success() {
|
|
return { isError: false, value: { path: 'new.txt', before: null, after: 'content' }, content: [{ type: 'text', text: 'Saved' }] }
|
|
}
|
|
|
|
test('parent traversal uses the physical session cwd like the filesystem tools', async t => {
|
|
const { root, repository } = await workspace(t)
|
|
const inside = join(repository, 'inside')
|
|
const aliases = join(root, 'aliases')
|
|
await mkdir(inside)
|
|
await mkdir(aliases)
|
|
const alias = join(aliases, 'cwd')
|
|
await symlink(inside, alias)
|
|
const harness = createHarness()
|
|
t.after(harness.dispose)
|
|
harness.store.register('session-1', repository)
|
|
const call = execution(alias, 'write', '../new.txt')
|
|
await harness.pre(call)
|
|
assert.equal(harness.denial(call), undefined)
|
|
assert.equal(harness.resolved[0].options.cwd, inside)
|
|
})
|
|
|
|
for (const name of ['write', 'edit']) {
|
|
test(`${name} blocks an unregistered repository before dispatch`, async t => {
|
|
const { repository } = await workspace(t)
|
|
const harness = createHarness()
|
|
t.after(harness.dispose)
|
|
let calls = 0
|
|
const outcome = await harness.dispatch(execution(repository, name), () => { calls++; return success() })
|
|
assert.equal(calls, 0)
|
|
assert.equal(outcome.isError, true)
|
|
assert.match(outcome.error.message, /not registered/)
|
|
})
|
|
|
|
test(`${name} allows a registered current-session repository`, async t => {
|
|
const { repository } = await workspace(t)
|
|
const harness = createHarness()
|
|
t.after(harness.dispose)
|
|
harness.store.register('session-1', repository)
|
|
let calls = 0
|
|
const outcome = await harness.dispatch(execution(repository, name), () => { calls++; return success() })
|
|
assert.equal(calls, 1)
|
|
assert.equal(outcome.isError, false)
|
|
})
|
|
|
|
test(`${name} outside Git appends a warning and awaits successful reporting`, async t => {
|
|
const { root } = await workspace(t)
|
|
let release
|
|
const persisted = new Promise(resolve => { release = resolve })
|
|
let mutation
|
|
let reportStarted
|
|
const started = new Promise(resolve => { reportStarted = resolve })
|
|
const harness = createHarness({ onSuccessfulMutation: async value => { mutation = value; reportStarted(); await persisted } })
|
|
t.after(harness.dispose)
|
|
const call = execution(root, name, ' file with spaces ')
|
|
const outcome = await harness.dispatch(call, success)
|
|
const promise = harness.post(call, outcome)
|
|
await started
|
|
assert.deepEqual(harness.resolved[0], { filePath: ' file with spaces ', options: { cwd: root, signal: call.signal } })
|
|
assert.equal(mutation.filePath, join(root, ' file with spaces '))
|
|
assert.equal(mutation.repository, null)
|
|
assert.equal(mutation.sessionId, 'session-1')
|
|
assert.equal(mutation.toolName, name)
|
|
release()
|
|
const decision = await promise
|
|
assert.equal(decision.kind, 'accept')
|
|
assert.equal(Object.hasOwn(decision, 'value'), false)
|
|
assert.equal(decision.content[0], outcome.content[0])
|
|
assert.match(decision.content.at(-1).text, /WARNING:.*outside a Git repository/)
|
|
})
|
|
|
|
test(`${name} intent checks the actual target and preserves downstream intent`, async t => {
|
|
const { root, repository } = await workspace(t)
|
|
const harness = createHarness()
|
|
t.after(harness.dispose)
|
|
harness.store.register('session-1', repository)
|
|
const call = execution(repository, name)
|
|
await harness.pre(call)
|
|
const expected = { version: 'observed-version' }
|
|
let observationCalls = 0
|
|
const intentName = `fs/${name}-intent`
|
|
harness.context.on(intentName, () => { observationCalls++; return expected })
|
|
assert.equal(await harness.intent(intentName, { path: join(repository, 'new.txt') }, call), expected)
|
|
assert.equal(observationCalls, 1)
|
|
|
|
const nested = join(repository, 'nested')
|
|
await mkdir(nested)
|
|
await execute('git', ['init', '--quiet', nested])
|
|
await assert.rejects(harness.intent(intentName, { path: join(nested, 'new.txt') }, call), /not registered/)
|
|
assert.equal(observationCalls, 1)
|
|
assert.notEqual(root, repository)
|
|
})
|
|
}
|
|
|
|
test('missing pre-execution certificate blocks even if another hook grants permission', async t => {
|
|
const { repository } = await workspace(t)
|
|
const harness = createHarness()
|
|
t.after(harness.dispose)
|
|
harness.store.register('session-1', repository)
|
|
harness.context.on('tools/pre-execute', () => ({ kind: 'allow' }), true)
|
|
let calls = 0
|
|
const outcome = await harness.dispatch(execution(repository), () => { calls++; return success() })
|
|
assert.equal(calls, 0)
|
|
assert.match(outcome.error.message, /did not complete/)
|
|
})
|
|
|
|
test('other tools pass through without filesystem resolution or reporting', async t => {
|
|
let reports = 0
|
|
const harness = createHarness({ onSuccessfulMutation: () => { reports++ } })
|
|
t.after(harness.dispose)
|
|
const call = execution('/unused', 'bash')
|
|
const decision = { kind: 'ask', reason: 'Existing policy' }
|
|
assert.equal(await harness.pre(call, decision), decision)
|
|
assert.equal(harness.denial(call), undefined)
|
|
const intent = { version: 'untouched' }
|
|
assert.equal(await harness.intent('fs/write-intent', {}, call, () => intent), intent)
|
|
assert.deepEqual(await harness.post(call, success()), { kind: 'accept' })
|
|
assert.equal(harness.resolved.length, 0)
|
|
assert.equal(reports, 0)
|
|
})
|
|
|
|
test('pre-execution preserves downstream deny and ask decisions', async t => {
|
|
const { repository } = await workspace(t)
|
|
const harness = createHarness()
|
|
t.after(harness.dispose)
|
|
harness.store.register('session-1', repository)
|
|
const call = execution(repository)
|
|
const denied = { kind: 'deny', reason: 'Other policy refused' }
|
|
assert.equal(await harness.pre(call, denied), denied)
|
|
assert.equal(harness.resolved.length, 0)
|
|
const ask = { kind: 'ask', reason: 'Approval required' }
|
|
assert.equal(await harness.pre(call, ask), ask)
|
|
assert.equal(harness.denial(call), undefined)
|
|
})
|
|
|
|
test('last-moment unregistration and session identity changes invalidate certificates', async t => {
|
|
const { repository } = await workspace(t)
|
|
const harness = createHarness()
|
|
t.after(harness.dispose)
|
|
harness.store.register('session-1', repository)
|
|
const call = execution(repository)
|
|
await harness.pre(call)
|
|
harness.store.unregister('session-1', repository)
|
|
assert.match(harness.denial(call), /not registered/)
|
|
harness.store.register('session-1', repository)
|
|
call.agent.session.header.id = 'session-2'
|
|
assert.match(harness.denial(call), /session or working directory changed/)
|
|
call.agent.session.header.id = 'session-1'
|
|
call.agent.session.header.cwd = '/other'
|
|
assert.match(harness.denial(call), /session or working directory changed/)
|
|
})
|
|
|
|
test('registration in another session never authorizes a mutation', async t => {
|
|
const { repository } = await workspace(t)
|
|
const harness = createHarness()
|
|
t.after(harness.dispose)
|
|
harness.store.register('parent-session', repository)
|
|
const call = execution(repository)
|
|
await harness.pre(call)
|
|
assert.match(harness.denial(call), /not registered/)
|
|
})
|
|
|
|
test('nested repository and physical symlink target require their own registration', async t => {
|
|
const { root, repository } = await workspace(t)
|
|
const nested = join(repository, 'nested')
|
|
await mkdir(nested)
|
|
await execute('git', ['init', '--quiet', nested])
|
|
const alias = join(root, 'alias')
|
|
await symlink(nested, alias)
|
|
const harness = createHarness()
|
|
t.after(harness.dispose)
|
|
harness.store.register('session-1', repository)
|
|
const call = execution(root, 'write', join(alias, 'missing', 'new.txt'))
|
|
await harness.pre(call)
|
|
assert.match(harness.denial(call), /not registered/)
|
|
harness.store.register('session-1', nested)
|
|
await harness.pre(call)
|
|
assert.equal(harness.denial(call), undefined)
|
|
})
|
|
|
|
test('cancellation prevents dispatch and reaches filesystem resolution unchanged', async t => {
|
|
const { repository } = await workspace(t)
|
|
const harness = createHarness()
|
|
t.after(harness.dispose)
|
|
harness.store.register('session-1', repository)
|
|
const controller = new AbortController()
|
|
const call = execution(repository)
|
|
call.signal = controller.signal
|
|
controller.abort(new Error('Cancelled by caller'))
|
|
let calls = 0
|
|
const outcome = await harness.dispatch(call, () => { calls++; return success() })
|
|
assert.equal(outcome.isError, true)
|
|
assert.match(outcome.error.message, /Cancelled by caller/)
|
|
assert.equal(calls, 0)
|
|
assert.equal(harness.resolved.length, 0)
|
|
})
|
|
|
|
test('cancellation during classification prevents a certificate from allowing dispatch', async t => {
|
|
const { root } = await workspace(t)
|
|
const harness = createHarness()
|
|
t.after(harness.dispose)
|
|
const controller = new AbortController()
|
|
const call = execution(root)
|
|
call.signal = controller.signal
|
|
harness.context.fs.processPath = async target => { controller.abort(new Error('Cancelled during resolution')); return target.path }
|
|
await assert.rejects(harness.pre(call), /Cancelled during resolution/)
|
|
assert.notEqual(harness.denial(call), undefined)
|
|
})
|
|
|
|
test('intent rechecks registration after downstream observation settles', async t => {
|
|
const { repository } = await workspace(t)
|
|
const harness = createHarness()
|
|
t.after(harness.dispose)
|
|
harness.store.register('session-1', repository)
|
|
const call = execution(repository)
|
|
await harness.pre(call)
|
|
await assert.rejects(harness.intent('fs/write-intent', { path: join(repository, 'new.txt') }, call, () => {
|
|
harness.store.unregister('session-1', repository)
|
|
return { createIfAbsent: true }
|
|
}), /not registered/)
|
|
})
|
|
|
|
test('normal tool failures retain failure content and never report success', async t => {
|
|
const { root } = await workspace(t)
|
|
let reports = 0
|
|
const harness = createHarness({ onSuccessfulMutation: () => { reports++ } })
|
|
t.after(harness.dispose)
|
|
const call = execution(root)
|
|
await harness.pre(call)
|
|
const failure = { isError: true, error: { message: 'Literal not found' }, content: [{ type: 'text', text: 'Literal not found' }] }
|
|
const decision = await harness.post(call, failure)
|
|
assert.equal(decision.content[0], failure.content[0])
|
|
assert.equal(Object.hasOwn(decision, 'value'), false)
|
|
assert.equal(failure.isError, true)
|
|
assert.equal(reports, 0)
|
|
})
|
|
|
|
test('persistence failure warns without rejecting a completed mutation', async t => {
|
|
const { repository } = await workspace(t)
|
|
const harness = createHarness({ onSuccessfulMutation: async () => { throw new Error('disk unavailable') } })
|
|
t.after(harness.dispose)
|
|
harness.store.register('session-1', repository)
|
|
const call = execution(repository)
|
|
await harness.pre(call)
|
|
const decision = await harness.post(call, success())
|
|
assert.equal(decision.kind, 'accept')
|
|
assert.match(decision.content.at(-1).text, /operation completed.*could not be saved: disk unavailable/)
|
|
})
|
|
|
|
test('outside warning preserves downstream blocks and replacement values', async t => {
|
|
const { root } = await workspace(t)
|
|
const harness = createHarness()
|
|
t.after(harness.dispose)
|
|
const call = execution(root)
|
|
await harness.pre(call)
|
|
const block = { kind: 'block', feedback: [{ type: 'text', text: 'Other post policy' }], additionalContexts: [] }
|
|
const blocked = await harness.post(call, success(), block)
|
|
assert.equal(blocked.kind, 'block')
|
|
assert.equal(blocked.feedback, block.feedback)
|
|
assert.equal(blocked.additionalContexts[0].role, 'user')
|
|
assert.match(blocked.additionalContexts[0].content[0].text, /WARNING/)
|
|
const replacement = { kind: 'accept', value: { path: 'replacement' } }
|
|
const replaced = await harness.post(call, success(), replacement)
|
|
assert.equal(replaced.value, replacement.value)
|
|
assert.equal(Object.hasOwn(replaced, 'content'), false)
|
|
assert.equal(replaced.additionalContexts.length, 1)
|
|
})
|
|
|
|
test('post content replacement and Code Mode warnings preserve existing contexts', async t => {
|
|
const { root } = await workspace(t)
|
|
const harness = createHarness()
|
|
t.after(harness.dispose)
|
|
const call = execution(root)
|
|
call.parent = Symbol('code-call')
|
|
await harness.pre(call)
|
|
const existingContext = { id: 'existing', role: 'user', source: { kind: 'plugin', plugin: 'test' }, content: [] }
|
|
const decision = await harness.post(call, success(), { kind: 'accept', content: [{ type: 'text', text: 'Replacement' }], additionalContexts: [existingContext] })
|
|
assert.equal(decision.content[0].text, 'Replacement')
|
|
assert.equal(decision.additionalContexts[0], existingContext)
|
|
assert.equal(decision.additionalContexts[1].source.plugin, 'dsh-touched-git')
|
|
})
|
|
|
|
test('tools/result and disposal clear certificates and unregister every hook', async t => {
|
|
const { repository } = await workspace(t)
|
|
const harness = createHarness()
|
|
harness.store.register('session-1', repository)
|
|
const call = execution(repository)
|
|
await harness.pre(call)
|
|
assert.equal(harness.denial(call), undefined)
|
|
harness.result(call, success())
|
|
assert.match(harness.denial(call), /did not complete/)
|
|
await harness.pre(call)
|
|
const guard = harness.guards[0]
|
|
harness.dispose()
|
|
harness.dispose()
|
|
assert.match(guard(call), /did not complete/)
|
|
assert.equal(harness.guards.length, 0)
|
|
assert.ok([...harness.hooks.values()].every(callbacks => callbacks.length === 0))
|
|
})
|