feat(agent-core-v2): collect os-level services into the workspace scope

- Move fs service, fs watch (shared subscription fan-out), process
  runner, and a git facade to Workspace scope; sessionFs domain removed
- Add IWorkspaceToolPolicy with workspace veto wired through tool
  activation, execution guard, composed evaluation, and profile
  prompt projection; injected via ISessionToolPolicyGate seed
- kap-server fs routes and fs.watch bridge remap to the workspace
  services; wire unchanged
This commit is contained in:
haozhe.yang 2026-07-29 19:58:25 +08:00
parent 4c23ee7ed2
commit acf2059365
52 changed files with 1474 additions and 639 deletions

View file

@ -21,7 +21,7 @@
// references become '(circular)', and class instances collapse to a '(ClassName)'
// marker — the wire shape of an entry is the JSON projection of the type here.
//
// Index (Session: 25 keys · Agent: 68 keys)
// Index (Session: 18 keys · Agent: 68 keys)
// Session
// cron.inFlight src/session/cron/sessionCronServiceImpl.ts
// cron.lastSeenAt src/session/cron/sessionCronServiceImpl.ts
@ -34,13 +34,6 @@
// interaction.recentlyResolved src/session/interaction/interactionService.ts
// sessionActivity.current src/session/sessionActivity/sessionActivityService.ts
// sessionActivity.folds src/session/sessionActivity/sessionActivityService.ts
// sessionFs.realRootsCache src/session/sessionFs/fsService.ts
// sessionFs.rgResolution src/session/sessionFs/fsService.ts
// sessionFsWatch.gitignoreLoaded src/session/sessionFs/fsWatchService.ts
// sessionFsWatch.pending src/session/sessionFs/fsWatchService.ts
// sessionFsWatch.rawCount src/session/sessionFs/fsWatchService.ts
// sessionFsWatch.truncated src/session/sessionFs/fsWatchService.ts
// sessionFsWatch.watched src/session/sessionFs/fsWatchService.ts
// sessionLog.rootLevel src/session/sessionLog/sessionLogService.ts
// sessionMetadata.data src/session/sessionMetadata/sessionMetadataService.ts
// sessionSkillCatalog.contributions src/session/sessionSkillCatalog/skillCatalogService.ts
@ -172,27 +165,6 @@ export interface SessionStateSnapshot {
background: number;
lastTurnReason?: 'completed' | 'cancelled' | 'failed';
}>;
// src/session/sessionFs/fsService.ts
'sessionFs.realRootsCache': {
readonly key: string;
readonly roots: readonly string[];
} | undefined;
'sessionFs.rgResolution': /* RgResolution — packages/agent-core-v2/src/session/sessionFs/rgLocator.ts */ {
readonly path: string;
readonly source: /* RgResolutionSource — packages/agent-core-v2/src/session/sessionFs/rgLocator.ts */ 'system-path' | 'share-bin-cached';
} | null | undefined;
// src/session/sessionFs/fsWatchService.ts
'sessionFsWatch.gitignoreLoaded': boolean;
'sessionFsWatch.pending': /* FsChangeEntry — packages/agent-core-v2/src/session/sessionFs/fsWatch.ts */ {
path: string;
change: /* FsChangeAction — packages/agent-core-v2/src/session/sessionFs/fsWatch.ts */ 'created' | 'modified' | 'deleted';
kind: /* FsChangeKind — packages/agent-core-v2/src/session/sessionFs/fsWatch.ts */ 'file' | 'directory' | 'symlink';
size_delta?: number;
etag?: string;
}[];
'sessionFsWatch.rawCount': number;
'sessionFsWatch.truncated': boolean;
'sessionFsWatch.watched': Set<string>;
// src/session/sessionLog/sessionLogService.ts
'sessionLog.rootLevel': /* LogLevelState — packages/agent-core-v2/src/_base/log/logService.ts */ {
level: /* LogLevel — packages/agent-core-v2/src/_base/log/log.ts */ 'info' | 'off' | 'error' | 'warn' | 'debug';
@ -772,7 +744,7 @@ export interface AgentStateSnapshot {
'llmRequester.lastConfigLogSignature': string | undefined;
'llmRequester.mediaDegradedTurns': Set<number>;
'llmRequester.mediaStrippedTurns': Map<number, /* MediaStripSnapshot — packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts */ {
readonly "__@mediaStripSnapshotBrand@2441": undefined;
readonly "__@mediaStripSnapshotBrand@2263": undefined;
}>;
'llmRequester.turnConfigs': Map<number, /* TurnRequestConfig — packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts */ {
readonly resolved: /* ProfileModelContext — packages/agent-core-v2/src/agent/profile/profile.ts */ {

View file

@ -152,8 +152,28 @@ const DOMAIN_LAYER = new Map([
['file', 2],
['config', 2],
['projectLocalConfig', 2],
['sessionFs', 2],
// `process` is the Session-scope process-runner CONTRACT
// (`ISessionProcessRunner`); the implementation moved to the Workspace
// scope (`workspaceProcess`) but the contract stays in the session domain
// so Session/Agent consumers keep importing it without crossing the
// Workspace-tier import ban.
['process', 2],
// `workspaceProcess` is the Workspace-scope `ISessionProcessRunner`
// implementation (default cwd = handler root); it consumes the `process`
// contract (L2) and the os process bridge (L1).
['workspaceProcess', 2],
// `workspaceGit` is the Workspace-scope git facade pinned to the handler
// root over the App-scope `git` service (L1).
['workspaceGit', 2],
// `workspaceToolPolicy` is the Workspace-scope owner of the os-level tool
// veto set (runtime capabilities keyed off the handler's os backend); it
// hands every session the `sessionToolPolicyGate` seed contract (L1).
['workspaceToolPolicy', 2],
// `sessionToolPolicyGate` is the Session-scope seeded workspace tool-veto
// contract (`ISessionToolPolicyGate`): a pure data + change-event
// injection contract (the Workspace-scope `workspaceToolPolicy` impl hands
// it to each session) with no IO, so it sits in L1 beside `workspaceInfo`.
['sessionToolPolicyGate', 1],
['workspace', 2],
['workspaceAliases', 2],
['workspaceSessions', 2],
@ -182,6 +202,11 @@ const DOMAIN_LAYER = new Map([
// fs-watch refresh); its highest dependency is `projectLocalConfig` (L2),
// so it sits in L3 beside the other Workspace-scope resource owners.
['workspaceDirs', 3],
// `workspaceFs` is the Workspace-scope fs surface (list/read/search/grep/
// git status/diff) plus the shared fs-watch fan-out; its highest
// dependency is `workspaceDirs` (L3 — the additional-dir set used for
// path confinement), so it sits in L3 beside it.
['workspaceFs', 3],
['sessionToolPolicy', 3],
['permissionGate', 3],
['toolApproval', 3],
@ -590,7 +615,6 @@ const ALLOWED_EXCEPTIONS = new Set([
'filestore>persistence/backends',
'process>os/backends',
'terminal>os/backends',
'sessionFs>os/backends',
'blobStore>persistence/backends',
// `sessionIndex` (L2) reads the `persistence_minidb_readmodel` experimental
// flag (L3) to switch session listings between the legacy N+1 disk read and

View file

@ -92,6 +92,7 @@ import { ISessionInstructionsProvider } from '#/session/sessionInstructions/inst
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog';
import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy';
import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate';
import type { ResolvedAgentProfile, SystemPromptContext } from '#/agent/profile/profile';
import { IAgentStateService } from '#/agent/state/agentState';
@ -203,6 +204,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
@ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog,
@ISessionInstructionsProvider private readonly instructions: ISessionInstructionsProvider,
@ISessionToolPolicy private readonly sessionToolPolicy: ISessionToolPolicy,
@ISessionToolPolicyGate private readonly toolPolicyGate: ISessionToolPolicyGate,
@IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService,
@IAgentProfileCatalogService private readonly builtinProfiles: IAgentProfileCatalogService,
@IAgentStateService private readonly states: IAgentStateService,
@ -887,6 +889,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ
): boolean {
return isToolActiveComposed(
{
workspaceDisabledTools: this.toolPolicyGate.disabledTools,
profile,
global: this.config.get<ToolsConfig>(TOOLS_SECTION),
sessionDisabledTools: this.sessionToolPolicy.disabledTools(),

View file

@ -3,7 +3,8 @@
*
* Owns the activation pass that turns the module-level `registerAgentToolService`
* contributions (`toolRegistry`, L3) into entries of the per-agent runtime
* registry: a contribution activates only when its `when` predicate holds
* registry: a contribution activates only when its `when` predicate holds,
* the workspace os-level veto (`sessionToolPolicyGate`) does not disable it,
* and its declared `name` is allowed by the bound Profile's tool policy
* (`profile`, L4). `AgentLifecycleService.create` awaits one activation pass
* after restore and profile binding, so an Agent's tools reflect the Profile

View file

@ -2,7 +2,8 @@
* `toolActivation` domain (L4) `IAgentToolActivationService` implementation.
*
* Iterates the `toolRegistry` contribution table and, for each entry allowed
* by the bound Profile's tool policy (`profile`), resolves the Agent-scope
* by the workspace os-level veto (the seeded `sessionToolPolicyGate`) AND
* the bound Profile's tool policy (`profile`), resolves the Agent-scope
* service through the container nothing constructs the tool before this
* `accessor.get` and registers the real instance into the runtime
* registry.
@ -30,6 +31,7 @@ import { IAgentProfileService } from '#/agent/profile/profile';
import { isToolActive } from '#/agent/toolPolicy/evaluate';
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
import { getAgentToolContributions } from '#/agent/toolRegistry/toolContribution';
import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate';
import { IAgentToolActivationService } from './toolActivation';
@ -40,6 +42,7 @@ export class AgentToolActivationService extends Disposable implements IAgentTool
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService,
@IAgentProfileService private readonly profile: IAgentProfileService,
@ISessionToolPolicyGate private readonly toolPolicyGate: ISessionToolPolicyGate,
@IEventBus eventBus: IEventBus,
) {
super();
@ -53,10 +56,15 @@ export class AgentToolActivationService extends Disposable implements IAgentTool
activate(): Promise<void> {
const data = this.profile.data();
const policy = { tools: data.activeToolNames, disallowedTools: data.disallowedTools };
const workspaceVeto = { disallowedTools: this.toolPolicyGate.disabledTools };
this.instantiationService.invokeFunction((accessor) => {
for (const { id, options } of getAgentToolContributions()) {
const source = options.source ?? 'builtin';
if (this.toolRegistry.resolve(options.name) !== undefined) continue;
// The workspace (os-level) veto outranks the profile: a disabled tool
// never activates, so it never reaches the registry (and therefore
// the schema) at all.
if (!isToolActive(workspaceVeto, options.name, source)) continue;
if (!isToolActive(policy, options.name, source)) continue;
if (options.when !== undefined && !options.when(accessor)) continue;
const tool = accessor.get(id);

View file

@ -3,8 +3,9 @@
*
* Applies allowlists and denylists with builtin/MCP matching semantics shared
* by Agent authorization, profile prompt construction, and child-agent setup.
* `isToolActiveComposed` intersects the three policy layers (profile, global
* `[tools]` config, Session denylist) so every consumer evaluates the same
* `isToolActiveComposed` intersects the policy layers (workspace os-level
* veto, profile, global `[tools]` config, Session denylist the workspace
* veto first, outranking the rest) so every consumer evaluates the same
* combination instead of re-implementing it. An empty/absent global `enabled`
* list means unconstrained an explicit empty list must never disable
* everything.
@ -58,6 +59,12 @@ export interface GlobalToolsPolicy {
}
export interface ToolPolicyLayers {
/**
* The workspace (os-level) veto: tools the runtime / workspace disables.
* Evaluated FIRST it outranks every other layer, so a workspace-disabled
* tool is inactive no matter what profile, config, or session layers say.
*/
readonly workspaceDisabledTools?: readonly string[];
readonly profile: ToolActivationPolicy;
readonly global?: GlobalToolsPolicy;
readonly sessionDisabledTools?: readonly string[];
@ -69,6 +76,7 @@ export function isToolActiveComposed(
source: ToolSource = 'builtin',
): boolean {
return (
isToolActive({ disallowedTools: layers.workspaceDisabledTools }, name, source) &&
isToolActive(layers.profile, name, source) &&
isToolActive(
{

View file

@ -1,12 +1,14 @@
/**
* `toolPolicy` domain (L4) Agent-scope tool authorization service.
*
* Intersects the bound profile policy, global `[tools]` configuration, and
* Session denylist (composed by `isToolActiveComposed` in `./evaluate`), and
* installs the resulting authorization check into the L3 executor preflight so
* direct tool calls cannot bypass schema filtering. Disclosure entries retain
* their implicit availability when a profile allowlist omits them, while
* explicit deny layers still apply.
* Intersects the workspace os-level veto (the seeded `sessionToolPolicyGate`,
* which outranks everything below it), the bound profile policy, global
* `[tools]` configuration, and Session denylist (composed by
* `isToolActiveComposed` in `./evaluate`), and installs the resulting
* authorization check into the L3 executor preflight so direct tool calls
* cannot bypass schema filtering. Disclosure entries retain their implicit
* availability when a profile allowlist omits them, while explicit deny
* layers still apply.
*/
import { Disposable } from '#/_base/di/lifecycle';
@ -16,6 +18,7 @@ import { TOOLS_SECTION, type ToolsConfig } from './configSection';
import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
import { IConfigService } from '#/app/config/config';
import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy';
import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate';
import { SELECT_TOOLS_TOOL_NAME } from '#/agent/toolSelect/toolSelect';
import type { ToolSource } from '#/tool/toolContract';
@ -29,6 +32,7 @@ export class AgentToolPolicyService extends Disposable implements IAgentToolPoli
@IAgentProfileService private readonly profile: IAgentProfileService,
@IConfigService private readonly config: IConfigService,
@ISessionToolPolicy private readonly sessionToolPolicy: ISessionToolPolicy,
@ISessionToolPolicyGate private readonly toolPolicyGate: ISessionToolPolicyGate,
@IAgentToolExecutorService toolExecutor: IAgentToolExecutorService,
) {
super();
@ -61,6 +65,7 @@ export class AgentToolPolicyService extends Disposable implements IAgentToolPoli
const profile = this.profile.data();
return isToolActiveComposed(
{
workspaceDisabledTools: this.toolPolicyGate.disabledTools,
profile: { disallowedTools: profile.disallowedTools },
global: this.config.get<ToolsConfig>(TOOLS_SECTION),
sessionDisabledTools: this.sessionToolPolicy.disabledTools(),
@ -77,6 +82,7 @@ export class AgentToolPolicyService extends Disposable implements IAgentToolPoli
): boolean {
return isToolActiveComposed(
{
workspaceDisabledTools: this.toolPolicyGate.disabledTools,
profile,
global: this.config.get<ToolsConfig>(TOOLS_SECTION),
sessionDisabledTools: this.sessionToolPolicy.disabledTools(),

View file

@ -3,9 +3,8 @@
*
* Parses `git status --porcelain=v1 --branch`, `git diff --numstat`, and
* `gh pr view --json` output into the protocol `FsGitStatusResponse` shape.
* No IO, no DI plain functions so they can be unit-tested directly. Moved
* from `session/sessionFs/fsGit.ts` (originally ported from v1
* `services/fs/fsGit.ts`).
* No IO, no DI plain functions so they can be unit-tested directly.
* Originally ported from v1 `services/fs/fsGit.ts`.
*/
import type { FsGitStatus, FsGitStatusResponse, FsPullRequest } from './git';

View file

@ -14,7 +14,7 @@ import { TaskErrors } from '#/agent/task/errors';
import { ProtocolErrors } from '#/kosong/protocol/errors';
import { ConfigErrors } from '#/app/config/errors';
import { FileErrors } from '#/app/file/fileService';
import { FsErrors } from '#/session/sessionFs/errors';
import { FsErrors } from '#/workspace/workspaceFs/errors';
import { FullCompactionErrors } from '#/agent/fullCompaction/errors';
import { GoalErrors } from '#/agent/goal/errors';
import { LoopErrors } from '#/agent/loop/errors';
@ -47,7 +47,7 @@ export { TaskErrors } from '#/agent/task/errors';
export { ProtocolErrors } from '#/kosong/protocol/errors';
export { ConfigErrors } from '#/app/config/errors';
export { FileErrors } from '#/app/file/fileService';
export { FsErrors } from '#/session/sessionFs/errors';
export { FsErrors } from '#/workspace/workspaceFs/errors';
export { FullCompactionErrors } from '#/agent/fullCompaction/errors';
export { GoalErrors } from '#/agent/goal/errors';
export { LoopErrors } from '#/agent/loop/errors';

View file

@ -390,14 +390,21 @@ export * from '#/app/bashParser/bashParser';
import '#/app/bashParser/bashParserService';
export * from '#/session/process/processRunner';
export * from '#/session/process/processRunnerService';
export * from '#/session/sessionFs/errors';
export * from '#/session/sessionFs/fs';
export * from '#/session/sessionFs/fsService';
export * from '#/session/sessionFs/fsWatch';
export * from '#/session/sessionFs/fsWatchService';
export * from '#/session/sessionFs/gitContext';
export * from '#/session/sessionFs/rgLocator';
export * from '#/session/sessionFs/runRg';
export * from '#/workspace/workspaceProcess/workspaceProcessRunnerService';
export * from '#/workspace/workspaceFs/errors';
export * from '#/workspace/workspaceFs/fs';
export * from '#/workspace/workspaceFs/fsService';
export * from '#/workspace/workspaceFs/fsWatch';
export * from '#/workspace/workspaceFs/fsWatchService';
export * from '#/session/agentLifecycle/profile/gitContext';
export * from '#/workspace/workspaceFs/rgLocator';
export * from '#/workspace/workspaceFs/runRg';
export * from '#/workspace/workspaceGit/workspaceGit';
export * from '#/workspace/workspaceGit/workspaceGitService';
export * from '#/session/sessionToolPolicyGate/sessionToolPolicyGate';
export * from '#/session/sessionToolPolicyGate/sessionToolPolicyGateService';
export * from '#/workspace/workspaceToolPolicy/workspaceToolPolicy';
export * from '#/workspace/workspaceToolPolicy/workspaceToolPolicyService';
export * from '#/app/hostFolderBrowser/hostFolderBrowser';
export * from '#/app/hostFolderBrowser/hostFolderBrowserService';
export * from '#/persistence/interface/storage';

View file

@ -7,7 +7,7 @@
* predicate. Mode-specific argument building and output parsing stay in the
* tools themselves.
*
* Ported from `session/sessionFs/runRg` onto the os tools: the subprocess now
* Ported from `workspace/workspaceFs/runRg` onto the os tools: the subprocess now
* goes through `IHostProcessService.spawn` instead of the session
* `ISessionProcessRunner.exec`.
*/

View file

@ -12,7 +12,7 @@
* list before `AgentProfileCatalogService` constructs.
*/
import { collectGitContext } from '#/session/sessionFs/gitContext';
import { collectGitContext } from './gitContext';
import { registerAgentProfile } from '#/app/agentProfileCatalog/contribution';
import {
renderSystemPrompt,

View file

@ -1,13 +1,18 @@
/**
* `process` domain (L2) `ISessionProcessRunner` implementation.
* `process` domain (L2) the default `ISessionProcessRunner` implementation.
*
* Resolves the default cwd from the session's `ISessionContext` and delegates
* the actual host spawn to the App-scope `IHostProcessService`. A per-call
* `options.cwd` wins over the seeded cwd. A per-call `options.env` is overlaid
* onto `process.env` and passed as the child's complete env bag (the host
* replaces the child env with what we pass); when `options.env` is omitted we
* pass `undefined` so the child inherits `process.env` verbatim. Bound at
* Session scope.
* pass `undefined` so the child inherits `process.env` verbatim.
*
* This Session-scope registration is the DEFAULT for scopes built without a
* workspace handler (test hosts, harness agents). Real sessions get the
* handler-shared Workspace-scope runner (`workspaceProcess`) as a scope seed
* from `workspaceHandler`, which shadows this registration same pattern as
* the other workspace-capability injection contracts.
*/
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';

View file

@ -1,45 +0,0 @@
/**
* `sessionFsWatch` domain (L2) workspace-confined filesystem change feed.
*
* Defines the `ISessionFsWatchService` that turns the os `IHostFsWatchService`
* raw events into a workspace-relative, debounced, `.gitignore`-aware change
* feed (`FsChangeEvent`) for the session. Callers declare the set of
* workspace-relative paths they care about; events outside that subtree are
* dropped. Session-scoped the scope itself is the session, so no
* `sessionId` is threaded through.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { Event } from '#/_base/event';
export type FsChangeKind = 'file' | 'directory' | 'symlink';
export type FsChangeAction = 'created' | 'modified' | 'deleted';
export interface FsChangeEntry {
path: string;
change: FsChangeAction;
kind: FsChangeKind;
size_delta?: number | undefined;
etag?: string | undefined;
}
export interface FsChangeEvent {
changes: FsChangeEntry[];
coalesced_window_ms: number;
truncated?: boolean | undefined;
count?: number | undefined;
}
export interface ISessionFsWatchService {
readonly _serviceBrand: undefined;
setWatchedPaths(paths: readonly string[]): void;
readonly watchedPaths: readonly string[];
readonly onDidChangeFiles: Event<FsChangeEvent>;
}
export const ISessionFsWatchService: ServiceIdentifier<ISessionFsWatchService> =
createDecorator<ISessionFsWatchService>('sessionFsWatchService');

View file

@ -0,0 +1,32 @@
/**
* `sessionToolPolicyGate` domain (L1) seeded workspace tool-veto contract.
*
* Defines `ISessionToolPolicyGate`, the pure-data injection contract the
* Workspace-scope `workspaceToolPolicy` hands to every Session scope it
* creates: the workspace's os-level disabled-tool set as a live read view
* plus its change event. The contract carries no IO capability probing and
* workspace config live on the workspace side; the Agent-scope `toolPolicy`
* and `toolActivation` read this seed and apply the veto (it outranks every
* Agent-side policy layer). Seeded into the Session scope by
* `workspaceHandler` when the session is materialized; a no-op default
* registration keeps scopes built without a handler (tests) resolvable.
* Session-scoped.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { ScopeSeed } from '#/_base/di/scope';
import type { Event } from '#/_base/event';
export interface ISessionToolPolicyGate {
readonly _serviceBrand: undefined;
readonly disabledTools: readonly string[];
readonly onDidChange: Event<void>;
}
export const ISessionToolPolicyGate: ServiceIdentifier<ISessionToolPolicyGate> =
createDecorator<ISessionToolPolicyGate>('sessionToolPolicyGate');
export function sessionToolPolicyGateSeed(gate: ISessionToolPolicyGate): ScopeSeed {
return [[ISessionToolPolicyGate as ServiceIdentifier<unknown>, gate]];
}

View file

@ -0,0 +1,29 @@
/**
* `sessionToolPolicyGate` domain (L1) no-op default `ISessionToolPolicyGate`.
*
* An empty gate (nothing vetoed, never changes) registered at Session scope
* so Session/Agent scopes materialized WITHOUT a workspace handler test
* hosts, harness agents still resolve the contract. The handler's seed
* (`sessionToolPolicyGateSeed`) shadows this registration for real sessions,
* the same way every other workspace-resource injection contract works.
*/
import { Event } from '#/_base/event';
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { ISessionToolPolicyGate } from './sessionToolPolicyGate';
export class NoopSessionToolPolicyGate implements ISessionToolPolicyGate {
declare readonly _serviceBrand: undefined;
readonly disabledTools: readonly string[] = [];
readonly onDidChange = Event.None as Event<void>;
}
registerScopedService(
LifecycleScope.Session,
ISessionToolPolicyGate,
NoopSessionToolPolicyGate,
ScopeActivation.OnScopeCreated,
'sessionToolPolicyGate',
);

View file

@ -1,5 +1,5 @@
/**
* `sessionFs` domain error codes.
* `workspaceFs` domain error codes.
*/
import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes';

View file

@ -1,13 +1,14 @@
/**
* `sessionFs` domain (L2) wire-shaped filesystem operations.
* `workspaceFs` domain (L3) wire-shaped filesystem operations.
*
* Defines the `ISessionFsService` that backs the fs REST surface: content search,
* content grep, and git status/diff, together with the zod DTO schemas the
* transports validate against. It orchestrates the os `IHostFileSystem`
* (file IO, resolved against the workspace root) plus `ISessionProcessRunner`
* (for `rg` / `git` / `gh`). Git status/diff DTOs live in the `git` domain.
* Session-scoped the scope itself is the session, so no `sessionId` is
* threaded through.
* Defines the `IWorkspaceFsService` that backs the fs REST surface: content
* search, content grep, and git status/diff, together with the zod DTO
* schemas the transports validate against. It orchestrates the os
* `IHostFileSystem` (file IO, resolved against the workspace root) plus the
* handler-shared `ISessionProcessRunner` (for `rg`). Git status/diff DTOs
* live in the `git` domain. Workspace-scoped one instance per handler,
* pinned to the handler root (chdir is gone, so the root never changes); the
* edge resolves it through any live session of the workspace.
*/
import { z } from 'zod';
@ -231,7 +232,7 @@ export interface FsDownloadResolved {
readonly modifiedAt: Date;
}
export interface ISessionFsService {
export interface IWorkspaceFsService {
readonly _serviceBrand: undefined;
list(req: FsListRequest): Promise<FsListResponse>;
@ -248,5 +249,5 @@ export interface ISessionFsService {
resolveDownload(relPath: string): Promise<FsDownloadResolved>;
}
export const ISessionFsService: ServiceIdentifier<ISessionFsService> =
createDecorator<ISessionFsService>('sessionFsService');
export const IWorkspaceFsService: ServiceIdentifier<IWorkspaceFsService> =
createDecorator<IWorkspaceFsService>('workspaceFsService');

View file

@ -1,5 +1,5 @@
/**
* `sessionFs` domain (L2) `runCommand` helper over `ISessionProcessRunner`.
* `workspaceFs` domain (L3) `runCommand` helper over `ISessionProcessRunner`.
*
* Collects a child process's full stdout/stderr and exit code through the
* Agent's backend-pluggable `ISessionProcessRunner`, with optional `AbortSignal`

View file

@ -1,5 +1,5 @@
/**
* `sessionFs` domain (L2) pure search/grep helpers.
* `workspaceFs` domain (L3) pure search/grep helpers.
*
* Fuzzy filename scoring, glob matching, grep-pattern compilation, and
* ripgrep `--json` record parsing. No IO, no DI plain functions so they can

View file

@ -1,23 +1,25 @@
/**
* `sessionFs` domain (L2) `ISessionFsService` implementation.
* `workspaceFs` domain (L3) `IWorkspaceFsService` implementation.
*
* Backs the fs REST surface (search / grep / git status / git diff) by
* orchestrating the os `IHostFileSystem` (file IO, resolved against the
* workspace root), `ISessionProcessRunner` (`rg`), and `IGitService` (git
* root and execution environment come from the scope, so no `sessionId` is
* threaded through. Git operations are delegated to the App-scoped
* `IGitService`; this service only confines paths and computes repo-relative
* paths before calling it.
* workspace root), the handler-shared `ISessionProcessRunner` (`rg`), and
* `IWorkspaceGitService` (git status/diff bound to the handler root; this
* service only confines paths and computes repo-relative paths before
* calling it).
*
* Path confinement applies the lexical `ISessionWorkspaceContext.isWithin`
* check first, then re-verifies the candidate through `IHostFileSystem.realpath`
* (resolving the longest existing prefix, so not-yet-created paths still work):
* a symlink inside the workspace must not steer fs actions to files outside it.
* The plain-data state (`rgResolution`, `realRootsCache`) is registered into
* `sessionState` (`ISessionStateService`) and read/written through it.
* Path confinement applies a lexical within-workspace check first (the
* handler root plus the `workspaceDirs` additional-dir set, mirroring the
* Session-scope `workspaceContext` view semantics), then re-verifies the
* candidate through `IHostFileSystem.realpath` (resolving the longest
* existing prefix, so not-yet-created paths still work): a symlink inside
* the workspace must not steer fs actions to files outside it. The small
* caches (`rgResolution`, `realRootsCache`) are plain per-handler fields.
* Bound at Workspace scope one instance per handler, shared by every
* session of the workspace.
*/
import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import {
type FsDiffRequest,
@ -63,7 +65,6 @@ const FsWireErrorCode = {
import ignore, { type Ignore } from 'ignore';
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { defineState } from '#/_base/state/stateRegistry';
import {
buildEtag,
countLines,
@ -73,14 +74,14 @@ import {
guessMime,
} from '#/_base/utils/fileMeta';
import { ErrorCodes, Error2, isError2, unwrapErrorCause } from '#/errors';
import { IGitService } from '#/app/git/git';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { IHostFileSystem, type HostDirEntry, type HostFileStat } from '#/os/interface/hostFileSystem';
import { ISessionProcessRunner } from '#/session/process/processRunner';
import { ISessionStateService } from '#/session/state/sessionState';
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext';
import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs';
import { IWorkspaceGitService } from '#/workspace/workspaceGit/workspaceGit';
import { type FsDownloadResolved, type FsPathResolved, ISessionFsService } from './fs';
import { type FsDownloadResolved, type FsPathResolved, IWorkspaceFsService } from './fs';
import { readStream, runCommand } from './fsProcess';
import { ensureRgPath, type RgProbe, type RgResolution } from './rgLocator';
import {
@ -103,41 +104,43 @@ const FS_READ_MAX_BYTES = 10 * 1024 * 1024;
const HIDDEN_NAME_RE = /^\./;
const MACOS_NOISE = new Set(['.DS_Store', '.AppleDouble', '.LSOverride']);
export const sessionFsRgResolutionKey = defineState<RgResolution | null | undefined>(
'sessionFs.rgResolution',
() => undefined,
);
export const sessionFsRealRootsCacheKey = defineState<
{ readonly key: string; readonly roots: readonly string[] } | undefined
>('sessionFs.realRootsCache', () => undefined);
export class SessionFsService implements ISessionFsService {
export class WorkspaceFsService implements IWorkspaceFsService {
declare readonly _serviceBrand: undefined;
private readonly gitignoreCache = new Map<string, Ignore>();
private rgResolution: RgResolution | null | undefined = undefined;
private realRootsCache: { readonly key: string; readonly roots: readonly string[] } | undefined =
undefined;
private readonly workDir: string;
constructor(
@ISessionStateService private readonly states: ISessionStateService,
@ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext,
@IWorkspaceContext workspace: IWorkspaceContext,
@IWorkspaceDirs private readonly workspaceDirs: IWorkspaceDirs,
@IHostFileSystem private readonly hostFs: IHostFileSystem,
@ISessionProcessRunner private readonly runner: ISessionProcessRunner,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IGitService private readonly git: IGitService,
@IWorkspaceGitService private readonly git: IWorkspaceGitService,
) {
this.states.register(sessionFsRgResolutionKey);
this.states.register(sessionFsRealRootsCacheKey);
this.workDir = resolve(workspace.cwd);
}
private get rgResolution(): RgResolution | null | undefined {
return this.states.get(sessionFsRgResolutionKey);
private resolvePathInput(rel: string): string {
return isAbsolute(rel) ? resolve(rel) : resolve(this.workDir, rel);
}
private set rgResolution(value: RgResolution | null | undefined) {
this.states.set(sessionFsRgResolutionKey, value);
private isWithinWorkspace(absPath: string): boolean {
const target = resolve(absPath);
if (target === this.workDir) return true;
const rel = relative(this.workDir, target);
if (rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)) return true;
return this.workspaceDirs.additionalDirs.some((dir) => {
const r = relative(resolve(dir), target);
return r === '' || (!r.startsWith('..') && !isAbsolute(r));
});
}
private absOf(rel: string): string {
return rel === '' || rel === '.' ? this.workspace.workDir : join(this.workspace.workDir, rel);
return rel === '' || rel === '.' ? this.workDir : join(this.workDir, rel);
}
async list(req: FsListRequest): Promise<FsListResponse> {
@ -341,7 +344,7 @@ export class SessionFsService implements ISessionFsService {
} catch (err) {
throw mapFsError(err, req.path);
}
const name = rel === '.' ? basename(this.workspace.workDir) : basename(abs);
const name = rel === '.' ? basename(this.workDir) : basename(abs);
return buildFsEntry(rel, name, st, true);
}
@ -358,7 +361,7 @@ export class SessionFsService implements ISessionFsService {
resolved.map(async ({ raw, rel, abs }) => {
try {
const st = await this.hostFs.lstat(abs);
const name = rel === '.' ? basename(this.workspace.workDir) : basename(abs);
const name = rel === '.' ? basename(this.workDir) : basename(abs);
entries[raw] = buildFsEntry(rel, name, st, false);
} catch {
entries[raw] = null;
@ -482,8 +485,6 @@ export class SessionFsService implements ISessionFsService {
}
async gitStatus(req: FsGitStatusRequest): Promise<FsGitStatusResponse> {
const cwd = this.workspace.workDir;
let filter: Set<string> | undefined;
if (req.paths !== undefined && req.paths.length > 0) {
filter = new Set();
@ -492,13 +493,12 @@ export class SessionFsService implements ISessionFsService {
}
}
return this.git.status(cwd, filter);
return this.git.status(filter);
}
async diff(req: FsDiffRequest): Promise<FsDiffResponse> {
const cwd = this.workspace.workDir;
const abs = await this.resolveWithin(req.path);
return this.git.diff(cwd, this.toRel(abs), abs);
return this.git.diff(this.toRel(abs), abs);
}
private async grepWithRg(
@ -528,7 +528,7 @@ export class SessionFsService implements ISessionFsService {
args.push(req.pattern);
args.push('.');
const proc = await this.runner.exec([rgPath, ...args], { cwd: this.workspace.workDir });
const proc = await this.runner.exec([rgPath, ...args], { cwd: this.workDir });
const acc = new RgJsonAccumulator(req);
let killed = false;
@ -688,13 +688,13 @@ export class SessionFsService implements ISessionFsService {
}
private async matcher(): Promise<Ignore | undefined> {
const cwd = this.workspace.workDir;
const cwd = this.workDir;
const cached = this.gitignoreCache.get(cwd);
if (cached !== undefined) return cached;
const ig = ignore();
ig.add('.git/');
try {
const contents = await this.hostFs.readText(join(this.workspace.workDir, '.gitignore'));
const contents = await this.hostFs.readText(join(this.workDir, '.gitignore'));
ig.add(contents);
} catch {
}
@ -705,7 +705,7 @@ export class SessionFsService implements ISessionFsService {
private async resolveRg(): Promise<RgResolution | null> {
if (this.rgResolution !== undefined) return this.rgResolution;
const probe: RgProbe = {
exec: (args) => runCommand(this.runner, args, { cwd: this.workspace.workDir }),
exec: (args) => runCommand(this.runner, args, { cwd: this.workDir }),
};
try {
this.rgResolution = await ensureRgPath(probe);
@ -715,20 +715,8 @@ export class SessionFsService implements ISessionFsService {
return this.rgResolution;
}
private get realRootsCache():
| { readonly key: string; readonly roots: readonly string[] }
| undefined {
return this.states.get(sessionFsRealRootsCacheKey);
}
private set realRootsCache(
value: { readonly key: string; readonly roots: readonly string[] } | undefined,
) {
this.states.set(sessionFsRealRootsCacheKey, value);
}
private async realRoots(): Promise<readonly string[]> {
const dirs = [this.workspace.workDir, ...this.workspace.additionalDirs];
const dirs = [this.workDir, ...this.workspaceDirs.additionalDirs.map((d) => resolve(d))];
const key = dirs.join('\n');
if (this.realRootsCache?.key === key) return this.realRootsCache.roots;
const roots: string[] = [];
@ -778,8 +766,8 @@ export class SessionFsService implements ISessionFsService {
details: { path: inputPath, reason: 'dotdot_segment' },
});
}
const abs = this.workspace.resolve(inputPath);
if (!this.workspace.isWithin(abs)) {
const abs = this.resolvePathInput(inputPath);
if (!this.isWithinWorkspace(abs)) {
throw new Error2(ErrorCodes.FS_PATH_ESCAPES, `path "${inputPath}" escapes workspace`, {
details: { path: inputPath, reason: 'resolved_outside' },
});
@ -797,7 +785,7 @@ export class SessionFsService implements ISessionFsService {
}
private toRel(abs: string): string {
const cwd = this.workspace.workDir;
const cwd = this.workDir;
if (abs === cwd) return '.';
const rel = relative(cwd, abs);
if (rel === '') return '.';
@ -1027,9 +1015,9 @@ function toWireError(err: unknown): { code: number; msg: string } {
}
registerScopedService(
LifecycleScope.Session,
ISessionFsService,
SessionFsService,
LifecycleScope.Workspace,
IWorkspaceFsService,
WorkspaceFsService,
ScopeActivation.OnScopeCreated,
'sessionFs',
'workspaceFs',
);

View file

@ -0,0 +1,55 @@
/**
* `workspaceFs` domain (L3) workspace-confined filesystem change feed.
*
* Defines the `IWorkspaceFsWatchService` that turns the os
* `IHostFsWatchService` raw events into a workspace-relative, debounced,
* `.gitignore`-aware change feed (`FsChangeEvent`) for the whole handler.
* One os watcher on the workspace root is shared by every subscriber
* subscribers are the sessions of this workspace (via the transport's
* fs-watch bridge) and any Workspace-scope service that wants change
* notifications. Each subscription declares the set of workspace-relative
* paths it cares about; events outside that subtree are dropped, and every
* subscription gets its own debounce window and truncation counters, so a
* per-session feed through a subscription is indistinguishable from the old
* per-session watch service. Workspace-scoped.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { IDisposable } from '#/_base/di/lifecycle';
import type { Event } from '#/_base/event';
export type FsChangeKind = 'file' | 'directory' | 'symlink';
export type FsChangeAction = 'created' | 'modified' | 'deleted';
export interface FsChangeEntry {
path: string;
change: FsChangeAction;
kind: FsChangeKind;
size_delta?: number | undefined;
etag?: string | undefined;
}
export interface FsChangeEvent {
changes: FsChangeEntry[];
coalesced_window_ms: number;
truncated?: boolean | undefined;
count?: number | undefined;
}
export interface IWorkspaceFsWatchSubscription extends IDisposable {
setWatchedPaths(paths: readonly string[]): void;
readonly watchedPaths: readonly string[];
readonly onDidChangeFiles: Event<FsChangeEvent>;
}
export interface IWorkspaceFsWatchService {
readonly _serviceBrand: undefined;
subscribe(): IWorkspaceFsWatchSubscription;
}
export const IWorkspaceFsWatchService: ServiceIdentifier<IWorkspaceFsWatchService> =
createDecorator<IWorkspaceFsWatchService>('workspaceFsWatchService');

View file

@ -1,25 +1,25 @@
/**
* `sessionFsWatch` domain (L2) `ISessionFsWatchService` implementation.
* `workspaceFs` domain (L3) `IWorkspaceFsWatchService` implementation.
*
* Subscribes to the os `IHostFsWatchService` on the workspace root, confines
* events to the caller-declared subtree and to non-`.gitignore`d paths,
* debounces them into fixed windows and re-exposes them as workspace-relative
* `FsChangeEvent`s. The os watcher is started lazily on the first non-empty
* subscription and stopped when the subscription set becomes empty. The
* plain-data state (`watched`, `pending`, `rawCount`, `truncated`,
* `gitignoreLoaded`) is registered into `sessionState` (`ISessionStateService`)
* and read/written through it. Path confinement is lexical
* (`ISessionWorkspaceContext.isWithin`), matching `sessionFs`.
* Keeps ONE os `IHostFsWatchService` subscription on the handler root and
* fans its raw events out to every `IWorkspaceFsWatchSubscription`: the
* shared leg (the os handle plus the `.gitignore` matcher) runs once per
* handler, the per-subscriber leg (subtree confinement, debounce window,
* overflow truncation) runs once per subscription, so two sessions of the
* same workspace never hang a second os watcher. The os handle starts
* lazily when the first subscription declares a non-empty path set and
* stops when no subscription watches anything. Path confinement is lexical
* (the handler root plus the `workspaceDirs` additional-dir set), matching
* the rest of `workspaceFs`. Bound at Workspace scope.
*/
import { isAbsolute, join, relative, sep } from 'node:path';
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
import ignore, { type Ignore } from 'ignore';
import { Disposable, type IDisposable } from '#/_base/di/lifecycle';
import { Emitter, type Event } from '#/_base/event';
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { defineState } from '#/_base/state/stateRegistry';
import { ErrorCodes, Error2 } from '#/errors';
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
import {
@ -27,11 +27,15 @@ import {
type IHostFsWatchHandle,
IHostFsWatchService,
} from '#/os/interface/hostFsWatch';
import { ISessionStateService } from '#/session/state/sessionState';
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
import type { FsChangeEntry, FsChangeEvent } from './fsWatch';
import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext';
import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs';
import { ISessionFsWatchService } from './fsWatch';
import {
type FsChangeEntry,
type FsChangeEvent,
IWorkspaceFsWatchService,
type IWorkspaceFsWatchSubscription,
} from './fsWatch';
const DEFAULT_DEBOUNCE_MS = 200;
const DEFAULT_MAX_CHANGES_PER_WINDOW = 500;
@ -43,117 +47,62 @@ function readPositiveIntEnv(name: string, fallback: number): number {
return Number.isFinite(n) && n > 0 ? n : fallback;
}
export const sessionFsWatchWatchedKey = defineState<Set<string>>(
'sessionFsWatch.watched',
() => new Set<string>(),
);
export const sessionFsWatchPendingKey = defineState<FsChangeEntry[]>('sessionFsWatch.pending', () => []);
export const sessionFsWatchRawCountKey = defineState<number>('sessionFsWatch.rawCount', () => 0);
export const sessionFsWatchTruncatedKey = defineState<boolean>('sessionFsWatch.truncated', () => false);
export const sessionFsWatchGitignoreLoadedKey = defineState<boolean>(
'sessionFsWatch.gitignoreLoaded',
() => false,
);
export class SessionFsWatchService extends Disposable implements ISessionFsWatchService {
export class WorkspaceFsWatchService extends Disposable implements IWorkspaceFsWatchService {
declare readonly _serviceBrand: undefined;
private readonly emitter = this._register(new Emitter<FsChangeEvent>());
readonly onDidChangeFiles: Event<FsChangeEvent> = this.emitter.event;
private readonly subscriptions = new Set<WorkspaceFsWatchSubscription>();
private handle: IHostFsWatchHandle | undefined;
private handleSub: IDisposable | undefined;
private debounceTimer: NodeJS.Timeout | undefined;
private readonly debounceMs = readPositiveIntEnv(
'KIMI_CODE_FS_WATCH_DEBOUNCE_MS',
DEFAULT_DEBOUNCE_MS,
);
private readonly maxChangesPerWindow = readPositiveIntEnv(
'KIMI_CODE_FS_WATCH_MAX_CHANGES_PER_WINDOW',
DEFAULT_MAX_CHANGES_PER_WINDOW,
);
private gitignoreLoaded = false;
private readonly matcher: Ignore = ignore().add('.git/');
private readonly workDir: string;
constructor(
@ISessionStateService private readonly states: ISessionStateService,
@ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext,
@IWorkspaceContext workspace: IWorkspaceContext,
@IWorkspaceDirs private readonly workspaceDirs: IWorkspaceDirs,
@IHostFsWatchService private readonly hostFsWatch: IHostFsWatchService,
@IHostFileSystem private readonly hostFs: IHostFileSystem,
) {
super();
this.states.register(sessionFsWatchWatchedKey);
this.states.register(sessionFsWatchPendingKey);
this.states.register(sessionFsWatchRawCountKey);
this.states.register(sessionFsWatchTruncatedKey);
this.states.register(sessionFsWatchGitignoreLoadedKey);
this.workDir = resolve(workspace.cwd);
}
private get watched(): Set<string> {
return this.states.get(sessionFsWatchWatchedKey);
subscribe(): IWorkspaceFsWatchSubscription {
const subscription = new WorkspaceFsWatchSubscription(this);
this.subscriptions.add(subscription);
return subscription;
}
private set watched(value: Set<string>) {
this.states.set(sessionFsWatchWatchedKey, value);
}
private get pending(): FsChangeEntry[] {
return this.states.get(sessionFsWatchPendingKey);
}
private set pending(value: FsChangeEntry[]) {
this.states.set(sessionFsWatchPendingKey, value);
}
private get rawCount(): number {
return this.states.get(sessionFsWatchRawCountKey);
}
private set rawCount(value: number) {
this.states.set(sessionFsWatchRawCountKey, value);
}
private get truncated(): boolean {
return this.states.get(sessionFsWatchTruncatedKey);
}
private set truncated(value: boolean) {
this.states.set(sessionFsWatchTruncatedKey, value);
}
private get gitignoreLoaded(): boolean {
return this.states.get(sessionFsWatchGitignoreLoadedKey);
}
private set gitignoreLoaded(value: boolean) {
this.states.set(sessionFsWatchGitignoreLoadedKey, value);
}
get watchedPaths(): readonly string[] {
return Array.from(this.watched);
}
setWatchedPaths(paths: readonly string[]): void {
/** Subscription → service: confinement validation + rel normalization. */
normalizeWatchedPaths(paths: readonly string[]): Set<string> {
const next = new Set<string>();
for (const p of paths) {
const abs = this.resolveWithin(p);
next.add(this.toRel(abs));
}
this.watched = next;
if (next.size === 0) {
this.teardownHandle();
this.clearWindow();
return;
return next;
}
/** Subscription → service: a subscription's path set changed (or it disposed). */
syncHandle(): void {
for (const sub of this.subscriptions) {
if (sub.hasPaths()) {
this.ensureHandle();
return;
}
}
this.ensureHandle();
this.teardownHandle();
}
dropSubscription(subscription: WorkspaceFsWatchSubscription): void {
this.subscriptions.delete(subscription);
this.syncHandle();
}
private ensureHandle(): void {
if (this.handle !== undefined) return;
this.loadGitignore();
const handle = this.hostFsWatch.watch(this.workspace.workDir, { recursive: true });
const handle = this.hostFsWatch.watch(this.workDir, { recursive: true });
this.handle = handle;
this.handleSub = handle.onDidChange((e) => this.onRaw(e));
}
@ -168,14 +117,12 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch
private loadGitignore(): void {
if (this.gitignoreLoaded) return;
this.gitignoreLoaded = true;
void this.hostFs
.readText(join(this.workspace.workDir, '.gitignore'))
.then(
(content) => {
this.matcher.add(content);
},
() => undefined,
);
void this.hostFs.readText(join(this.workDir, '.gitignore')).then(
(content) => {
this.matcher.add(content);
},
() => undefined,
);
}
private onRaw(e: HostFsChange): void {
@ -183,8 +130,111 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch
if (rel === '.') return;
const probe = e.kind === 'directory' ? `${rel}/` : rel;
if (this.matcher.ignores(probe)) return;
if (!isUnderAny(rel, this.watched)) return;
for (const sub of this.subscriptions) {
sub.onRawChange(rel, e);
}
}
override dispose(): void {
// `sub.dispose()` removes the subscription from the set; deleting the
// current element mid-iteration is safe for JS Sets.
for (const sub of this.subscriptions) {
sub.dispose();
}
this.subscriptions.clear();
this.teardownHandle();
super.dispose();
}
private resolveWithin(inputPath: string): string {
if (inputPath === '' || inputPath === '/') {
throw new Error2(ErrorCodes.FS_PATH_ESCAPES, `path "${inputPath}" rejected (empty)`, {
details: { path: inputPath, reason: 'empty' },
});
}
if (isAbsolute(inputPath)) {
throw new Error2(ErrorCodes.FS_PATH_ESCAPES, `path "${inputPath}" rejected (absolute)`, {
details: { path: inputPath, reason: 'absolute' },
});
}
const segments = inputPath.split(/[/\\]+/);
if (segments.some((s) => s === '..')) {
throw new Error2(
ErrorCodes.FS_PATH_ESCAPES,
`path "${inputPath}" rejected (dotdot segment)`,
{ details: { path: inputPath, reason: 'dotdot_segment' } },
);
}
const abs = isAbsolute(inputPath) ? resolve(inputPath) : resolve(this.workDir, inputPath);
if (!this.isWithinWorkspace(abs)) {
throw new Error2(ErrorCodes.FS_PATH_ESCAPES, `path "${inputPath}" escapes workspace`, {
details: { path: inputPath, reason: 'resolved_outside' },
});
}
return abs;
}
private isWithinWorkspace(absPath: string): boolean {
const target = resolve(absPath);
if (target === this.workDir) return true;
const rel = relative(this.workDir, target);
if (rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)) return true;
return this.workspaceDirs.additionalDirs.some((dir) => {
const r = relative(resolve(dir), target);
return r === '' || (!r.startsWith('..') && !isAbsolute(r));
});
}
private toRel(abs: string): string {
const cwd = this.workDir;
if (abs === cwd) return '.';
const rel = relative(cwd, abs);
if (rel === '') return '.';
return rel.split(sep).join('/');
}
}
class WorkspaceFsWatchSubscription implements IWorkspaceFsWatchSubscription {
private readonly emitter = new Emitter<FsChangeEvent>();
readonly onDidChangeFiles: Event<FsChangeEvent> = this.emitter.event;
private watched = new Set<string>();
private pending: FsChangeEntry[] = [];
private rawCount = 0;
private truncated = false;
private debounceTimer: NodeJS.Timeout | undefined;
private disposed = false;
private readonly debounceMs = readPositiveIntEnv(
'KIMI_CODE_FS_WATCH_DEBOUNCE_MS',
DEFAULT_DEBOUNCE_MS,
);
private readonly maxChangesPerWindow = readPositiveIntEnv(
'KIMI_CODE_FS_WATCH_MAX_CHANGES_PER_WINDOW',
DEFAULT_MAX_CHANGES_PER_WINDOW,
);
constructor(private readonly owner: WorkspaceFsWatchService) {}
get watchedPaths(): readonly string[] {
return Array.from(this.watched);
}
hasPaths(): boolean {
return !this.disposed && this.watched.size > 0;
}
setWatchedPaths(paths: readonly string[]): void {
if (this.disposed) return;
this.watched = this.owner.normalizeWatchedPaths(paths);
if (this.watched.size === 0) {
this.clearWindow();
}
this.owner.syncHandle();
}
onRawChange(rel: string, e: HostFsChange): void {
if (this.disposed || !isUnderAny(rel, this.watched)) return;
this.pending.push({ path: rel, change: e.action, kind: e.kind });
this.rawCount += 1;
if (this.pending.length > this.maxChangesPerWindow) {
@ -200,7 +250,7 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch
private flush(): void {
this.debounceTimer = undefined;
if (this.rawCount === 0) return;
if (this.disposed || this.rawCount === 0) return;
const truncated = this.truncated;
const count = this.rawCount;
const changes = truncated ? [] : this.pending;
@ -226,46 +276,12 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch
this.truncated = false;
}
override dispose(): void {
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.clearWindow();
this.teardownHandle();
super.dispose();
}
private resolveWithin(inputPath: string): string {
if (inputPath === '' || inputPath === '/') {
throw new Error2(ErrorCodes.FS_PATH_ESCAPES, `path "${inputPath}" rejected (empty)`, {
details: { path: inputPath, reason: 'empty' },
});
}
if (isAbsolute(inputPath)) {
throw new Error2(ErrorCodes.FS_PATH_ESCAPES, `path "${inputPath}" rejected (absolute)`, {
details: { path: inputPath, reason: 'absolute' },
});
}
const segments = inputPath.split(/[/\\]+/);
if (segments.some((s) => s === '..')) {
throw new Error2(
ErrorCodes.FS_PATH_ESCAPES,
`path "${inputPath}" rejected (dotdot segment)`,
{ details: { path: inputPath, reason: 'dotdot_segment' } },
);
}
const abs = this.workspace.resolve(inputPath);
if (!this.workspace.isWithin(abs)) {
throw new Error2(ErrorCodes.FS_PATH_ESCAPES, `path "${inputPath}" escapes workspace`, {
details: { path: inputPath, reason: 'resolved_outside' },
});
}
return abs;
}
private toRel(abs: string): string {
const cwd = this.workspace.workDir;
if (abs === cwd) return '.';
const rel = relative(cwd, abs);
if (rel === '') return '.';
return rel.split(sep).join('/');
this.emitter.dispose();
this.owner.dropSubscription(this);
}
}
@ -279,9 +295,9 @@ function isUnderAny(rel: string, parents: ReadonlySet<string>): boolean {
}
registerScopedService(
LifecycleScope.Session,
ISessionFsWatchService,
SessionFsWatchService,
LifecycleScope.Workspace,
IWorkspaceFsWatchService,
WorkspaceFsWatchService,
ScopeActivation.OnScopeCreated,
'sessionFsWatch',
'workspaceFs',
);

View file

@ -1,12 +1,12 @@
/**
* `sessionFs` domain shared ripgrep (`rg`) binary locator.
* `workspaceFs` domain shared ripgrep (`rg`) binary locator.
*
* Single place that decides which `rg` the Glob and Grep paths run. The lookup
* mirrors v1's `ensureRgPath` intent (bundled-or-system, graceful degradation)
* but is driven through a caller-supplied {@link RgProbe} so it works against
* whatever execution environment the caller has Glob probes through the
* session `ISessionProcessRunner`, Grep through the shared runner as well.
* Both run `rg --version` and treat exit code 0 as "available".
* Single place that decides which `rg` the fs search/grep paths run. The
* lookup mirrors v1's `ensureRgPath` intent (bundled-or-system, graceful
* degradation) but is driven through a caller-supplied {@link RgProbe} so it
* works against whatever execution environment the caller has the fs
* surface probes through the handler-shared `ISessionProcessRunner`, running
* `rg --version` and treating exit code 0 as "available".
*
* Lookup order (first hit wins):
* 1. System `rg` on the execution-environment PATH (`rg --version`).

View file

@ -1,17 +1,13 @@
/**
* `sessionFs` domain shared ripgrep subprocess plumbing.
* `workspaceFs` domain shared ripgrep subprocess plumbing.
*
* Single place that knows how Glob spawns `rg` through the session
* `ISessionProcessRunner`: timeout / abort handling, capped stdout / stderr
* draining, two-phase kill with process disposal, and the EAGAIN retry
* predicate. Mode-specific argument building and output parsing stay in the
* tools themselves.
*
* Ported from v1 (`packages/agent-core/src/tools/support/run-rg.ts`) onto the
* v2 `ISessionProcessRunner`. Grep keeps its own `runCommand` path in
* `fsService` (it streams JSON and has a pure-node fallback); this helper is
* shared in the sense that the previously inline Glob plumbing now lives in one
* reusable module under the same `sessionFs` domain as Grep's search code.
* Timeout / abort handling, capped stdout / stderr draining, two-phase kill
* with process disposal, and the EAGAIN retry predicate for spawning `rg`
* through the handler-shared `ISessionProcessRunner`. Ported from v1
* (`packages/agent-core/src/tools/support/run-rg.ts`). The fs surface's Grep
* keeps its own `runCommand` path in `fsService` (it streams JSON and has a
* pure-node fallback); this helper is the reusable module for callers that
* want the simpler buffered shape.
*/
import type { Readable } from 'node:stream';

View file

@ -0,0 +1,22 @@
/**
* `workspaceGit` domain (L2) handler-root-bound git facade contract.
*
* Defines the `IWorkspaceGitService`, a thin facade over the App-scope
* `IGitService` pinned to this handler's workspace root: callers pass
* repo-relative paths only, never a `cwd`. The PR-status cache stays in the
* App-scope `IGitService`, keyed by `cwd` with a 60 s TTL one entry per
* workspace root, which is exactly per-handler ownership. Workspace-scoped.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { FsDiffResponse, FsGitStatusResponse } from '#/app/git/git';
export interface IWorkspaceGitService {
readonly _serviceBrand: undefined;
status(pathFilter?: ReadonlySet<string>): Promise<FsGitStatusResponse>;
diff(relPath: string, absPath: string): Promise<FsDiffResponse>;
}
export const IWorkspaceGitService: ServiceIdentifier<IWorkspaceGitService> =
createDecorator<IWorkspaceGitService>('workspaceGitService');

View file

@ -0,0 +1,39 @@
/**
* `workspaceGit` domain (L2) `IWorkspaceGitService` implementation.
*
* Delegates every call to the App-scope `IGitService` with `cwd` pinned to
* the handler's workspace root (`IWorkspaceContext.cwd`). Owns no state: the
* PR-status cache (keyed by `cwd`, 60 s TTL) already lives in the App-scope
* service, which makes it per-handler in effect. Bound at Workspace scope.
*/
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { type FsDiffResponse, type FsGitStatusResponse, IGitService } from '#/app/git/git';
import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext';
import { IWorkspaceGitService } from './workspaceGit';
export class WorkspaceGitService implements IWorkspaceGitService {
declare readonly _serviceBrand: undefined;
constructor(
@IWorkspaceContext private readonly workspace: IWorkspaceContext,
@IGitService private readonly git: IGitService,
) {}
status(pathFilter?: ReadonlySet<string>): Promise<FsGitStatusResponse> {
return this.git.status(this.workspace.cwd, pathFilter);
}
diff(relPath: string, absPath: string): Promise<FsDiffResponse> {
return this.git.diff(this.workspace.cwd, relPath, absPath);
}
}
registerScopedService(
LifecycleScope.Workspace,
IWorkspaceGitService,
WorkspaceGitService,
ScopeActivation.OnScopeCreated,
'workspaceGit',
);

View file

@ -17,11 +17,11 @@
* resources as pure-data read views (the injection contracts):
* `sessionSkillCatalogData` / `sessionAgentProfileCatalogData` (the merged
* catalogs), `sessionInstructionsProvider` (the AGENTS.md snapshot),
* `sessionMcpHandle` (the one shared MCP connection manager), and
* `sessionMcpHandle` (the one shared MCP connection manager),
* `sessionWorkspaceInfo` (the shared additional-directory set caller
* `additionalDirs` options union into it at materialization; the
* `workspaceDirs` service owns persistence and the `local.toml` watch)
* discovery,
* `workspaceDirs` service owns persistence and the `local.toml` watch), and
* `sessionToolPolicyGate` (the workspace's os-level tool veto) discovery,
* watching and connecting all live on the Workspace-scope services; session
* consumers read the seeds and refresh off their change events.
* Materializes the session's initial metadata on
@ -103,8 +103,10 @@ import {
type SessionLifecycleHookSlots,
} from '#/session/sessionLifecycleHooks/sessionLifecycleHooks';
import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata';
import { ISessionProcessRunner } from '#/session/process/processRunner';
import { sessionSkillCatalogDataSeed } from '#/session/sessionSkillCatalog/skillCatalogData';
import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy';
import { sessionToolPolicyGateSeed } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate';
import { IWireService } from '#/wire/wire';
import {
AGENT_WIRE_RECORD_KEY,
@ -117,6 +119,7 @@ import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs';
import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions';
import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp';
import { IWorkspaceSkillCatalog } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalog';
import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy';
import { agentScopeOf, sessionDirOf, sessionScopeOf } from './addressing';
import {
@ -168,6 +171,8 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan
@IWorkspaceInstructionsService private readonly instructions: IWorkspaceInstructionsService,
@IWorkspaceMcpService private readonly mcp: IWorkspaceMcpService,
@IWorkspaceDirs private readonly workspaceDirs: IWorkspaceDirs,
@IWorkspaceToolPolicy private readonly toolPolicy: IWorkspaceToolPolicy,
@ISessionProcessRunner private readonly processRunner: ISessionProcessRunner,
) {
super();
}
@ -259,6 +264,11 @@ export class WorkspaceHandlerService extends Disposable implements IWorkspaceHan
...sessionInstructionsProviderSeed(this.instructions.sessionProvider()),
...sessionMcpHandleSeed(this.mcp.sessionHandle()),
...sessionWorkspaceInfoSeed(this.workspaceDirs.sessionInfo()),
...sessionToolPolicyGateSeed(this.toolPolicy.sessionGate()),
// The handler-shared Workspace-scope process runner shadows the
// Session-scope default registration in every session of this
// workspace (same seed pattern as the contracts above).
[ISessionProcessRunner, this.processRunner],
],
},
) as ISessionScopeHandle;

View file

@ -0,0 +1,66 @@
/**
* `workspaceProcess` domain (L2) `ISessionProcessRunner` implementation.
*
* Resolves the default cwd from the handler's `IWorkspaceContext` (chdir is
* gone, so the workspace root is the one fixed default) and delegates the
* actual host spawn to the App-scope `IHostProcessService`. A per-call
* `options.cwd` wins over the handler root. A per-call `options.env` is
* overlaid onto `process.env` and passed as the child's complete env bag (the
* host replaces the child env with what we pass); when `options.env` is
* omitted we pass `undefined` so the child inherits `process.env` verbatim.
*
* Bound at Workspace scope one runner per handler, shared by every session
* of the workspace. The contract (`ISessionProcessRunner`) stays in the
* session domain so Session/Agent consumers keep importing it without
* crossing the Workspace-tier import ban; the Workspace-scope registration
* reaches them through ordinary parent-scope resolution.
*/
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { IHostProcessService } from '#/os/interface/hostProcess';
import { type IProcess, ISessionProcessRunner, type ProcessExecOptions } from '#/session/process/processRunner';
import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext';
export class WorkspaceProcessRunnerService implements ISessionProcessRunner {
declare readonly _serviceBrand: undefined;
constructor(
@IWorkspaceContext private readonly ctx: IWorkspaceContext,
@IHostProcessService private readonly hostProcess: IHostProcessService,
) {}
async exec(args: readonly string[], options?: ProcessExecOptions): Promise<IProcess> {
const command = args[0];
if (command === undefined) {
throw new Error(
'WorkspaceProcessRunnerService.exec(): at least one argument (the command to run) is required.',
);
}
const restArgs = args.slice(1);
const cwd = options?.cwd ?? this.ctx.cwd;
const env = this._buildExecEnv(options?.env);
return this.hostProcess.spawn(command, restArgs, { cwd, env });
}
private _buildExecEnv(
invocationEnv: Record<string, string> | undefined,
): Record<string, string> | undefined {
if (invocationEnv === undefined) {
return undefined;
}
return {
...(process.env as Record<string, string>),
...invocationEnv,
};
}
}
registerScopedService(
LifecycleScope.Workspace,
ISessionProcessRunner,
WorkspaceProcessRunnerService,
ScopeActivation.OnScopeCreated,
'workspaceProcess',
);

View file

@ -0,0 +1,30 @@
/**
* `workspaceToolPolicy` domain (L2) os-level tool enable/disable contract.
*
* Defines the `IWorkspaceToolPolicy`, the Workspace-scope owner of the
* tool-veto set that outranks every Agent-side policy layer (profile ×
* `[tools]` config × session denylist): a tool the workspace disables never
* activates and can never execute, no matter what the upper layers allow.
* The set derives from the handler's runtime capabilities the
* `IWorkspaceContext.osBackendId` keying pair records which os backend the
* handler binds; a runtime whose backend lacks a capability (e.g. no PTY)
* contributes the dependent tool names here. The set reaches every session
* of the handler through the `ISessionToolPolicyGate` seed
* (`sessionGate()`), a live read view over this service. Workspace-scoped.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { Event } from '#/_base/event';
import type { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate';
export interface IWorkspaceToolPolicy {
readonly _serviceBrand: undefined;
disabledTools(): readonly string[];
readonly onDidChange: Event<void>;
sessionGate(): ISessionToolPolicyGate;
}
export const IWorkspaceToolPolicy: ServiceIdentifier<IWorkspaceToolPolicy> =
createDecorator<IWorkspaceToolPolicy>('workspaceToolPolicy');

View file

@ -0,0 +1,75 @@
/**
* `workspaceToolPolicy` domain (L2) `IWorkspaceToolPolicy` implementation.
*
* Computes the os-level disabled-tool set from the runtime capabilities the
* handler binds (`IWorkspaceContext.osBackendId`). The local runtime carries
* the full node os backend (fs / process / PTY / watch), so it vetoes
* nothing; runtimes with a reduced os backend land their own capability
* mapping (or seed their own `IWorkspaceToolPolicy`) when they arrive. A
* workspace-level tools config does not exist yet when one does, it joins
* the capability set here and fires `onDidChange`. Bound at Workspace scope.
*/
import { Disposable } from '#/_base/di/lifecycle';
import { Event } from '#/_base/event';
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import type { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate';
import {
IWorkspaceContext,
LOCAL_OS_BACKEND_ID,
} from '#/workspace/workspaceContext/workspaceContext';
import { IWorkspaceToolPolicy } from './workspaceToolPolicy';
/**
* The os-level veto set for one os backend. Pure so the capability mapping is
* unit-testable without a scope. The local backend has every capability, so
* it disables nothing.
*
* An UNKNOWN backend also disables nothing that fail-open is a deliberate
* choice: the veto is a safety override, and guessing restrictions for a
* backend nobody has mapped would break tools on a runtime that may be fully
* capable. The cost is the opposite silence: a NEW runtime that forgets to
* extend this mapping ships with no os-level veto. When a new os backend
* lands, extend this mapping (or seed its own `IWorkspaceToolPolicy`) as
* part of the runtime bring-up.
*/
export function computeCapabilityDisabledTools(osBackendId: string): readonly string[] {
if (osBackendId === LOCAL_OS_BACKEND_ID) return [];
return [];
}
export class WorkspaceToolPolicyService extends Disposable implements IWorkspaceToolPolicy {
declare readonly _serviceBrand: undefined;
private readonly disabled: readonly string[];
readonly onDidChange = Event.None as Event<void>;
constructor(@IWorkspaceContext workspace: IWorkspaceContext) {
super();
this.disabled = computeCapabilityDisabledTools(workspace.osBackendId);
}
disabledTools(): readonly string[] {
return this.disabled;
}
sessionGate(): ISessionToolPolicyGate {
const current = (): readonly string[] => this.disabledTools();
return {
_serviceBrand: undefined,
onDidChange: this.onDidChange,
get disabledTools() {
return current();
},
};
}
}
registerScopedService(
LifecycleScope.Workspace,
IWorkspaceToolPolicy,
WorkspaceToolPolicyService,
ScopeActivation.OnScopeCreated,
'workspaceToolPolicy',
);

View file

@ -19,6 +19,7 @@ import { IAtomicDocumentStore, type IAtomicDocumentStore as AtomicDocumentStore
import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog';
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy';
import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate';
import { IWireService } from '#/wire/wire';
import type { ExecutableTool, ToolExecution, ToolResult, ToolSource } from '#/tool/toolContract';
@ -806,6 +807,57 @@ describe('AgentToolPolicyService executor enforcement', () => {
expect(probe.calls).toBe(0);
});
// Phase-4 behavior contract: the workspace (os-level) veto — seeded as
// `ISessionToolPolicyGate` — blocks direct execution just like the classic
// layers, and it wins over every one of them.
it('blocks a direct builtin call through the workspace tool-policy gate', async () => {
ctx = createTestAgent(
hostEnvironmentServices(homeDir),
sessionService(ISessionToolPolicyGate, {
_serviceBrand: undefined,
disabledTools: ['PolicyProbe'],
onDidChange: Event.None as Event<void>,
} satisfies ISessionToolPolicyGate),
);
await ctx.get(IAgentProfileService).bind({
profile: DEFAULT_AGENT_PROFILE_NAME,
model: MOCK_MODEL,
});
const probe = new PolicyProbeTool('PolicyProbe');
ctx.get(IAgentToolRegistryService).register(probe);
const result = await executeDirectToolCall(ctx, 'PolicyProbe');
expect(result).toMatchObject({
isError: true,
output: 'Tool "PolicyProbe" is disabled by the active tool policy',
});
expect(probe.calls).toBe(0);
});
// The prompt projection goes through the same workspace veto: a profile
// whose prompt renders `skillActive` must see the Skill tool as inactive
// when the gate disables it (profileService's `isToolActiveForProfile`).
it('applies the workspace gate in the prompt projection (skillActive)', async () => {
registerAgentProfile({
name: 'gate-skill-active',
tools: ['Read', 'Skill'],
systemPrompt: (context) => `skill-active:${String(context.skillActive)}`,
});
ctx = createTestAgent(
hostEnvironmentServices(homeDir),
sessionService(ISessionToolPolicyGate, {
_serviceBrand: undefined,
disabledTools: ['Skill'],
onDidChange: Event.None as Event<void>,
} satisfies ISessionToolPolicyGate),
);
const profileService = ctx.get(IAgentProfileService);
await profileService.bind({ profile: 'gate-skill-active', model: MOCK_MODEL });
expect(profileService.data().systemPrompt).toBe('skill-active:false');
});
it('does not reject select_tools, the policy-gated disclosure loading entry', async () => {
ctx = createTestAgent(hostEnvironmentServices(homeDir));
// The default profile's allowlist does not name select_tools; the guard

View file

@ -9,6 +9,7 @@ import {
} from '#/_base/di/scope';
import { createServices } from '#/_base/di/test';
import { IEventBus } from '#/app/event/eventBus';
import { Event } from '#/_base/event';
import { IAgentProfileService, type ProfileData } from '#/agent/profile/profile';
import { IAgentToolActivationService } from '#/agent/toolActivation/toolActivation';
import { AgentToolActivationService } from '#/agent/toolActivation/toolActivationService';
@ -20,6 +21,7 @@ import {
} from '#/agent/toolRegistry/toolContribution';
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService';
import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate';
import type { AgentTool, ToolExecution } from '#/tool/toolContract';
class StubTool implements AgentTool {
@ -68,6 +70,7 @@ describe('AgentToolActivationService', () => {
activeToolNames?: readonly string[];
disallowedTools?: readonly string[];
} = {};
const gateData: { disabledTools: readonly string[] } = { disabledTools: [] };
function createActivationHost() {
disposables = new DisposableStore();
@ -80,6 +83,13 @@ describe('AgentToolActivationService', () => {
reg.definePartialInstance(IEventBus, {
subscribe: () => toDisposable(() => {}),
});
reg.defineInstance(ISessionToolPolicyGate, {
_serviceBrand: undefined,
get disabledTools() {
return gateData.disabledTools;
},
onDidChange: Event.None as Event<void>,
} satisfies ISessionToolPolicyGate);
reg.define(IAgentToolRegistryService, AgentToolRegistryService);
reg.define(IAgentToolActivationService, AgentToolActivationService);
reg.define(IAlphaTool, AlphaTool);
@ -98,6 +108,7 @@ describe('AgentToolActivationService', () => {
_clearAgentToolContributionsForTests();
delete profileData.activeToolNames;
delete profileData.disallowedTools;
gateData.disabledTools = [];
});
afterEach(() => {
@ -173,6 +184,38 @@ describe('AgentToolActivationService', () => {
expect(gammaConstructions).toBe(0);
});
// Phase-4 behavior contract: the workspace (os-level) veto outranks the
// profile — a workspace-disabled tool never activates, so it never lands
// in `registry.list()` (and therefore never reaches the model's schema).
it('honors the workspace tool-policy veto before the profile', async () => {
gateData.disabledTools = ['Beta'];
registerAgentToolService(IAlphaTool, AlphaTool, { name: 'Alpha' });
registerAgentToolService(IBetaTool, BetaTool, { name: 'Beta' });
const ix = createActivationHost();
await ix.get(IAgentToolActivationService).activate();
const registry = ix.get(IAgentToolRegistryService);
expect(registry.resolve('Alpha')).toBeInstanceOf(AlphaTool);
expect(registry.resolve('Beta')).toBeUndefined();
expect(registry.list().map((t) => t.name)).not.toContain('Beta');
expect(betaConstructions).toBe(0);
});
it('lets the workspace veto win over a profile allowlist', async () => {
profileData.activeToolNames = ['Alpha', 'Beta'];
gateData.disabledTools = ['Beta'];
registerAgentToolService(IAlphaTool, AlphaTool, { name: 'Alpha' });
registerAgentToolService(IBetaTool, BetaTool, { name: 'Beta' });
const ix = createActivationHost();
await ix.get(IAgentToolActivationService).activate();
const registry = ix.get(IAgentToolRegistryService);
expect(registry.resolve('Alpha')).toBeInstanceOf(AlphaTool);
expect(registry.resolve('Beta')).toBeUndefined();
});
it('is idempotent and picks up newly allowed tools on re-activation', async () => {
profileData.activeToolNames = ['Alpha'];
registerAgentToolService(IAlphaTool, AlphaTool, { name: 'Alpha' });

View file

@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import {
findInactiveToolPatterns,
isToolActive,
isToolActiveComposed,
literalToolNames,
} from '#/agent/toolPolicy/evaluate';
@ -61,3 +62,51 @@ describe('literalToolNames', () => {
).toEqual(['Read']);
});
});
describe('isToolActiveComposed workspace veto', () => {
it('lets the workspace layer veto a tool every other layer allows', () => {
expect(
isToolActiveComposed(
{
workspaceDisabledTools: ['Bash'],
profile: { tools: ['Bash', 'Read'] },
global: { enabled: ['Bash', 'Read'] },
sessionDisabledTools: [],
},
'Bash',
),
).toBe(false);
expect(
isToolActiveComposed(
{
workspaceDisabledTools: ['Bash'],
profile: { tools: ['Bash', 'Read'] },
},
'Read',
),
).toBe(true);
});
it('applies the workspace veto to MCP tools by glob', () => {
expect(
isToolActiveComposed(
{ workspaceDisabledTools: ['mcp__blocked__*'], profile: {} },
'mcp__blocked__write',
'mcp',
),
).toBe(false);
});
it('stays inactive when any classic layer also denies', () => {
expect(
isToolActiveComposed(
{
workspaceDisabledTools: ['Bash'],
profile: {},
sessionDisabledTools: ['Bash'],
},
'Bash',
),
).toBe(false);
});
});

View file

@ -26,6 +26,7 @@ import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'
import type { McpConnectionManager } from '#/agent/mcp/connection-manager';
import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy';
import { ISessionProcessRunner } from '#/session/process/processRunner';
import { IWorkspaceSkillCatalog } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalog';
import { IWorkspaceAgentProfileCatalog } from '#/workspace/workspaceAgentProfileCatalog/workspaceAgentProfileCatalog';
import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions';
@ -38,6 +39,8 @@ import { encodeWorkDirKey } from '#/_base/utils/workdir-slug';
import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext';
import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler';
import { WorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandlerService';
import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy';
import { WorkspaceToolPolicyService } from '#/workspace/workspaceToolPolicy/workspaceToolPolicyService';
import { recordingTelemetry, type TelemetryRecord } from '../telemetry/stubs';
import { stubLog } from '../../_base/log/stubs';
@ -120,6 +123,10 @@ function sessionStubs(): ReturnType<typeof stubPair>[] {
disabledTools: () => [],
setDisabledTools: () => Promise.resolve(),
} satisfies ISessionToolPolicy),
stubPair(ISessionProcessRunner, {
_serviceBrand: undefined,
exec: () => Promise.reject(new Error('process exec is not supported in this test')),
} satisfies ISessionProcessRunner),
stubPair(IWorkspaceSkillCatalog, (() => {
const catalog = {
getSkill: () => undefined,
@ -252,6 +259,13 @@ describe('WorkspaceLifecycleService', () => {
ScopeActivation.OnScopeCreated,
'workspaceHandler',
);
registerScopedService(
LifecycleScope.Workspace,
IWorkspaceToolPolicy,
WorkspaceToolPolicyService,
ScopeActivation.OnScopeCreated,
'workspaceToolPolicy',
);
registerScopedService(
LifecycleScope.Workspace,
IWorkspaceDirs,

View file

@ -32,6 +32,8 @@ import { IHostEnvironment } from '#/os/interface/hostEnvironment';
import { IHostFileSystem, type HostFileStat } from '#/os/interface/hostFileSystem';
import { IHostProcessService, type IHostProcess } from '#/os/interface/hostProcess';
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate';
import { Event } from '#/_base/event';
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
import {
type GrepInput,
@ -311,6 +313,11 @@ describe('GrepTool', () => {
reg.define(IGrepTool, ProductionGrepTool);
reg.define(IAgentToolRegistryService, AgentToolRegistryService);
reg.define(IAgentToolActivationService, AgentToolActivationService);
reg.defineInstance(ISessionToolPolicyGate, {
_serviceBrand: undefined,
disabledTools: [],
onDidChange: Event.None as Event<void>,
} satisfies ISessionToolPolicyGate);
reg.definePartialInstance(IAgentProfileService, {
data: () => ({}) as unknown as ProfileData,
});

View file

@ -58,6 +58,7 @@ import { IHostFileSystem } from '#/os/interface/hostFileSystem';
import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog';
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy';
import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate';
import { _clearAgentToolContributionsForTests } from '#/agent/toolRegistry/toolContribution';
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
import '#/agent/toolActivation/toolActivationService';
@ -329,6 +330,11 @@ describe('AgentLifecycleService', () => {
disabledTools: () => [],
setDisabledTools: () => Promise.resolve(),
} as unknown as ISessionToolPolicy);
ix.stub(ISessionToolPolicyGate, {
_serviceBrand: undefined,
disabledTools: [],
onDidChange: Event.None as Event<void>,
} satisfies ISessionToolPolicyGate);
permissionModeSetMode = vi.fn();
ix.stub(IAgentPermissionModeService, {
_serviceBrand: undefined,

View file

@ -6,7 +6,7 @@ import {
collectGitContext,
parseProjectName,
sanitizeRemoteUrl,
} from '#/session/sessionFs/gitContext';
} from '#/session/agentLifecycle/profile/gitContext';
import type { ILogger } from '#/_base/log/log';
import type { IProcess, ISessionProcessRunner } from '#/session/process/processRunner';

View file

@ -1,210 +0,0 @@
/**
* `sessionFsWatch` domain (L2) verifies confinement to the declared subtree,
* workspace-relative path mapping, debounce coalescing, window truncation,
* `.gitignore` filtering and handle lifecycle, using a fake os watcher.
*/
import { isAbsolute, join, relative, resolve } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LifecycleScope } from '#/_base/di/scope';
import { createScopedTestHost, stubPair } from '#/_base/di/test';
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
import {
type HostFsChange,
type IHostFsWatchHandle,
IHostFsWatchService,
} from '#/os/interface/hostFsWatch';
import { ISessionStateService } from '#/session/state/sessionState';
import { SessionStateService } from '#/session/state/sessionStateService';
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
import type { FsChangeEvent } from '#/session/sessionFs/fsWatch';
import { ISessionFsWatchService } from '#/session/sessionFs/fsWatch';
import { SessionFsWatchService } from '#/session/sessionFs/fsWatchService';
const WORK_DIR = '/repo';
void SessionFsWatchService;
function stubWorkspace(): ISessionWorkspaceContext {
return {
_serviceBrand: undefined,
workDir: WORK_DIR,
additionalDirs: [],
resolve: (rel) => (isAbsolute(rel) ? rel : resolve(WORK_DIR, rel)),
isWithin: (abs) => {
const r = relative(WORK_DIR, abs);
return r === '' || (!r.startsWith('..') && !isAbsolute(r));
},
assertAllowed: (abs) => abs,
};
}
interface FakeWatch {
readonly service: IHostFsWatchService;
readonly watchCalls: string[];
fire: (rel: string, action: HostFsChange['action'], kind?: HostFsChange['kind']) => void;
readonly disposed: () => boolean;
}
function fakeHostFsWatch(): FakeWatch {
const watchCalls: string[] = [];
let listener: ((e: HostFsChange) => void) | undefined;
let disposed = false;
const handle: IHostFsWatchHandle = {
onDidChange: (l) => {
listener = l;
return { dispose: () => (listener = undefined) };
},
dispose: () => {
disposed = true;
listener = undefined;
},
};
const service: IHostFsWatchService = {
_serviceBrand: undefined,
watch: (path) => {
watchCalls.push(path);
disposed = false;
return handle;
},
};
return {
service,
watchCalls,
fire: (rel, action, kind = 'file') =>
listener?.({ path: join(WORK_DIR, rel), action, kind }),
disposed: () => disposed,
};
}
function fakeHostFs(gitignore?: string): IHostFileSystem {
return {
_serviceBrand: undefined,
readText: async (p: string) => {
if (gitignore !== undefined && p === join(WORK_DIR, '.gitignore')) return gitignore;
const err = new Error(`ENOENT: ${p}`) as NodeJS.ErrnoException;
err.code = 'ENOENT';
throw err;
},
} as unknown as IHostFileSystem;
}
interface Harness {
readonly svc: ISessionFsWatchService;
readonly watch: FakeWatch;
readonly events: FsChangeEvent[];
}
function makeSession(gitignore?: string): Harness {
const watch = fakeHostFsWatch();
const host = createScopedTestHost();
const session = host.child(LifecycleScope.Session, 's1', [
stubPair(ISessionStateService, new SessionStateService()),
stubPair(ISessionWorkspaceContext, stubWorkspace()),
stubPair(IHostFsWatchService, watch.service),
stubPair(IHostFileSystem, fakeHostFs(gitignore)),
]);
const svc = session.accessor.get(ISessionFsWatchService);
const events: FsChangeEvent[] = [];
svc.onDidChangeFiles((e) => events.push(e));
disposers.push(() => host.dispose());
return { svc, watch, events };
}
const disposers: Array<() => void> = [];
describe('SessionFsWatchService', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
for (const d of disposers.splice(0)) d();
vi.useRealTimers();
});
it('starts the os watcher on the workspace root for a non-empty subscription', () => {
const { svc, watch } = makeSession();
svc.setWatchedPaths(['src']);
expect(watch.watchCalls).toEqual([WORK_DIR]);
expect(svc.watchedPaths).toEqual(['src']);
});
it('drops events outside the subscribed subtree', () => {
const { svc, watch, events } = makeSession();
svc.setWatchedPaths(['src']);
watch.fire('src/a.ts', 'created');
watch.fire('lib/b.ts', 'created');
vi.advanceTimersByTime(200);
expect(events).toHaveLength(1);
expect(events[0]?.changes).toEqual([{ path: 'src/a.ts', change: 'created', kind: 'file' }]);
});
it('coalesces changes within a window into one event', () => {
const { svc, watch, events } = makeSession();
svc.setWatchedPaths(['.']);
watch.fire('a.ts', 'created');
watch.fire('b.ts', 'modified');
watch.fire('c.ts', 'deleted');
vi.advanceTimersByTime(200);
expect(events).toHaveLength(1);
expect(events[0]?.coalesced_window_ms).toBe(200);
expect(events[0]?.changes).toHaveLength(3);
});
it('marks the event truncated when the window overflows', () => {
const { svc, watch, events } = makeSession();
svc.setWatchedPaths(['.']);
for (let i = 0; i < 501; i++) watch.fire(`f${i}.ts`, 'created');
vi.advanceTimersByTime(200);
expect(events).toHaveLength(1);
expect(events[0]?.truncated).toBe(true);
expect(events[0]?.changes).toEqual([]);
expect(events[0]?.count).toBe(501);
});
it('filters out `.gitignore`d paths once loaded', async () => {
const { svc, watch, events } = makeSession('dist/\n');
svc.setWatchedPaths(['.']);
await Promise.resolve();
await Promise.resolve();
watch.fire('dist/x.js', 'created');
watch.fire('src/keep.ts', 'created');
vi.advanceTimersByTime(200);
expect(events).toHaveLength(1);
expect(events[0]?.changes.map((c) => c.path)).toEqual(['src/keep.ts']);
});
it('rejects paths that escape the workspace', () => {
const { svc } = makeSession();
expect(() => svc.setWatchedPaths(['../x'])).toThrowError(/escapes workspace|rejected/);
expect(() => svc.setWatchedPaths(['/abs'])).toThrowError(/rejected/);
});
it('disposes the os handle when the subscription set becomes empty', () => {
const { svc, watch } = makeSession();
svc.setWatchedPaths(['src']);
expect(watch.disposed()).toBe(false);
svc.setWatchedPaths([]);
expect(watch.disposed()).toBe(true);
});
it('does not fire after the service is disposed', () => {
const { svc, watch, events } = makeSession();
svc.setWatchedPaths(['.']);
watch.fire('a.ts', 'created');
(svc as unknown as { dispose: () => void }).dispose();
vi.advanceTimersByTime(200);
expect(events).toHaveLength(0);
});
});

View file

@ -58,6 +58,7 @@ import { FileProjectLocalConfigService } from '#/persistence/backends/node-fs/pr
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy';
import { ISessionProcessRunner } from '#/session/process/processRunner';
import { ISessionStateService } from '#/session/state/sessionState';
import { SessionStateService } from '#/session/state/sessionStateService';
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
@ -67,6 +68,8 @@ import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs';
import { WorkspaceDirsService } from '#/workspace/workspaceDirs/workspaceDirsService';
import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler';
import { WorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandlerService';
import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy';
import { WorkspaceToolPolicyService } from '#/workspace/workspaceToolPolicy/workspaceToolPolicyService';
import { IWorkspaceInstructionsService } from '#/workspace/workspaceInstructions/workspaceInstructions';
import { IWorkspaceMcpService } from '#/workspace/workspaceMcp/workspaceMcp';
import { IWorkspaceSkillCatalog } from '#/workspace/workspaceSkillCatalog/workspaceSkillCatalog';
@ -194,6 +197,13 @@ describe('workspace add-dir (handler chain)', () => {
ScopeActivation.OnScopeCreated,
'workspaceHandler',
);
registerScopedService(
LifecycleScope.Workspace,
IWorkspaceToolPolicy,
WorkspaceToolPolicyService,
ScopeActivation.OnScopeCreated,
'workspaceToolPolicy',
);
registerScopedService(
LifecycleScope.Workspace,
IWorkspaceDirs,
@ -318,6 +328,10 @@ describe('workspace add-dir (handler chain)', () => {
disabledTools: () => [],
setDisabledTools: () => Promise.resolve(),
} as unknown as ISessionToolPolicy),
stubPair(ISessionProcessRunner, {
_serviceBrand: undefined,
exec: () => Promise.reject(new Error('process exec is not supported in this test')),
} satisfies ISessionProcessRunner),
stubPair(IAgentLifecycleService, {
_serviceBrand: undefined,
onDidCreate: () => ({ dispose: () => {} }),

View file

@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest';
import { type IProcess, type ISessionProcessRunner } from '#/session/process/processRunner';
import { runCommand, type RunCommandOptions } from '#/session/sessionFs/fsProcess';
import { runCommand, type RunCommandOptions } from '#/workspace/workspaceFs/fsProcess';
interface FakeProcessOptions {
readonly stdout?: string;

View file

@ -7,7 +7,7 @@ import {
matchesAnyGlob,
rgPath,
stripTrailingNewline,
} from '#/session/sessionFs/fsSearch';
} from '#/workspace/workspaceFs/fsSearch';
describe('computeFuzzyScore', () => {
it('returns 0 for an empty query', () => {

View file

@ -13,27 +13,48 @@ import { createScopedTestHost, stubPair } from '#/_base/di/test';
import { IGitService } from '#/app/git/git';
import { ErrorCodes, Error2 } from '#/errors';
import { type HostDirEntry, IHostFileSystem } from '#/os/interface/hostFileSystem';
import { ISessionFsService } from '#/session/sessionFs/fs';
import { SessionFsService } from '#/session/sessionFs/fsService';
import { IWorkspaceFsService } from '#/workspace/workspaceFs/fs';
import { WorkspaceFsService } from '#/workspace/workspaceFs/fsService';
import { ISessionProcessRunner, type IProcess } from '#/session/process/processRunner';
import { ISessionStateService } from '#/session/state/sessionState';
import { SessionStateService } from '#/session/state/sessionStateService';
import { ITelemetryService, type TelemetryProperties } from '#/app/telemetry/telemetry';
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext';
import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs';
import { IWorkspaceGitService } from '#/workspace/workspaceGit/workspaceGit';
const WORK_DIR = '/repo';
function stubWorkspace(): ISessionWorkspaceContext {
function stubWorkspaceContext(): IWorkspaceContext {
return {
_serviceBrand: undefined,
workDir: WORK_DIR,
workspaceId: 'w',
cwd: WORK_DIR,
source: 'local',
meta: { id: 'w', root: WORK_DIR, name: 'proj', createdAt: 1, lastOpenedAt: 1 },
persistenceScope: 'sessions/w',
osBackendId: 'local',
persistenceBackendId: 'local',
};
}
function stubWorkspaceDirs(): IWorkspaceDirs {
return {
_serviceBrand: undefined,
ready: Promise.resolve(),
additionalDirs: [],
resolve: (rel) => (isAbsolute(rel) ? rel : resolve(WORK_DIR, rel)),
isWithin: (abs) => {
const r = relative(WORK_DIR, abs);
return r === '' || (!r.startsWith('..') && !isAbsolute(r));
onDidChange: () => ({ dispose: () => {} }),
addDir: () => Promise.reject(new Error('not supported in tests')),
mergeAdditionalDirs: () => Promise.resolve(),
sessionInfo: () => {
throw new Error('not supported in tests');
},
assertAllowed: (abs) => abs,
};
}
function workspaceGitStub(git: IGitService): IWorkspaceGitService {
return {
_serviceBrand: undefined,
status: (filter) => git.status(WORK_DIR, filter),
diff: (rel, abs) => git.diff(WORK_DIR, rel, abs),
};
}
@ -295,18 +316,11 @@ function telemetryStub(events: Array<{ event: string; properties: Record<string,
beforeEach(() => {
_clearScopedRegistryForTests();
registerScopedService(
LifecycleScope.Session,
ISessionStateService,
SessionStateService,
ScopeActivation.OnScopeCreated,
'state',
);
registerScopedService(
LifecycleScope.Session,
ISessionFsService,
SessionFsService,
LifecycleScope.Workspace,
IWorkspaceFsService,
WorkspaceFsService,
ScopeActivation.OnDemand,
'sessionFs',
'workspaceFs',
);
});
@ -341,21 +355,22 @@ function makeSession(
symlinks: readonly string[] = [],
runner?: ISessionProcessRunner,
symlinkTargets: Record<string, string> = {},
): ISessionFsService {
): IWorkspaceFsService {
host = createScopedTestHost();
const session = host.child(LifecycleScope.Session, 's1', [
stubPair(ISessionWorkspaceContext, stubWorkspace()),
const workspace = host.child(LifecycleScope.Workspace, 'w1', [
stubPair(IWorkspaceContext, stubWorkspaceContext()),
stubPair(IWorkspaceDirs, stubWorkspaceDirs()),
stubPair(IHostFileSystem, fakeFs(files, symlinks, symlinkTargets)),
stubPair(ISessionProcessRunner, runner ?? fakeRunner(handler)),
stubPair(ITelemetryService, telemetryStub(events)),
stubPair(IGitService, git),
stubPair(IWorkspaceGitService, workspaceGitStub(git)),
]);
return session.accessor.get(ISessionFsService);
return workspace.accessor.get(IWorkspaceFsService);
}
const emptyHandler: RunHandler = () => ({ stdout: '', exitCode: 0 });
describe('SessionFsService.gitStatus', () => {
describe('WorkspaceFsService.gitStatus', () => {
it('delegates to IGitService with the session cwd and a confined filter', async () => {
const calls: Array<{ cwd: string; filter: ReadonlySet<string> | undefined }> = [];
const git: IGitService = {
@ -397,7 +412,7 @@ describe('SessionFsService.gitStatus', () => {
});
});
describe('SessionFsService.diff', () => {
describe('WorkspaceFsService.diff', () => {
it('delegates to IGitService with confined rel and abs paths', async () => {
const calls: Array<{ cwd: string; rel: string; abs: string }> = [];
const git: IGitService = {
@ -434,7 +449,7 @@ describe('SessionFsService.diff', () => {
});
});
describe('SessionFsService.search', () => {
describe('WorkspaceFsService.search', () => {
it('finds files by fuzzy query and respects the result cap', async () => {
const fs = makeSession(
{ 'src/foo.ts': '', 'src/bar.ts': '', 'README.md': '' },
@ -462,7 +477,7 @@ describe('SessionFsService.search', () => {
});
});
describe('SessionFsService.grep', () => {
describe('WorkspaceFsService.grep', () => {
it('falls back to the node implementation when rg is unavailable', async () => {
const events: Array<{ event: string; properties: Record<string, unknown> }> = [];
const fs = makeSession(
@ -577,7 +592,7 @@ describe('SessionFsService.grep', () => {
});
});
describe('SessionFsService.list', () => {
describe('WorkspaceFsService.list', () => {
it('lists files and directories with kinds', async () => {
const fs = makeSession(
{ 'src/a.ts': '', 'src/sub/b.ts': '', 'README.md': '' },
@ -630,7 +645,7 @@ describe('SessionFsService.list', () => {
});
});
describe('SessionFsService.read', () => {
describe('WorkspaceFsService.read', () => {
it('reads utf-8 content with metadata', async () => {
const fs = makeSession({ 'src/a.ts': 'hello\nworld\n' }, emptyHandler);
const result = await fs.read({
@ -678,7 +693,7 @@ describe('SessionFsService.read', () => {
});
});
describe('SessionFsService.stat', () => {
describe('WorkspaceFsService.stat', () => {
it('returns a file entry with mime', async () => {
const fs = makeSession({ 'src/a.ts': 'content' }, emptyHandler);
const entry = await fs.stat({ path: 'src/a.ts' });
@ -694,7 +709,7 @@ describe('SessionFsService.stat', () => {
});
});
describe('SessionFsService.statMany', () => {
describe('WorkspaceFsService.statMany', () => {
it('returns null per missing path and entries for present ones', async () => {
const fs = makeSession({ 'a.txt': 'hi' }, emptyHandler);
const result = await fs.statMany({ paths: ['a.txt', 'missing.txt'] });
@ -703,7 +718,7 @@ describe('SessionFsService.statMany', () => {
});
});
describe('SessionFsService.listMany', () => {
describe('WorkspaceFsService.listMany', () => {
it('returns results per path and partial_errors for failures', async () => {
const fs = makeSession({ 'a.txt': '' }, emptyHandler);
const result = await fs.listMany({
@ -720,7 +735,7 @@ describe('SessionFsService.listMany', () => {
});
});
describe('SessionFsService.mkdir', () => {
describe('WorkspaceFsService.mkdir', () => {
it('creates a directory and returns its entry', async () => {
const fs = makeSession({}, emptyHandler);
const entry = await fs.mkdir({ path: 'newdir', recursive: false });
@ -736,7 +751,7 @@ describe('SessionFsService.mkdir', () => {
});
});
describe('SessionFsService.resolvePath', () => {
describe('WorkspaceFsService.resolvePath', () => {
it('returns absolute, relative, and isDirectory', async () => {
const fs = makeSession({ 'src/a.ts': '' }, emptyHandler);
const res = await fs.resolvePath('src/a.ts');
@ -746,7 +761,7 @@ describe('SessionFsService.resolvePath', () => {
});
});
describe('SessionFsService.resolveDownload', () => {
describe('WorkspaceFsService.resolveDownload', () => {
it('returns size, etag, mime, modifiedAt', async () => {
const fs = makeSession({ 'a.txt': 'hello' }, emptyHandler);
const res = await fs.resolveDownload('a.txt');
@ -762,10 +777,10 @@ describe('SessionFsService.resolveDownload', () => {
});
});
describe('SessionFsService symlink confinement', () => {
describe('WorkspaceFsService symlink confinement', () => {
const escapeTargets = { docs: '/outside' };
function escapeSession(): ISessionFsService {
function escapeSession(): IWorkspaceFsService {
return makeSession(
{ 'src/a.ts': '' },
emptyHandler,

View file

@ -0,0 +1,298 @@
/**
* `workspaceFs` fs-watch (L3) verifies the shared os watcher fan-out:
* confinement to each subscription's declared subtree, workspace-relative
* path mapping, per-subscription debounce coalescing and window truncation,
* `.gitignore` filtering, and the handle lifecycle (one os watch per handler
* no matter how many subscriptions), using a fake os watcher.
*/
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LifecycleScope } from '#/_base/di/scope';
import { createScopedTestHost, stubPair } from '#/_base/di/test';
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
import {
type HostFsChange,
type IHostFsWatchHandle,
IHostFsWatchService,
} from '#/os/interface/hostFsWatch';
import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext';
import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs';
import type { FsChangeEvent } from '#/workspace/workspaceFs/fsWatch';
import { IWorkspaceFsWatchService } from '#/workspace/workspaceFs/fsWatch';
import { WorkspaceFsWatchService } from '#/workspace/workspaceFs/fsWatchService';
const WORK_DIR = '/repo';
void WorkspaceFsWatchService;
function stubWorkspaceContext(): IWorkspaceContext {
return {
_serviceBrand: undefined,
workspaceId: 'w',
cwd: WORK_DIR,
source: 'local',
meta: { id: 'w', root: WORK_DIR, name: 'proj', createdAt: 1, lastOpenedAt: 1 },
persistenceScope: 'sessions/w',
osBackendId: 'local',
persistenceBackendId: 'local',
};
}
function stubWorkspaceDirs(): IWorkspaceDirs {
return {
_serviceBrand: undefined,
ready: Promise.resolve(),
additionalDirs: [],
onDidChange: () => ({ dispose: () => {} }),
addDir: () => Promise.reject(new Error('not supported in tests')),
mergeAdditionalDirs: () => Promise.resolve(),
sessionInfo: () => {
throw new Error('not supported in tests');
},
};
}
interface FakeWatch {
readonly service: IHostFsWatchService;
readonly watchCalls: string[];
fire: (rel: string, action: HostFsChange['action'], kind?: HostFsChange['kind']) => void;
readonly disposedCount: () => number;
}
function fakeHostFsWatch(): FakeWatch {
const watchCalls: string[] = [];
let listener: ((e: HostFsChange) => void) | undefined;
let disposedCount = 0;
const handle: IHostFsWatchHandle = {
onDidChange: (l) => {
listener = l;
return { dispose: () => (listener = undefined) };
},
dispose: () => {
disposedCount += 1;
listener = undefined;
},
};
const service: IHostFsWatchService = {
_serviceBrand: undefined,
watch: (path) => {
watchCalls.push(path);
return handle;
},
};
return {
service,
watchCalls,
fire: (rel, action, kind = 'file') =>
listener?.({ path: join(WORK_DIR, rel), action, kind }),
disposedCount: () => disposedCount,
};
}
function fakeHostFs(gitignore?: string): IHostFileSystem {
return {
_serviceBrand: undefined,
readText: async (p: string) => {
if (gitignore !== undefined && p === join(WORK_DIR, '.gitignore')) return gitignore;
const err = new Error(`ENOENT: ${p}`) as NodeJS.ErrnoException;
err.code = 'ENOENT';
throw err;
},
} as unknown as IHostFileSystem;
}
interface Harness {
readonly svc: IWorkspaceFsWatchService;
readonly watch: FakeWatch;
}
function makeWorkspace(gitignore?: string): Harness {
const watch = fakeHostFsWatch();
const host = createScopedTestHost();
const workspace = host.child(LifecycleScope.Workspace, 'w1', [
stubPair(IWorkspaceContext, stubWorkspaceContext()),
stubPair(IWorkspaceDirs, stubWorkspaceDirs()),
stubPair(IHostFsWatchService, watch.service),
stubPair(IHostFileSystem, fakeHostFs(gitignore)),
]);
const svc = workspace.accessor.get(IWorkspaceFsWatchService);
disposers.push(() => host.dispose());
return { svc, watch };
}
function collect(sub: { onDidChangeFiles: (l: (e: FsChangeEvent) => void) => unknown }): FsChangeEvent[] {
const events: FsChangeEvent[] = [];
sub.onDidChangeFiles((e) => events.push(e));
return events;
}
const disposers: Array<() => void> = [];
describe('WorkspaceFsWatchService', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
for (const d of disposers.splice(0)) d();
vi.useRealTimers();
});
it('starts the os watcher on the workspace root for a non-empty subscription', () => {
const { svc, watch } = makeWorkspace();
const sub = svc.subscribe();
sub.setWatchedPaths(['src']);
expect(watch.watchCalls).toEqual([WORK_DIR]);
expect(sub.watchedPaths).toEqual(['src']);
});
it('drops events outside the subscribed subtree', () => {
const { svc, watch } = makeWorkspace();
const sub = svc.subscribe();
sub.setWatchedPaths(['src']);
const events = collect(sub);
watch.fire('src/a.ts', 'created');
watch.fire('lib/b.ts', 'created');
vi.advanceTimersByTime(200);
expect(events).toHaveLength(1);
expect(events[0]?.changes).toEqual([{ path: 'src/a.ts', change: 'created', kind: 'file' }]);
});
it('coalesces changes within a window into one event', () => {
const { svc, watch } = makeWorkspace();
const sub = svc.subscribe();
sub.setWatchedPaths(['.']);
const events = collect(sub);
watch.fire('a.ts', 'created');
watch.fire('b.ts', 'modified');
watch.fire('c.ts', 'deleted');
vi.advanceTimersByTime(200);
expect(events).toHaveLength(1);
expect(events[0]?.coalesced_window_ms).toBe(200);
expect(events[0]?.changes).toHaveLength(3);
});
it('marks the event truncated when the window overflows', () => {
const { svc, watch } = makeWorkspace();
const sub = svc.subscribe();
sub.setWatchedPaths(['.']);
const events = collect(sub);
for (let i = 0; i < 501; i++) watch.fire(`f${i}.ts`, 'created');
vi.advanceTimersByTime(200);
expect(events).toHaveLength(1);
expect(events[0]?.truncated).toBe(true);
expect(events[0]?.changes).toEqual([]);
expect(events[0]?.count).toBe(501);
});
it('filters out `.gitignore`d paths once loaded', async () => {
const { svc, watch } = makeWorkspace('dist/\n');
const sub = svc.subscribe();
sub.setWatchedPaths(['.']);
const events = collect(sub);
await Promise.resolve();
await Promise.resolve();
watch.fire('dist/x.js', 'created');
watch.fire('src/keep.ts', 'created');
vi.advanceTimersByTime(200);
expect(events).toHaveLength(1);
expect(events[0]?.changes.map((c) => c.path)).toEqual(['src/keep.ts']);
});
it('rejects paths that escape the workspace', () => {
const { svc } = makeWorkspace();
const sub = svc.subscribe();
expect(() => sub.setWatchedPaths(['../x'])).toThrowError(/escapes workspace|rejected/);
expect(() => sub.setWatchedPaths(['/abs'])).toThrowError(/rejected/);
});
it('disposes the os handle when the last watched path set becomes empty', () => {
const { svc, watch } = makeWorkspace();
const sub = svc.subscribe();
sub.setWatchedPaths(['src']);
expect(watch.disposedCount()).toBe(0);
sub.setWatchedPaths([]);
expect(watch.disposedCount()).toBe(1);
});
it('does not fire after the service is disposed', () => {
const { svc, watch } = makeWorkspace();
const sub = svc.subscribe();
sub.setWatchedPaths(['.']);
const events = collect(sub);
watch.fire('a.ts', 'created');
(svc as unknown as { dispose: () => void }).dispose();
vi.advanceTimersByTime(200);
expect(events).toHaveLength(0);
});
// Phase-4 behavior contract: two sessions of one workspace share the
// handler's single os watch — subscriptions fan out, they never hang a
// second watcher.
it('shares one os watch across subscriptions and fans events out per subscription', () => {
const { svc, watch } = makeWorkspace();
const subA = svc.subscribe();
const subB = svc.subscribe();
subA.setWatchedPaths(['src']);
subB.setWatchedPaths(['lib']);
subB.setWatchedPaths(['lib', 'src']);
const eventsA = collect(subA);
const eventsB = collect(subB);
expect(watch.watchCalls).toEqual([WORK_DIR]);
watch.fire('src/a.ts', 'created');
watch.fire('lib/b.ts', 'modified');
watch.fire('other/c.ts', 'deleted');
vi.advanceTimersByTime(200);
expect(eventsA).toHaveLength(1);
expect(eventsA[0]?.changes).toEqual([{ path: 'src/a.ts', change: 'created', kind: 'file' }]);
expect(eventsB).toHaveLength(1);
expect(eventsB[0]?.changes).toHaveLength(2);
});
it('keeps the shared os watch alive while any subscription still watches', () => {
const { svc, watch } = makeWorkspace();
const subA = svc.subscribe();
const subB = svc.subscribe();
subA.setWatchedPaths(['src']);
subB.setWatchedPaths(['lib']);
expect(watch.watchCalls).toEqual([WORK_DIR]);
subA.setWatchedPaths([]);
expect(watch.disposedCount()).toBe(0);
subB.dispose();
expect(watch.disposedCount()).toBe(1);
});
it('gives each subscription its own truncation counters', () => {
const { svc, watch } = makeWorkspace();
const subA = svc.subscribe();
const subB = svc.subscribe();
subA.setWatchedPaths(['.']);
subB.setWatchedPaths(['.']);
const eventsA = collect(subA);
const eventsB = collect(subB);
for (let i = 0; i < 501; i++) watch.fire(`f${i}.ts`, 'created');
vi.advanceTimersByTime(200);
expect(eventsA).toHaveLength(1);
expect(eventsB).toHaveLength(1);
expect(eventsA[0]?.truncated).toBe(true);
expect(eventsB[0]?.truncated).toBe(true);
});
});

View file

@ -44,6 +44,8 @@ import { WorkspaceLifecycleService } from '#/app/workspaceLifecycle/workspaceLif
import { resumeSessionById } from '#/app/workspaceLifecycle/sessionLookup';
import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler';
import { WorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandlerService';
import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy';
import { WorkspaceToolPolicyService } from '#/workspace/workspaceToolPolicy/workspaceToolPolicyService';
import { IAgentActivityView } from '#/agent/activityView/activityView';
import { ISessionExternalHooksService } from '#/session/externalHooks/externalHooks';
import {
@ -52,6 +54,7 @@ import {
} from '#/session/sessionLifecycleHooks/sessionLifecycleHooks';
import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy';
import { ISessionProcessRunner } from '#/session/process/processRunner';
import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex';
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
@ -481,6 +484,13 @@ describe('WorkspaceHandlerService', () => {
ScopeActivation.OnScopeCreated,
'workspaceHandler',
);
registerScopedService(
LifecycleScope.Workspace,
IWorkspaceToolPolicy,
WorkspaceToolPolicyService,
ScopeActivation.OnScopeCreated,
'workspaceToolPolicy',
);
registerScopedService(
LifecycleScope.Workspace,
IWorkspaceDirs,
@ -524,6 +534,10 @@ describe('WorkspaceHandlerService', () => {
stubPair(IHostEnvironment, hostEnvironmentStub()),
stubPair(IWorkspaceSkillCatalog, workspaceSkillCatalogStub()),
stubPair(ISessionToolPolicy, sessionToolPolicyStub()),
stubPair(ISessionProcessRunner, {
_serviceBrand: undefined,
exec: () => Promise.reject(new Error('process exec is not supported in this test')),
} satisfies ISessionProcessRunner),
stubPair(IWorkspaceAgentProfileCatalog, workspaceAgentProfileCatalogStub()),
stubPair(IWorkspaceInstructionsService, workspaceInstructionsStub()),
stubPair(IWorkspaceService, workspaceStub()),

View file

@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Readable } from 'node:stream';
import {
LifecycleScope,
ScopeActivation,
_clearScopedRegistryForTests,
registerScopedService,
} from '#/_base/di/scope';
import { createScopedTestHost, stubPair } from '#/_base/di/test';
import { IHostProcessService } from '#/os/interface/hostProcess';
import { HostProcessService } from '#/os/backends/node-local/hostProcessService';
import { ISessionProcessRunner } from '#/session/process/processRunner';
import { WorkspaceProcessRunnerService } from '#/workspace/workspaceProcess/workspaceProcessRunnerService';
import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext';
async function collect(stream: Readable): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(chunk as Buffer);
}
return Buffer.concat(chunks).toString('utf8');
}
describe('WorkspaceProcessRunnerService', () => {
let dir: string;
beforeEach(async () => {
_clearScopedRegistryForTests();
registerScopedService(
LifecycleScope.App,
IHostProcessService,
HostProcessService,
ScopeActivation.OnDemand,
'hostProcess',
);
registerScopedService(
LifecycleScope.Workspace,
ISessionProcessRunner,
WorkspaceProcessRunnerService,
ScopeActivation.OnDemand,
'workspaceProcess',
);
dir = await mkdtemp(join(tmpdir(), 'procrunner-'));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
async function makeRunner(): Promise<ISessionProcessRunner> {
const host = createScopedTestHost();
const workspace = host.child(LifecycleScope.Workspace, 'w', [
stubPair(IWorkspaceContext, {
_serviceBrand: undefined,
workspaceId: 'w',
cwd: dir,
source: 'local',
meta: { id: 'w', root: dir, name: 'proj', createdAt: 1, lastOpenedAt: 1 },
persistenceScope: 'sessions/w',
osBackendId: 'local',
persistenceBackendId: 'local',
} satisfies IWorkspaceContext),
]);
return workspace.accessor.get(ISessionProcessRunner);
}
it('exec runs a command and captures stdout + exit code', async () => {
const runner = await makeRunner();
const proc = await runner.exec(['node', '-e', 'process.stdout.write("ok")']);
const out = await collect(proc.stdout);
expect(out).toBe('ok');
expect(await proc.wait()).toBe(0);
expect(proc.exitCode).toBe(0);
});
it('exec overlays per-call env', async () => {
const runner = await makeRunner();
const proc = await runner.exec(
['node', '-e', 'process.stdout.write(process.env.FOO ?? "")'],
{ env: { FOO: 'bar' } },
);
const out = await collect(proc.stdout);
expect(out).toBe('bar');
expect(await proc.wait()).toBe(0);
});
});

View file

@ -66,8 +66,11 @@ import { SessionSkillCatalogService } from '#/session/sessionSkillCatalog/skillC
import { ISessionStateService } from '#/session/state/sessionState';
import { SessionStateService } from '#/session/state/sessionStateService';
import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy';
import { ISessionProcessRunner } from '#/session/process/processRunner';
import { IWorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandler';
import { WorkspaceHandlerService } from '#/workspace/workspaceHandler/workspaceHandlerService';
import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy';
import { WorkspaceToolPolicyService } from '#/workspace/workspaceToolPolicy/workspaceToolPolicyService';
import { IWorkspaceAgentProfileCatalog } from '#/workspace/workspaceAgentProfileCatalog/workspaceAgentProfileCatalog';
import { WorkspaceAgentProfileCatalogService } from '#/workspace/workspaceAgentProfileCatalog/workspaceAgentProfileCatalogService';
import { ExplicitFileAgentSource, IExplicitFileAgentSource } from '#/workspace/workspaceAgentProfileCatalog/explicitFileAgentSource';
@ -143,6 +146,13 @@ describe('workspace resource sharing (handler chain)', () => {
ScopeActivation.OnScopeCreated,
'workspaceHandler',
);
registerScopedService(
LifecycleScope.Workspace,
IWorkspaceToolPolicy,
WorkspaceToolPolicyService,
ScopeActivation.OnScopeCreated,
'workspaceToolPolicy',
);
registerScopedService(
LifecycleScope.Workspace,
IWorkspaceSkillCatalog,
@ -299,6 +309,10 @@ describe('workspace resource sharing (handler chain)', () => {
disabledTools: () => [],
setDisabledTools: () => Promise.resolve(),
} as unknown as ISessionToolPolicy),
stubPair(ISessionProcessRunner, {
_serviceBrand: undefined,
exec: () => Promise.reject(new Error('process exec is not supported in this test')),
} satisfies ISessionProcessRunner),
stubPair(IAgentLifecycleService, {
_serviceBrand: undefined,
onDidCreate: () => ({ dispose: () => {} }),

View file

@ -0,0 +1,66 @@
/**
* `workspaceToolPolicy` domain (L2) verifies the capability-derived veto
* set and the `ISessionToolPolicyGate` live read view the handler seeds into
* every session.
*/
import { afterEach, describe, expect, it } from 'vitest';
import { LifecycleScope } from '#/_base/di/scope';
import { createScopedTestHost, stubPair, type ScopedTestHost } from '#/_base/di/test';
import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext';
import { IWorkspaceToolPolicy } from '#/workspace/workspaceToolPolicy/workspaceToolPolicy';
import {
WorkspaceToolPolicyService,
computeCapabilityDisabledTools,
} from '#/workspace/workspaceToolPolicy/workspaceToolPolicyService';
void WorkspaceToolPolicyService;
const WORK_DIR = '/repo';
function stubWorkspaceContext(osBackendId = 'local'): IWorkspaceContext {
return {
_serviceBrand: undefined,
workspaceId: 'w',
cwd: WORK_DIR,
source: 'local',
meta: { id: 'w', root: WORK_DIR, name: 'proj', createdAt: 1, lastOpenedAt: 1 },
persistenceScope: 'sessions/w',
osBackendId,
persistenceBackendId: 'local',
};
}
let host: ScopedTestHost | undefined;
afterEach(() => {
host?.dispose();
host = undefined;
});
function makePolicy(osBackendId = 'local'): IWorkspaceToolPolicy {
host = createScopedTestHost();
const workspace = host.child(LifecycleScope.Workspace, 'w1', [
stubPair(IWorkspaceContext, stubWorkspaceContext(osBackendId)),
]);
return workspace.accessor.get(IWorkspaceToolPolicy);
}
describe('computeCapabilityDisabledTools', () => {
it('disables nothing for the local os backend', () => {
expect(computeCapabilityDisabledTools('local')).toEqual([]);
});
});
describe('WorkspaceToolPolicyService', () => {
it('exposes an empty veto set on the local runtime', () => {
expect(makePolicy().disabledTools()).toEqual([]);
});
it('hands sessions a live gate view over the same veto set', () => {
const gate = makePolicy().sessionGate();
expect(gate.disabledTools).toEqual([]);
expect(gate.onDidChange).toBeDefined();
});
});

View file

@ -33,7 +33,7 @@ import {
fsStatManyResponseSchema,
fsStatRequestSchema,
fsStatResponseSchema,
} from '@moonshot-ai/agent-core-v2/session/sessionFs/fs';
} from '@moonshot-ai/agent-core-v2/workspace/workspaceFs/fs';
import { z } from 'zod';
import {

View file

@ -3,17 +3,20 @@
*
* Mirrors `packages/server/src/routes/fs.ts` path-for-path and schema-for-schema
* so existing v1 clients keep working against server-v2. Backed by the v2
* Session-scoped `ISessionFsService` (`agent-core-v2/src/sessionFs`): the route resolves
* the session from the URL, then dispatches `fs:<action>` to the matching
* `ISessionFsService` method. The wire schema comes from the engine's own
* `sessionFs` domain contract (`agent-core-v2`).
* Workspace-scoped `IWorkspaceFsService` (`agent-core-v2/src/workspace/workspaceFs`):
* the route resolves the session from the URL, then dispatches `fs:<action>`
* to the matching `IWorkspaceFsService` method the session's accessor
* resolves it from its parent Workspace scope (the handler), which is the
* "session → handler → workspace fs" chain (chdir is gone, so the handler
* root is the one fixed fs root). The wire schema comes from the engine's own
* `workspaceFs` domain contract (`agent-core-v2`).
*/
import { createReadStream } from 'node:fs';
import {
ErrorCodes,
ISessionFsService,
IWorkspaceFsService,
getLiveSessionById,
resumeSessionById,
isError2,
@ -31,7 +34,7 @@ import {
fsSearchRequestSchema,
fsStatManyRequestSchema,
fsStatRequestSchema,
} from '@moonshot-ai/agent-core-v2/session/sessionFs/fs';
} from '@moonshot-ai/agent-core-v2/workspace/workspaceFs/fs';
import { z } from 'zod';
import { errEnvelope, okEnvelope } from '../envelope';
@ -100,12 +103,14 @@ const FS_ACTIONS = [
type FsAction = (typeof FS_ACTIONS)[number];
const FS_TAIL_PREFIX = 'fs:';
function resolveFs(core: Scope, sessionId: string): ISessionFsService {
function resolveFs(core: Scope, sessionId: string): IWorkspaceFsService {
const session = getLiveSessionById(core.accessor, sessionId);
if (session === undefined) {
throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`);
}
return session.accessor.get(ISessionFsService);
// The fs service lives on the session's parent Workspace scope (the
// handler): one instance per workspace, pinned to the handler root.
return session.accessor.get(IWorkspaceFsService);
}
export function registerFsRoutes(app: FsRouteHost, core: Scope): void {
@ -260,7 +265,7 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void {
return;
}
let resolved: Awaited<ReturnType<ISessionFsService['resolveDownload']>>;
let resolved: Awaited<ReturnType<IWorkspaceFsService['resolveDownload']>>;
try {
resolved = await resolveFs(core, session_id).resolveDownload(relPath);
} catch (err) {
@ -333,7 +338,7 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void {
}
// ---------------------------------------------------------------------------
// Action handlers — thin adapters: parse body, call ISessionFsService, wrap result.
// Action handlers — thin adapters: parse body, call IWorkspaceFsService, wrap result.
// ---------------------------------------------------------------------------
type Req = { id: string; body: unknown };

View file

@ -1,9 +1,9 @@
/**
* `FsWatchBridge` volatile `/api/v1/ws` delivery for filesystem changes.
*
* Turns the core `ISessionFsWatchService.onDidChangeFiles` feed into
* `event.fs.changed` frames on the v1 WebSocket, byte-compatible with the v1
* server (`packages/server/.../fsWatcherService.ts`):
* Turns the core `IWorkspaceFsWatchService` feed into `event.fs.changed`
* frames on the v1 WebSocket, byte-compatible with the v1 server
* (`packages/server/.../fsWatcherService.ts`):
*
* client `{type:'watch_fs_add', id, payload:{session_id, paths}}`
* client `{type:'watch_fs_remove', id, payload:{session_id, paths}}`
@ -18,9 +18,12 @@
* to the socket they never enter the broadcaster / journal (fs changes are
* volatile: on overflow the client sees `truncated` and re-syncs).
*
* The core `ISessionFsWatchService` keeps a single subscription set per
* session; the bridge drives it with the **union** of every connection's
* paths for that session, then re-filters per connection on the way out.
* The core watch service is Workspace-scoped: one os watcher per handler,
* shared by every session of the workspace. The bridge holds ONE
* `IWorkspaceFsWatchSubscription` per session (driven with the union of every
* connection's paths for that session) and re-filters per connection on the
* way out two sessions of one workspace fan out from the same handler
* watch instead of hanging a second os watcher.
*/
import { isAbsolute, relative, sep } from 'node:path';
@ -28,12 +31,16 @@ import { isAbsolute, relative, sep } from 'node:path';
import {
type IDisposable,
type ISessionScopeHandle,
ISessionFsWatchService,
IWorkspaceFsWatchService,
ISessionWorkspaceContext,
getLiveSessionById,
type Scope,
} from '@moonshot-ai/agent-core-v2';
import type { FsChangeEntry, FsChangeEvent } from '@moonshot-ai/agent-core-v2/session/sessionFs/fsWatch';
import type {
FsChangeEntry,
FsChangeEvent,
IWorkspaceFsWatchSubscription,
} from '@moonshot-ai/agent-core-v2/workspace/workspaceFs/fsWatch';
import type { EventEnvelope, JournalLogger } from './sessionEventJournal';
@ -75,7 +82,7 @@ interface ConnEntry {
interface SessionWatch {
readonly id: string;
readonly session: ISessionScopeHandle;
readonly fsWatch: ISessionFsWatchService;
readonly fsWatch: IWorkspaceFsWatchSubscription;
readonly workspace: ISessionWorkspaceContext;
readonly conns: Map<string, ConnEntry>;
union: Set<string>;
@ -181,7 +188,9 @@ export class FsWatchBridge {
const sw: SessionWatch = {
id: sessionId,
session,
fsWatch: session.accessor.get(ISessionFsWatchService),
// One subscription per session, held on the handler-shared Workspace
// watch service (resolved through the session's parent scope).
fsWatch: session.accessor.get(IWorkspaceFsWatchService).subscribe(),
workspace: session.accessor.get(ISessionWorkspaceContext),
conns: new Map(),
union: new Set(),
@ -208,6 +217,7 @@ export class FsWatchBridge {
sw.sub?.dispose();
sw.sub = undefined;
sw.fsWatch.setWatchedPaths([]);
sw.fsWatch.dispose();
this.bySession.delete(sw.id);
}