fix: adapt to upstream veto-event tool API and reconcile fork copy ordering

- fileFencing: adjudicate through the onBeforeExecuteTool veto event
  (cold waitUntil probe after approval) instead of the removed
  onBeforeExecuteTool hook slot
- sessionLifecycle: copy fork session files via materializeSession's new
  beforeReady hook — under the target lease, yet before the readiness
  awaits so the forked tool policy is loaded
- kap-server: kosong discovery import path, required broadcaster homeDir
- tests: writeText HostFileStat returns, sessions-dir listing
  expectations, workspace-aliases lock wiring
This commit is contained in:
7Sageer 2026-07-22 20:27:39 +08:00
parent b58214dca6
commit 83d93cd8f2
9 changed files with 91 additions and 91 deletions

View file

@ -2,10 +2,13 @@
* `fileFencing` domain (L4) `IAgentFileFencingService` implementation.
*
* Owns one Agent's in-memory file revision baselines and participates in the
* `toolExecutor` hooks to enforce read-before-write and stale-write blocking
* for `Write`/`Edit`. Reads current revisions through the os host filesystem
* and keys paths with the normalization owned by `sessionFs`. Bound at Agent
* scope.
* `toolExecutor` surfaces to enforce read-before-write and stale-write
* blocking for `Write`/`Edit`: a `waitUntil` adjudication on the
* `onBeforeExecuteTool` veto event (registered after the permission gate, so
* an approval round-trip always precedes the staleness check), and baseline
* capture on `hooks.onDidExecuteTool`. Reads current revisions through the os
* host filesystem and keys paths with the normalization owned by `sessionFs`.
* Bound at Agent scope.
*/
import { InstantiationType } from '#/_base/di/extensions';
@ -15,7 +18,7 @@ import { unwrapErrorCause } from '#/_base/errors/errors';
import { fileStatTuplesEqual } from '#/_base/utils/fs';
import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
import type {
ToolBeforeExecuteContext,
BeforeToolExecuteEvent,
ToolDidExecuteContext,
ToolExecutionHookContext,
} from '#/agent/toolExecutor/toolHooks';
@ -43,10 +46,6 @@ function isFenced(ctx: ToolExecutionHookContext): boolean {
return ctx.toolCall.name === READ_TOOL || WRITE_TOOLS.has(ctx.toolCall.name);
}
function targetPathOf(ctx: ToolBeforeExecuteContext): string | undefined {
return fileAccessPath(ctx.execution.accesses);
}
function fileAccessPath(accesses: ToolAccesses | undefined): string | undefined {
if (accesses === undefined) return undefined;
for (const access of accesses) {
@ -97,35 +96,27 @@ export class AgentFileFencingService extends Disposable implements IAgentFileFen
@IAgentToolExecutorService toolExecutor: IAgentToolExecutorService,
) {
super();
toolExecutor.hooks.onBeforeExecuteTool.register('writeFencing', async (ctx, next) => {
this.onBefore(ctx);
if (ctx.decision?.block === true) return;
await next();
});
this._register(toolExecutor.onBeforeExecuteTool((event) => this.adjudicate(event)));
toolExecutor.hooks.onDidExecuteTool.register('writeFencing', async (ctx, next) => {
this.onDid(ctx);
await next();
});
}
private onBefore(ctx: ToolBeforeExecuteContext): void {
if (!isFenced(ctx)) return;
const path = targetPathOf(ctx);
private adjudicate(event: BeforeToolExecuteEvent): void {
if (!isFenced(event)) return;
if (!WRITE_TOOLS.has(event.toolCall.name)) return;
if (isAppendWrite(event)) return;
const path = fileAccessPath(event.execution.accesses);
if (path === undefined) return;
if (!WRITE_TOOLS.has(ctx.toolCall.name)) return;
if (isAppendWrite(ctx)) return;
const execute = ctx.decision?.execute ?? ctx.execution.execute;
ctx.decision = {
...ctx.decision,
execute: async (executeCtx) => {
const verdict = await this.compareRevision(path);
if (verdict !== 'clean') {
const reason = blockReason(ctx.toolCall.name, path, verdict);
return { output: reason, isError: true };
}
return execute(executeCtx);
},
};
const toolName = event.toolCall.name;
// Cold adjudication: the staleness probe runs only after every listener
// passed without a veto, so it always follows the approval round-trip.
event.waitUntil(async () => {
const verdict = await this.compareRevision(path);
if (verdict === 'clean') return undefined;
return { veto: { output: blockReason(toolName, path, verdict), isError: true } };
});
}
private onDid(ctx: ToolDidExecuteContext): void {

View file

@ -249,7 +249,10 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
}
}
private async materializeSession(opts: MaterializeSessionOptions): Promise<SessionEntry> {
private async materializeSession(
opts: MaterializeSessionOptions,
beforeReady?: (handle: ISessionScopeHandle) => Promise<void>,
): Promise<SessionEntry> {
const workspace = await this.workspaces.createOrTouch(opts.workDir);
const workspaceId = opts.workspaceId ?? workspace.id;
const sessionScope = this.bootstrap.sessionScope(workspaceId, opts.sessionId);
@ -302,6 +305,10 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
disposed: false,
};
this.entries.set(opts.sessionId, entry);
// Fork's session-file copy lands here: the lease is already held (so no
// peer can materialize the target underneath the copy) and the readiness
// awaits below still see the copied state (tool policy, agent files).
await beforeReady?.(handle);
handle.accessor.get(ISessionExternalHooksService);
handle.accessor.get(ISessionCronService);
await handle.accessor.get(ISessionMetadata).ready;
@ -698,20 +705,24 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
);
}
targetEntry = await this.materializeSession({
sessionId: targetId,
workDir: workspace.root,
});
targetEntry = await this.materializeSession(
{
sessionId: targetId,
workDir: workspace.root,
},
async (handle) => {
const dir = handle.accessor.get(ISessionContext).sessionDir;
// Reached only under our own lease (post-acquisition), so a later
// failure may safely attribute the directory to us and remove it.
targetSessionDir = dir;
await this.copySessionFiles(this.bootstrap.sessionDir(workspaceId, sourceId), dir);
},
);
target = targetEntry.handle;
const targetCtx = target.accessor.get(ISessionContext);
targetSessionDir = targetCtx.sessionDir;
const targetMeta = target.accessor.get(ISessionMetadata);
await this.copySessionFiles(
this.bootstrap.sessionDir(workspaceId, sourceId),
targetCtx.sessionDir,
);
const sourceAgents = sourceMeta?.agents ?? {};
const agentIds = Object.keys(sourceAgents);
for (const agentId of agentIds) {

View file

@ -1,10 +1,10 @@
/**
* `fileFencing` domain (L4) verifies the write/read-first gate end to end
* through the real DI scope tree: a real tmpdir, the real `HostFileSystem`,
* and the registered `writeFencing` hook participant over a real
* `OrderedHookSlot`. Covers the hard-block verdicts (stale outside change /
* never-read existing file), stat-only checks, and baseline isolation between
* two Agent scopes in one Session.
* and the registered `writeFencing` veto listener over a real
* `BeforeToolExecuteEmitter`. Covers the hard-block verdicts (stale outside
* change / never-read existing file), stat-only checks, and baseline
* isolation between two Agent scopes in one Session.
*/
import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
@ -18,8 +18,9 @@ import { createScopedTestHost, stubPair, type ScopedTestHost } from '#/_base/di/
import { IAgentFileFencingService } from '#/agent/fileFencing/fileFencing';
import { AgentFileFencingService } from '#/agent/fileFencing/fileFencingService';
import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
import { BeforeToolExecuteEmitter } from '#/agent/toolExecutor/beforeToolExecuteEvent';
import type {
ToolBeforeExecuteContext,
ResolvedToolExecutionHookContext,
ToolDidExecuteContext,
} from '#/agent/toolExecutor/toolHooks';
import type { ToolCall } from '#/kosong/contract/message';
@ -61,15 +62,17 @@ function makeEnv(): Env {
interface AgentWorld {
readonly env: Env;
readonly executor: IAgentToolExecutorService;
readonly beforeEmitter: BeforeToolExecuteEmitter;
}
function makeAgent(env: Env, session: Scope, agentId = 'main'): AgentWorld {
const executor = stubToolExecutor();
const beforeEmitter = new BeforeToolExecuteEmitter();
const executor = { ...stubToolExecutor(), onBeforeExecuteTool: beforeEmitter.event };
const agent = env.host.childOf(session, LifecycleScope.Agent, agentId, [
stubPair(IAgentToolExecutorService, executor),
]);
agent.accessor.get(IAgentFileFencingService);
return { env, executor };
return { env, executor, beforeEmitter };
}
function setup(): AgentWorld {
@ -81,7 +84,7 @@ function beforeCtx(
toolName: string,
path: string,
opts: { args?: Record<string, unknown> } = {},
): ToolBeforeExecuteContext {
): ResolvedToolExecutionHookContext {
const args =
opts.args ??
(toolName === 'Edit'
@ -112,28 +115,15 @@ function beforeCtx(
async function runBefore(
world: AgentWorld,
ctx: ToolBeforeExecuteContext,
): Promise<ToolBeforeExecuteContext> {
await world.executor.hooks.onBeforeExecuteTool.run(ctx);
await runPrepared(ctx);
ctx: ResolvedToolExecutionHookContext,
): Promise<ResolvedToolExecutionHookContext> {
await world.beforeEmitter.fireBeforeExecute(ctx);
return ctx;
}
async function runPrepared(
ctx: ToolBeforeExecuteContext,
): Promise<ExecutableToolResult | undefined> {
if (ctx.decision?.execute === undefined) return undefined;
return ctx.decision.execute({
turnId: ctx.turnId,
toolCallId: ctx.toolCall.id,
trace: ctx.trace,
signal: ctx.signal,
});
}
async function runDid(
world: AgentWorld,
ctx: ToolBeforeExecuteContext,
ctx: ResolvedToolExecutionHookContext,
result?: ExecutableToolResult,
): Promise<ToolDidExecuteContext> {
let effectiveResult = result;
@ -174,9 +164,8 @@ async function runOk(
opts: { args?: Record<string, unknown> } = {},
): Promise<ToolDidExecuteContext> {
const ctx = beforeCtx(toolName, path, opts);
await world.executor.hooks.onBeforeExecuteTool.run(ctx);
const prepared = await runPrepared(ctx);
expect(prepared?.isError).not.toBe(true);
const decision = await world.beforeEmitter.fireBeforeExecute(ctx);
expect(decision?.veto?.isError).not.toBe(true);
if (toolName === 'Write') {
const args = ctx.args as { readonly content: string; readonly mode?: 'overwrite' | 'append' };
writeFileSync(path, args.content, { flag: args.mode === 'append' ? 'a' : 'w' });
@ -190,12 +179,14 @@ async function runBlocked(
path: string,
): Promise<ExecutableToolResult> {
const ctx = beforeCtx(toolName, path);
await world.executor.hooks.onBeforeExecuteTool.run(ctx);
const result = await runPrepared(ctx);
if (result?.isError !== true) {
throw new Error(`expected ${toolName} on ${path} to be blocked, got: ${JSON.stringify(result)}`);
const decision = await world.beforeEmitter.fireBeforeExecute(ctx);
const veto = decision?.veto;
if (veto?.isError !== true) {
throw new Error(
`expected ${toolName} on ${path} to be blocked, got: ${JSON.stringify(decision)}`,
);
}
return result;
return veto;
}
const hosts: ScopedTestHost[] = [];
@ -252,19 +243,18 @@ describe('AgentFileFencingService', () => {
await runOk(world, 'Edit', file);
});
it('checks the file at execution time rather than during preflight', async () => {
it('probes the current revision when the fenced call is adjudicated', async () => {
const world = setup();
const file = join(world.env.workDir, 'a.txt');
writeFileSync(file, 'hello');
await runOk(world, 'Read', file);
const ctx = beforeCtx('Edit', file);
await world.executor.hooks.onBeforeExecuteTool.run(ctx);
// The staleness probe is a cold waitUntil factory: a change that landed
// after the read baseline but before adjudication is caught.
writeFileSync(file, 'changed while queued');
const blocked = await runPrepared(ctx);
expect(blocked?.isError).toBe(true);
expect(blocked?.output).toContain('changed on disk since');
const blocked = await runBlocked(world, 'Edit', file);
expect(blocked.output).toContain('changed on disk since');
});
it('fails closed when the current file revision cannot be read', async () => {
@ -388,13 +378,12 @@ describe('AgentFileFencingService', () => {
writeFileSync(file, 'hello world');
// The stale verdict blocks the Edit; the wrapper's error result must not
// be baselined by the did-hook, so the file stays stale until re-read.
// The stale verdict vetoes the Edit; the veto's error result must not be
// baselined by the did-hook, so the file stays stale until re-read.
const failed = beforeCtx('Edit', file);
await world.executor.hooks.onBeforeExecuteTool.run(failed);
const failedResult = await runPrepared(failed);
expect(failedResult?.isError).toBe(true);
await runDid(world, failed, { output: 'boom', isError: true });
const decision = await world.beforeEmitter.fireBeforeExecute(failed);
expect(decision?.veto?.isError).toBe(true);
await runDid(world, failed, decision!.veto!);
const retry = await runBlocked(world, 'Edit', file);
expect(retry.output).toContain('changed on disk since');

View file

@ -178,6 +178,7 @@ describe('AgentPlanService plan-guard listener', () => {
readText: vi.fn(async (path: string) => files.get(path) ?? ''),
writeText: vi.fn(async (path: string, content: string) => {
files.set(path, content);
return { isFile: true, isDirectory: false, size: Buffer.byteLength(content) };
}),
}),
);

View file

@ -13,6 +13,7 @@ import {
import { createScopedTestHost, stubPair } from '#/_base/di/test';
import { encodeWorkDirKey } from '#/_base/utils/workdir-slug';
import { HostFileSystem } from '#/os/backends/node-local/hostFsService';
import { CrossProcessLockService } from '#/os/backends/node-local/crossProcessLockService';
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
import { JsonAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore';
import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService';
@ -71,7 +72,7 @@ describe('WorkspaceAliasesService (file-backed)', () => {
});
function build(hostFs: IHostFileSystem = new HostFileSystem()): IWorkspaceAliases {
const fileStorage = new FileStorageService(homeDir);
const fileStorage = new FileStorageService(homeDir, undefined, undefined, new CrossProcessLockService());
const host = createScopedTestHost([
stubPair(IFileSystemStorageService, fileStorage),
stubPair(IAtomicDocumentStore, new JsonAtomicDocumentStore(fileStorage)),

View file

@ -17,7 +17,7 @@ import type { UsageStatus } from '@moonshot-ai/agent-core-v2/agent/usage/usage';
import type {
ProviderRefreshChange,
ProviderRefreshFailure,
} from '@moonshot-ai/agent-core-v2/app/modelCatalog/modelCatalog';
} from '@moonshot-ai/agent-core-v2/kosong/model/discovery';
import type { AgentPhase } from '../../../services/legacyStatus/legacyStatus';
import type { ConfigResponse } from '../../../protocol/rest-config';
import type { Session, SessionPendingInteraction } from '../../../protocol/session';

View file

@ -1726,7 +1726,7 @@ function sessionCreatedPayload(
/**
* Validate the `event.model_catalog.changed` payload published on the core
* `IEventService` (`IModelCatalogService` refresh / auth change): the three
* `IEventService` (`IProviderDiscoveryService` refresh / auth change): the three
* per-provider diff arrays are all the v1 frame carries.
*/
function modelCatalogChangedPayload(

View file

@ -1561,6 +1561,7 @@ describe('SessionEventBroadcaster', () => {
const core = makeCore(sessions, eventBus, metaAgents);
return new SessionEventBroadcaster({
eventsDir: dir,
homeDir: dir,
core,
maxBufferSize: 3,
transcriptService: new TranscriptService({ homeDir: dir, core }),
@ -1818,6 +1819,7 @@ describe('SessionEventBroadcaster', () => {
});
bc = new SessionEventBroadcaster({
eventsDir: dir,
homeDir: dir,
core,
maxBufferSize: 3,
transcriptService: service,
@ -1844,6 +1846,7 @@ describe('SessionEventBroadcaster', () => {
const service = new TranscriptService({ homeDir: dir, core });
bc = new SessionEventBroadcaster({
eventsDir: dir,
homeDir: dir,
core,
maxBufferSize: 3,
transcriptService: service,

View file

@ -123,7 +123,9 @@ describe('server-v2 /api/v1 fs folder picker', () => {
expect(body.code).toBe(0);
expect(body.data.path).toBe(await realpath(root));
const names = body.data.entries.map((e) => e.name).sort();
expect(names).toEqual(['alpha', 'beta']);
// `sessions/` is created by the server itself at boot (the always-on
// session-list watch mkdirs `<home>/sessions` before watching it).
expect(names).toEqual(['alpha', 'beta', 'sessions']);
for (const entry of body.data.entries) {
expect(entry.is_dir).toBe(true);
expect(entry.path).toBe(join(await realpath(root), entry.name));
@ -139,7 +141,9 @@ describe('server-v2 /api/v1 fs folder picker', () => {
`/api/v1/fs:browse?path=${encodeURIComponent(root)}`,
);
expect(body.code).toBe(0);
expect(body.data.entries.map((e) => e.name)).toEqual(['alpha', '.zeta']);
// `sessions/` sits between regular and dot-directories: the always-on
// session-list watch creates `<home>/sessions` at boot.
expect(body.data.entries.map((e) => e.name)).toEqual(['alpha', 'sessions', '.zeta']);
});
it('returns parent=null for the filesystem root', async () => {