diff --git a/packages/agent-core-v2/src/agent/fileFencing/fileFencingService.ts b/packages/agent-core-v2/src/agent/fileFencing/fileFencingService.ts index 1ce9f1738..b40a90ed0 100644 --- a/packages/agent-core-v2/src/agent/fileFencing/fileFencingService.ts +++ b/packages/agent-core-v2/src/agent/fileFencing/fileFencingService.ts @@ -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 { diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts index 1b725eb04..188f40a02 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts @@ -249,7 +249,10 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } } - private async materializeSession(opts: MaterializeSessionOptions): Promise { + private async materializeSession( + opts: MaterializeSessionOptions, + beforeReady?: (handle: ISessionScopeHandle) => Promise, + ): Promise { 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) { diff --git a/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts b/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts index fb9db5133..914091154 100644 --- a/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts +++ b/packages/agent-core-v2/test/agent/fileFencing/fileFencing.test.ts @@ -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 } = {}, -): ToolBeforeExecuteContext { +): ResolvedToolExecutionHookContext { const args = opts.args ?? (toolName === 'Edit' @@ -112,28 +115,15 @@ function beforeCtx( async function runBefore( world: AgentWorld, - ctx: ToolBeforeExecuteContext, -): Promise { - await world.executor.hooks.onBeforeExecuteTool.run(ctx); - await runPrepared(ctx); + ctx: ResolvedToolExecutionHookContext, +): Promise { + await world.beforeEmitter.fireBeforeExecute(ctx); return ctx; } -async function runPrepared( - ctx: ToolBeforeExecuteContext, -): Promise { - 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 { let effectiveResult = result; @@ -174,9 +164,8 @@ async function runOk( opts: { args?: Record } = {}, ): Promise { 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 { 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'); diff --git a/packages/agent-core-v2/test/agent/plan/planGuard.test.ts b/packages/agent-core-v2/test/agent/plan/planGuard.test.ts index 572bfac72..64e10e6da 100644 --- a/packages/agent-core-v2/test/agent/plan/planGuard.test.ts +++ b/packages/agent-core-v2/test/agent/plan/planGuard.test.ts @@ -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) }; }), }), ); diff --git a/packages/agent-core-v2/test/app/workspaceAliases/workspaceAliasesService.test.ts b/packages/agent-core-v2/test/app/workspaceAliases/workspaceAliasesService.test.ts index cd2951ab0..88887d6b2 100644 --- a/packages/agent-core-v2/test/app/workspaceAliases/workspaceAliasesService.test.ts +++ b/packages/agent-core-v2/test/app/workspaceAliases/workspaceAliasesService.test.ts @@ -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)), diff --git a/packages/kap-server/src/transport/ws/v1/events.ts b/packages/kap-server/src/transport/ws/v1/events.ts index 03322e79e..94db71709 100644 --- a/packages/kap-server/src/transport/ws/v1/events.ts +++ b/packages/kap-server/src/transport/ws/v1/events.ts @@ -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'; diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts index 114d83c38..ba4e2ad3c 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -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( diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 6e230811c..229bfcb9e 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -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, diff --git a/packages/kap-server/test/workspaceFs.test.ts b/packages/kap-server/test/workspaceFs.test.ts index 7ef2bdafe..a2caf9bae 100644 --- a/packages/kap-server/test/workspaceFs.test.ts +++ b/packages/kap-server/test/workspaceFs.test.ts @@ -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 `/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 `/sessions` at boot. + expect(body.data.entries.map((e) => e.name)).toEqual(['alpha', 'sessions', '.zeta']); }); it('returns parent=null for the filesystem root', async () => {