diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 8eda5de72..b46d51e4c 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -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; // 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; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': Mappersistence/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 diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 16d068af2..794281248 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -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(TOOLS_SECTION), sessionDisabledTools: this.sessionToolPolicy.disabledTools(), diff --git a/packages/agent-core-v2/src/agent/toolActivation/toolActivation.ts b/packages/agent-core-v2/src/agent/toolActivation/toolActivation.ts index 1ee66e1fc..751553641 100644 --- a/packages/agent-core-v2/src/agent/toolActivation/toolActivation.ts +++ b/packages/agent-core-v2/src/agent/toolActivation/toolActivation.ts @@ -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 diff --git a/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts b/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts index 840e5ef7e..643789448 100644 --- a/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts +++ b/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts @@ -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 { 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); diff --git a/packages/agent-core-v2/src/agent/toolPolicy/evaluate.ts b/packages/agent-core-v2/src/agent/toolPolicy/evaluate.ts index e119dc6a2..0c6958ffc 100644 --- a/packages/agent-core-v2/src/agent/toolPolicy/evaluate.ts +++ b/packages/agent-core-v2/src/agent/toolPolicy/evaluate.ts @@ -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( { diff --git a/packages/agent-core-v2/src/agent/toolPolicy/toolPolicyService.ts b/packages/agent-core-v2/src/agent/toolPolicy/toolPolicyService.ts index 994933ffa..7e89510bd 100644 --- a/packages/agent-core-v2/src/agent/toolPolicy/toolPolicyService.ts +++ b/packages/agent-core-v2/src/agent/toolPolicy/toolPolicyService.ts @@ -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(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(TOOLS_SECTION), sessionDisabledTools: this.sessionToolPolicy.disabledTools(), diff --git a/packages/agent-core-v2/src/app/git/gitParsers.ts b/packages/agent-core-v2/src/app/git/gitParsers.ts index 613ed7b8a..f2c6c651d 100644 --- a/packages/agent-core-v2/src/app/git/gitParsers.ts +++ b/packages/agent-core-v2/src/app/git/gitParsers.ts @@ -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'; diff --git a/packages/agent-core-v2/src/errors.ts b/packages/agent-core-v2/src/errors.ts index a39073527..96206d104 100644 --- a/packages/agent-core-v2/src/errors.ts +++ b/packages/agent-core-v2/src/errors.ts @@ -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'; diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 117299f75..4c4e586f6 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -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'; diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/runRg.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/runRg.ts index 596945539..2a8127376 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/runRg.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/runRg.ts @@ -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`. */ diff --git a/packages/agent-core-v2/src/session/sessionFs/gitContext.ts b/packages/agent-core-v2/src/session/agentLifecycle/profile/gitContext.ts similarity index 100% rename from packages/agent-core-v2/src/session/sessionFs/gitContext.ts rename to packages/agent-core-v2/src/session/agentLifecycle/profile/gitContext.ts diff --git a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts index 43d5befca..c2287149b 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts @@ -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, diff --git a/packages/agent-core-v2/src/session/process/processRunnerService.ts b/packages/agent-core-v2/src/session/process/processRunnerService.ts index 793b8fdff..cc2aaafa3 100644 --- a/packages/agent-core-v2/src/session/process/processRunnerService.ts +++ b/packages/agent-core-v2/src/session/process/processRunnerService.ts @@ -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'; diff --git a/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts b/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts deleted file mode 100644 index 6e44c4778..000000000 --- a/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts +++ /dev/null @@ -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; -} - -export const ISessionFsWatchService: ServiceIdentifier = - createDecorator('sessionFsWatchService'); diff --git a/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGate.ts b/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGate.ts new file mode 100644 index 000000000..d089e7e4f --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGate.ts @@ -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; +} + +export const ISessionToolPolicyGate: ServiceIdentifier = + createDecorator('sessionToolPolicyGate'); + +export function sessionToolPolicyGateSeed(gate: ISessionToolPolicyGate): ScopeSeed { + return [[ISessionToolPolicyGate as ServiceIdentifier, gate]]; +} diff --git a/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGateService.ts b/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGateService.ts new file mode 100644 index 000000000..37c2daf42 --- /dev/null +++ b/packages/agent-core-v2/src/session/sessionToolPolicyGate/sessionToolPolicyGateService.ts @@ -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; +} + +registerScopedService( + LifecycleScope.Session, + ISessionToolPolicyGate, + NoopSessionToolPolicyGate, + ScopeActivation.OnScopeCreated, + 'sessionToolPolicyGate', +); diff --git a/packages/agent-core-v2/src/session/sessionFs/errors.ts b/packages/agent-core-v2/src/workspace/workspaceFs/errors.ts similarity index 94% rename from packages/agent-core-v2/src/session/sessionFs/errors.ts rename to packages/agent-core-v2/src/workspace/workspaceFs/errors.ts index 058370a2f..dd5fe32d1 100644 --- a/packages/agent-core-v2/src/session/sessionFs/errors.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/errors.ts @@ -1,5 +1,5 @@ /** - * `sessionFs` domain error codes. + * `workspaceFs` domain error codes. */ import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; diff --git a/packages/agent-core-v2/src/session/sessionFs/fs.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fs.ts similarity index 90% rename from packages/agent-core-v2/src/session/sessionFs/fs.ts rename to packages/agent-core-v2/src/workspace/workspaceFs/fs.ts index f19320310..253e2c526 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fs.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fs.ts @@ -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; @@ -248,5 +249,5 @@ export interface ISessionFsService { resolveDownload(relPath: string): Promise; } -export const ISessionFsService: ServiceIdentifier = - createDecorator('sessionFsService'); +export const IWorkspaceFsService: ServiceIdentifier = + createDecorator('workspaceFsService'); diff --git a/packages/agent-core-v2/src/session/sessionFs/fsProcess.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fsProcess.ts similarity index 95% rename from packages/agent-core-v2/src/session/sessionFs/fsProcess.ts rename to packages/agent-core-v2/src/workspace/workspaceFs/fsProcess.ts index 2eabe9bb4..59c0a53b0 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsProcess.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fsProcess.ts @@ -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` diff --git a/packages/agent-core-v2/src/session/sessionFs/fsSearch.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fsSearch.ts similarity index 98% rename from packages/agent-core-v2/src/session/sessionFs/fsSearch.ts rename to packages/agent-core-v2/src/workspace/workspaceFs/fsSearch.ts index 0c4ed321f..cc4a20736 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsSearch.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fsSearch.ts @@ -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 diff --git a/packages/agent-core-v2/src/session/sessionFs/fsService.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts similarity index 89% rename from packages/agent-core-v2/src/session/sessionFs/fsService.ts rename to packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts index 103924b82..e45628031 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fsService.ts @@ -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( - '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(); + 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 { @@ -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 { - const cwd = this.workspace.workDir; - let filter: Set | 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 { - 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 { - 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 { 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 { - 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', ); diff --git a/packages/agent-core-v2/src/workspace/workspaceFs/fsWatch.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fsWatch.ts new file mode 100644 index 000000000..2ec869bd3 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fsWatch.ts @@ -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; +} + +export interface IWorkspaceFsWatchService { + readonly _serviceBrand: undefined; + + subscribe(): IWorkspaceFsWatchSubscription; +} + +export const IWorkspaceFsWatchService: ServiceIdentifier = + createDecorator('workspaceFsWatchService'); diff --git a/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fsWatchService.ts similarity index 54% rename from packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts rename to packages/agent-core-v2/src/workspace/workspaceFs/fsWatchService.ts index e335f708b..33b4e709f 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fsWatchService.ts @@ -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>( - 'sessionFsWatch.watched', - () => new Set(), -); -export const sessionFsWatchPendingKey = defineState('sessionFsWatch.pending', () => []); -export const sessionFsWatchRawCountKey = defineState('sessionFsWatch.rawCount', () => 0); -export const sessionFsWatchTruncatedKey = defineState('sessionFsWatch.truncated', () => false); -export const sessionFsWatchGitignoreLoadedKey = defineState( - '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()); - readonly onDidChangeFiles: Event = this.emitter.event; - + private readonly subscriptions = new Set(); 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 { - return this.states.get(sessionFsWatchWatchedKey); + subscribe(): IWorkspaceFsWatchSubscription { + const subscription = new WorkspaceFsWatchSubscription(this); + this.subscriptions.add(subscription); + return subscription; } - private set watched(value: Set) { - 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 { const next = new Set(); 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(); + readonly onDidChangeFiles: Event = this.emitter.event; + + private watched = new Set(); + 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): boolean { } registerScopedService( - LifecycleScope.Session, - ISessionFsWatchService, - SessionFsWatchService, + LifecycleScope.Workspace, + IWorkspaceFsWatchService, + WorkspaceFsWatchService, ScopeActivation.OnScopeCreated, - 'sessionFsWatch', + 'workspaceFs', ); diff --git a/packages/agent-core-v2/src/session/sessionFs/rgLocator.ts b/packages/agent-core-v2/src/workspace/workspaceFs/rgLocator.ts similarity index 84% rename from packages/agent-core-v2/src/session/sessionFs/rgLocator.ts rename to packages/agent-core-v2/src/workspace/workspaceFs/rgLocator.ts index 178514afc..a5d763076 100644 --- a/packages/agent-core-v2/src/session/sessionFs/rgLocator.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/rgLocator.ts @@ -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`). diff --git a/packages/agent-core-v2/src/session/sessionFs/runRg.ts b/packages/agent-core-v2/src/workspace/workspaceFs/runRg.ts similarity index 85% rename from packages/agent-core-v2/src/session/sessionFs/runRg.ts rename to packages/agent-core-v2/src/workspace/workspaceFs/runRg.ts index 46ab22342..ccb605119 100644 --- a/packages/agent-core-v2/src/session/sessionFs/runRg.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/runRg.ts @@ -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'; diff --git a/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGit.ts b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGit.ts new file mode 100644 index 000000000..615838088 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGit.ts @@ -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): Promise; + diff(relPath: string, absPath: string): Promise; +} + +export const IWorkspaceGitService: ServiceIdentifier = + createDecorator('workspaceGitService'); diff --git a/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGitService.ts b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGitService.ts new file mode 100644 index 000000000..45695c827 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceGit/workspaceGitService.ts @@ -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): Promise { + return this.git.status(this.workspace.cwd, pathFilter); + } + + diff(relPath: string, absPath: string): Promise { + return this.git.diff(this.workspace.cwd, relPath, absPath); + } +} + +registerScopedService( + LifecycleScope.Workspace, + IWorkspaceGitService, + WorkspaceGitService, + ScopeActivation.OnScopeCreated, + 'workspaceGit', +); diff --git a/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts b/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts index f0be47b3d..fcce2b7c3 100644 --- a/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceHandler/workspaceHandlerService.ts @@ -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; diff --git a/packages/agent-core-v2/src/workspace/workspaceProcess/workspaceProcessRunnerService.ts b/packages/agent-core-v2/src/workspace/workspaceProcess/workspaceProcessRunnerService.ts new file mode 100644 index 000000000..c2329f7f5 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceProcess/workspaceProcessRunnerService.ts @@ -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 { + 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 | undefined, + ): Record | undefined { + if (invocationEnv === undefined) { + return undefined; + } + return { + ...(process.env as Record), + ...invocationEnv, + }; + } +} + +registerScopedService( + LifecycleScope.Workspace, + ISessionProcessRunner, + WorkspaceProcessRunnerService, + ScopeActivation.OnScopeCreated, + 'workspaceProcess', +); diff --git a/packages/agent-core-v2/src/workspace/workspaceToolPolicy/workspaceToolPolicy.ts b/packages/agent-core-v2/src/workspace/workspaceToolPolicy/workspaceToolPolicy.ts new file mode 100644 index 000000000..d889678f1 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceToolPolicy/workspaceToolPolicy.ts @@ -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; + + sessionGate(): ISessionToolPolicyGate; +} + +export const IWorkspaceToolPolicy: ServiceIdentifier = + createDecorator('workspaceToolPolicy'); diff --git a/packages/agent-core-v2/src/workspace/workspaceToolPolicy/workspaceToolPolicyService.ts b/packages/agent-core-v2/src/workspace/workspaceToolPolicy/workspaceToolPolicyService.ts new file mode 100644 index 000000000..d63ecaee9 --- /dev/null +++ b/packages/agent-core-v2/src/workspace/workspaceToolPolicy/workspaceToolPolicyService.ts @@ -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; + + 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', +); diff --git a/packages/agent-core-v2/test/agent/profile/binding.test.ts b/packages/agent-core-v2/test/agent/profile/binding.test.ts index 8e2f4545c..9bc886288 100644 --- a/packages/agent-core-v2/test/agent/profile/binding.test.ts +++ b/packages/agent-core-v2/test/agent/profile/binding.test.ts @@ -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, + } 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, + } 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 diff --git a/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts b/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts index 2ed9a3898..68ec8ae17 100644 --- a/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts +++ b/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts @@ -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, + } 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' }); diff --git a/packages/agent-core-v2/test/agent/toolPolicy/evaluate.test.ts b/packages/agent-core-v2/test/agent/toolPolicy/evaluate.test.ts index a21952d8b..723393adc 100644 --- a/packages/agent-core-v2/test/agent/toolPolicy/evaluate.test.ts +++ b/packages/agent-core-v2/test/agent/toolPolicy/evaluate.test.ts @@ -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); + }); +}); diff --git a/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts b/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts index 91b2732fe..51a28e9a7 100644 --- a/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts @@ -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[] { 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, diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts index 868354b4a..85f0e06b6 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/grep.test.ts @@ -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, + } satisfies ISessionToolPolicyGate); reg.definePartialInstance(IAgentProfileService, { data: () => ({}) as unknown as ProfileData, }); diff --git a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts index cb51b35eb..fd3ef9765 100644 --- a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts @@ -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, + } satisfies ISessionToolPolicyGate); permissionModeSetMode = vi.fn(); ix.stub(IAgentPermissionModeService, { _serviceBrand: undefined, diff --git a/packages/agent-core-v2/test/session/sessionFs/gitContext.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/profile/gitContext.test.ts similarity index 99% rename from packages/agent-core-v2/test/session/sessionFs/gitContext.test.ts rename to packages/agent-core-v2/test/session/agentLifecycle/profile/gitContext.test.ts index 7e92fb462..26a01dd51 100644 --- a/packages/agent-core-v2/test/session/sessionFs/gitContext.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/profile/gitContext.test.ts @@ -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'; diff --git a/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts b/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts deleted file mode 100644 index 5ecccf986..000000000 --- a/packages/agent-core-v2/test/session/sessionFs/fsWatchService.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts b/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts index 84c181db2..b1430426d 100644 --- a/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts @@ -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: () => {} }), diff --git a/packages/agent-core-v2/test/session/sessionFs/fsProcess.test.ts b/packages/agent-core-v2/test/workspace/workspaceFs/fsProcess.test.ts similarity index 96% rename from packages/agent-core-v2/test/session/sessionFs/fsProcess.test.ts rename to packages/agent-core-v2/test/workspace/workspaceFs/fsProcess.test.ts index 7d86ceaaf..0eb576095 100644 --- a/packages/agent-core-v2/test/session/sessionFs/fsProcess.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceFs/fsProcess.test.ts @@ -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; diff --git a/packages/agent-core-v2/test/session/sessionFs/fsSearch.test.ts b/packages/agent-core-v2/test/workspace/workspaceFs/fsSearch.test.ts similarity index 98% rename from packages/agent-core-v2/test/session/sessionFs/fsSearch.test.ts rename to packages/agent-core-v2/test/workspace/workspaceFs/fsSearch.test.ts index 3f7da0b21..6c171e240 100644 --- a/packages/agent-core-v2/test/session/sessionFs/fsSearch.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceFs/fsSearch.test.ts @@ -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', () => { diff --git a/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts b/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts similarity index 91% rename from packages/agent-core-v2/test/session/sessionFs/fsService.test.ts rename to packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts index c1d8bd5dd..78261c787 100644 --- a/packages/agent-core-v2/test/session/sessionFs/fsService.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts @@ -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 { _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 = {}, -): 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 | 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 }> = []; 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, diff --git a/packages/agent-core-v2/test/workspace/workspaceFs/fsWatchService.test.ts b/packages/agent-core-v2/test/workspace/workspaceFs/fsWatchService.test.ts new file mode 100644 index 000000000..ef5a39498 --- /dev/null +++ b/packages/agent-core-v2/test/workspace/workspaceFs/fsWatchService.test.ts @@ -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); + }); +}); diff --git a/packages/agent-core-v2/test/workspace/workspaceHandler/workspaceHandler.test.ts b/packages/agent-core-v2/test/workspace/workspaceHandler/workspaceHandler.test.ts index 1c88ee7a9..5bf46aae1 100644 --- a/packages/agent-core-v2/test/workspace/workspaceHandler/workspaceHandler.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceHandler/workspaceHandler.test.ts @@ -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()), diff --git a/packages/agent-core-v2/test/workspace/workspaceProcess/workspaceProcessRunnerService.test.ts b/packages/agent-core-v2/test/workspace/workspaceProcess/workspaceProcessRunnerService.test.ts new file mode 100644 index 000000000..8f26c497c --- /dev/null +++ b/packages/agent-core-v2/test/workspace/workspaceProcess/workspaceProcessRunnerService.test.ts @@ -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 { + 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 { + 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); + }); +}); diff --git a/packages/agent-core-v2/test/workspace/workspaceResources.test.ts b/packages/agent-core-v2/test/workspace/workspaceResources.test.ts index ef39857e0..fa0487932 100644 --- a/packages/agent-core-v2/test/workspace/workspaceResources.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceResources.test.ts @@ -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: () => {} }), diff --git a/packages/agent-core-v2/test/workspace/workspaceToolPolicy/workspaceToolPolicy.test.ts b/packages/agent-core-v2/test/workspace/workspaceToolPolicy/workspaceToolPolicy.test.ts new file mode 100644 index 000000000..6fe3dcf92 --- /dev/null +++ b/packages/agent-core-v2/test/workspace/workspaceToolPolicy/workspaceToolPolicy.test.ts @@ -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(); + }); +}); diff --git a/packages/kap-server/src/openapi/transforms.ts b/packages/kap-server/src/openapi/transforms.ts index ffc27d497..fdd2a4b6e 100644 --- a/packages/kap-server/src/openapi/transforms.ts +++ b/packages/kap-server/src/openapi/transforms.ts @@ -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 { diff --git a/packages/kap-server/src/routes/fs.ts b/packages/kap-server/src/routes/fs.ts index 0854605b8..5e69cf9a8 100644 --- a/packages/kap-server/src/routes/fs.ts +++ b/packages/kap-server/src/routes/fs.ts @@ -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:` 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:` + * 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>; + let resolved: Awaited>; 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 }; diff --git a/packages/kap-server/src/transport/ws/v1/fsWatchBridge.ts b/packages/kap-server/src/transport/ws/v1/fsWatchBridge.ts index 10c4acfe1..6e2fc258d 100644 --- a/packages/kap-server/src/transport/ws/v1/fsWatchBridge.ts +++ b/packages/kap-server/src/transport/ws/v1/fsWatchBridge.ts @@ -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; union: Set; @@ -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); }