mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-30 11:35:02 +00:00
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:
parent
b58214dca6
commit
83d93cd8f2
9 changed files with 91 additions and 91 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue