From ff558ada0ea105f318690943fbc1b6108b4557e8 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 30 Sep 2026 19:18:05 -0400 Subject: [PATCH 1/6] fix: roll back workspace trust-boundary hardening Remove the symlink-realpath gates on file tools, the repo-config git hardening probe, and the local.toml trust gating; project-local config and git invocations return to plain resolution while the sensitive-file write guard stays. --- .../src/feedback/codebase/scanner.ts | 30 +- apps/pythinker-code/src/utils/git/git-args.ts | 22 -- .../src/utils/git/git-status.ts | 81 ++---- .../test/utils/git/git-status.test.ts | 43 --- .../policies/git-cwd-write-approve.ts | 5 +- .../src/agent/tools/edit/editTool.ts | 5 - .../src/agent/tools/os/glob/globTool.ts | 5 - .../src/agent/tools/os/grep/grepTool.ts | 5 - .../src/agent/tools/os/read/readTool.ts | 5 - .../src/agent/tools/os/write/writeTool.ts | 5 - .../read-media-file/readMediaFileTool.ts | 5 - .../agentProfileCatalog.ts | 2 - packages/agent-core-v2/src/app/git/git.ts | 12 - .../agent-core-v2/src/app/git/gitService.ts | 75 ++--- .../agent-core-v2/src/app/git/hardening.ts | 264 ------------------ .../projectLocalConfig/projectLocalConfig.ts | 6 +- .../src/features/tower/protocol/git.ts | 34 +-- .../node-fs/projectLocalConfigService.ts | 48 +--- packages/agent-core-v2/src/program/program.ts | 4 +- .../agentLifecycle/profile/gitContext.ts | 112 ++++++-- .../agentLifecycle/profile/profiles.ts | 5 +- .../src/session/subagent/subagentService.ts | 3 - .../agent-core-v2/src/tool/path-access.ts | 6 +- .../agent-core-v2/src/tool/realpath-access.ts | 183 ------------ .../workspaceDirs/workspaceDirsService.ts | 37 +-- .../test/agent/media/tools/read-media.test.ts | 46 +-- .../permissionPolicyService.test.ts | 33 --- .../test/app/edit/tools/edit.test.ts | 43 +-- .../test/app/git/gitService.test.ts | 74 +---- .../test/app/git/hardening.test.ts | 117 -------- .../dynamic_workflow/dynamic_workflow.test.ts | 6 - .../test/features/tower/store.test.ts | 72 +---- .../os/backends/node-local/tools/glob.test.ts | 59 +--- .../os/backends/node-local/tools/grep.test.ts | 67 +---- .../os/backends/node-local/tools/read.test.ts | 89 +----- .../backends/node-local/tools/write.test.ts | 62 +--- .../node-fs/projectLocalConfigService.test.ts | 127 --------- .../agentLifecycle/profile/gitContext.test.ts | 137 +++++---- .../test/tool/path-access.test.ts | 11 - .../test/tool/realpath-access.test.ts | 164 ----------- .../test/tools/fixtures/fake-exec.ts | 2 +- .../workspaceDirs/workspaceDirs.test.ts | 257 ----------------- .../workspace/workspaceFs/fsService.test.ts | 10 +- .../agent-gateway/test/v2Sessions.test.ts | 1 - 44 files changed, 270 insertions(+), 2109 deletions(-) delete mode 100644 apps/pythinker-code/src/utils/git/git-args.ts delete mode 100644 packages/agent-core-v2/src/app/git/hardening.ts delete mode 100644 packages/agent-core-v2/src/tool/realpath-access.ts delete mode 100644 packages/agent-core-v2/test/app/git/hardening.test.ts delete mode 100644 packages/agent-core-v2/test/persistence/backends/node-fs/projectLocalConfigService.test.ts delete mode 100644 packages/agent-core-v2/test/tool/realpath-access.test.ts delete mode 100644 packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts diff --git a/apps/pythinker-code/src/feedback/codebase/scanner.ts b/apps/pythinker-code/src/feedback/codebase/scanner.ts index 749c5058a..6df420217 100644 --- a/apps/pythinker-code/src/feedback/codebase/scanner.ts +++ b/apps/pythinker-code/src/feedback/codebase/scanner.ts @@ -4,8 +4,6 @@ import { lstat, readdir } from 'node:fs/promises'; import { join, relative, resolve } from 'node:path'; import { promisify } from 'node:util'; -import { GIT_CONFIG_ARGS } from '#/utils/git/git-args'; - import { DEFAULT_MAX_ARCHIVE_SIZE, DEFAULT_MAX_FILES, @@ -48,9 +46,9 @@ export async function scanCodebase( const root = resolve(rootInput); const limits = resolveLimits(options.limits); throwIfAborted(options.signal); - const usedGitIgnore = await isInsideGitWorkTree(root, GIT_CONFIG_ARGS); + const usedGitIgnore = await isInsideGitWorkTree(root); const collected = usedGitIgnore - ? await scanWithGit(root, GIT_CONFIG_ARGS, limits, options.signal) + ? await scanWithGit(root, limits, options.signal) : await scanWithoutFilter(root, limits, options.signal); const sortedFiles = collected.files.toSorted((a, b) => a.path.localeCompare(b.path)); @@ -71,18 +69,9 @@ function resolveLimits(limits: ScanCodebaseOptions['limits']): ScanCodebaseLimit }; } -async function isInsideGitWorkTree( - root: string, - configArgs: readonly string[], -): Promise { +async function isInsideGitWorkTree(root: string): Promise { try { - const { stdout } = await execFileAsync('git', [ - ...configArgs, - '-C', - root, - 'rev-parse', - '--is-inside-work-tree', - ]); + const { stdout } = await execFileAsync('git', ['-C', root, 'rev-parse', '--is-inside-work-tree']); return stdout.trim() === 'true'; } catch { return false; @@ -91,21 +80,12 @@ async function isInsideGitWorkTree( async function scanWithGit( root: string, - configArgs: readonly string[], limits: ScanCodebaseLimits, signal?: AbortSignal, ): Promise { const { stdout } = await execFileAsync( 'git', - [ - ...configArgs, - '-C', - root, - 'ls-files', - '-co', - '--exclude-standard', - '-z', - ], + ['-C', root, 'ls-files', '-co', '--exclude-standard', '-z'], { encoding: 'buffer', maxBuffer: 1024 * 1024 * 64, signal }, ); diff --git a/apps/pythinker-code/src/utils/git/git-args.ts b/apps/pythinker-code/src/utils/git/git-args.ts deleted file mode 100644 index fbcec0e07..000000000 --- a/apps/pythinker-code/src/utils/git/git-args.ts +++ /dev/null @@ -1,22 +0,0 @@ -const NULL_DEVICE = process.platform === 'win32' ? 'NUL' : '/dev/null'; - -export const GIT_CONFIG_ARGS: readonly string[] = [ - '-c', - 'core.fsmonitor=false', - '-c', - `core.hooksPath=${NULL_DEVICE}`, - '-c', - 'commit.gpgSign=false', - '-c', - 'log.showSignature=false', - '-c', - 'merge.verifySignatures=false', - '-c', - 'core.editor=', - '-c', - 'gpg.program=', - '-c', - 'submodule.recurse=false', -]; - -export const GIT_DIFF_ARGS: readonly string[] = ['--no-ext-diff', '--no-textconv']; diff --git a/apps/pythinker-code/src/utils/git/git-status.ts b/apps/pythinker-code/src/utils/git/git-status.ts index 8ca6fc9f6..833418305 100644 --- a/apps/pythinker-code/src/utils/git/git-status.ts +++ b/apps/pythinker-code/src/utils/git/git-status.ts @@ -9,7 +9,6 @@ import { execFile, spawnSync } from 'node:child_process'; -import { GIT_CONFIG_ARGS, GIT_DIFF_ARGS } from '#/utils/git/git-args'; import { resolveCommandPath } from '#/utils/process/resolve-command'; const BRANCH_TTL_MS = 5_000; @@ -98,18 +97,18 @@ export function createGitStatusCache( if (repoDetected && !isRepo) return null; if (!repoDetected) { repoDetected = true; - isRepo = detectGitRepo(git, workDir, GIT_CONFIG_ARGS); + isRepo = detectGitRepo(git, workDir); } if (!isRepo) return null; const now = Date.now(); if (now - branch.fetchedAt >= BRANCH_TTL_MS) { - branch = { value: readBranch(git, workDir, GIT_CONFIG_ARGS), fetchedAt: now }; + branch = { value: readBranch(git, workDir), fetchedAt: now }; } if (branch.value === null) return null; if (now - status.fetchedAt >= STATUS_TTL_MS) { - status = { ...readStatus(git, workDir, GIT_CONFIG_ARGS), fetchedAt: now }; + status = { ...readStatus(git, workDir), fetchedAt: now }; } refreshPullRequestIfNeeded(branch.value, now); @@ -156,32 +155,24 @@ export function createGitStatusCache( } } -function detectGitRepo(git: string, workDir: string, configArgs: readonly string[]): boolean { +function detectGitRepo(git: string, workDir: string): boolean { try { - const result = spawnSync( - git, - [...configArgs, '-C', workDir, 'rev-parse', '--is-inside-work-tree'], - { - encoding: 'utf8', - timeout: SPAWN_TIMEOUT_MS, - }, - ); + const result = spawnSync(git, ['-C', workDir, 'rev-parse', '--is-inside-work-tree'], { + encoding: 'utf8', + timeout: SPAWN_TIMEOUT_MS, + }); return result.status === 0 && result.stdout.trim() === 'true'; } catch { return false; } } -function readBranch(git: string, workDir: string, configArgs: readonly string[]): string | null { +function readBranch(git: string, workDir: string): string | null { try { - const result = spawnSync( - git, - [...configArgs, '-C', workDir, 'branch', '--show-current'], - { - encoding: 'utf8', - timeout: SPAWN_TIMEOUT_MS, - }, - ); + const result = spawnSync(git, ['-C', workDir, 'branch', '--show-current'], { + encoding: 'utf8', + timeout: SPAWN_TIMEOUT_MS, + }); if (result.status !== 0) return null; const name = result.stdout.trim(); return name.length > 0 ? name : null; @@ -193,7 +184,6 @@ function readBranch(git: string, workDir: string, configArgs: readonly string[]) function readStatus( git: string, workDir: string, - configArgs: readonly string[], ): { dirty: boolean; ahead: number; @@ -202,15 +192,11 @@ function readStatus( diffDeleted: number; } { try { - const result = spawnSync( - git, - [...configArgs, '-C', workDir, 'status', '--porcelain', '-b'], - { - encoding: 'utf8', - timeout: SPAWN_TIMEOUT_MS, - maxBuffer: 4 * 1024 * 1024, - }, - ); + const result = spawnSync(git, ['-C', workDir, 'status', '--porcelain', '-b'], { + encoding: 'utf8', + timeout: SPAWN_TIMEOUT_MS, + maxBuffer: 4 * 1024 * 1024, + }); if (result.status !== 0) { return { dirty: false, ahead: 0, behind: 0, diffAdded: 0, diffDeleted: 0 }; } @@ -229,7 +215,7 @@ function readStatus( dirty = true; } } - const diff = dirty ? readDiffStats(git, workDir, configArgs) : { added: 0, deleted: 0 }; + const diff = dirty ? readDiffStats(git, workDir) : { added: 0, deleted: 0 }; return { dirty, ahead, @@ -242,30 +228,13 @@ function readStatus( } } -function readDiffStats( - git: string, - workDir: string, - configArgs: readonly string[], -): { added: number; deleted: number } { +function readDiffStats(git: string, workDir: string): { added: number; deleted: number } { try { - const result = spawnSync( - git, - [ - ...configArgs, - '-C', - workDir, - 'diff', - ...GIT_DIFF_ARGS, - '--numstat', - 'HEAD', - '--', - ], - { - encoding: 'utf8', - timeout: SPAWN_TIMEOUT_MS, - maxBuffer: 4 * 1024 * 1024, - }, - ); + const result = spawnSync(git, ['-C', workDir, 'diff', '--numstat', 'HEAD', '--'], { + encoding: 'utf8', + timeout: SPAWN_TIMEOUT_MS, + maxBuffer: 4 * 1024 * 1024, + }); if (result.status !== 0) return { added: 0, deleted: 0 }; let added = 0; diff --git a/apps/pythinker-code/test/utils/git/git-status.test.ts b/apps/pythinker-code/test/utils/git/git-status.test.ts index 615ea9398..d74b82f41 100644 --- a/apps/pythinker-code/test/utils/git/git-status.test.ts +++ b/apps/pythinker-code/test/utils/git/git-status.test.ts @@ -216,49 +216,6 @@ describe('git status cache', () => { expect(mocks.execFile).not.toHaveBeenCalled(); }); - it('disables repo-local command config on every git invocation', async () => { - mocks.execFile.mockImplementation( - ( - _cmd: string, - _args: string[], - _options: unknown, - callback: (error: Error | null, stdout: string, stderr: string) => void, - ) => { - callback(new Error('no pull request'), '', ''); - }, - ); - mocks.spawnSync.mockImplementation((_cmd: string, args: string[]) => { - if (args.includes('rev-parse')) return { status: 0, stdout: 'true\n' }; - if (args.includes('branch')) return { status: 0, stdout: 'main\n' }; - if (args.includes('status')) return { status: 0, stdout: '## main...origin/main\n M a.ts\n' }; - if (args.includes('diff')) return { status: 0, stdout: '1\t1\ta.ts\n' }; - return { status: 1, stdout: '' }; - }); - - const cache = createGitStatusCache('/tmp/repo'); - expect(cache.getStatus()).not.toBeNull(); - await Promise.resolve(); - - const nullDevice = process.platform === 'win32' ? 'NUL' : '/dev/null'; - expect(mocks.spawnSync).toHaveBeenCalledTimes(4); - for (const call of mocks.spawnSync.mock.calls) { - const args = call[1] as string[]; - expect(args.slice(0, 4)).toEqual([ - '-c', - 'core.fsmonitor=false', - '-c', - `core.hooksPath=${nullDevice}`, - ]); - } - const diffCall = mocks.spawnSync.mock.calls.find((call) => - (call[1] as string[]).includes('diff'), - ); - expect(diffCall).toBeDefined(); - const diffArgs = diffCall![1] as string[]; - expect(diffArgs).toContain('--no-ext-diff'); - expect(diffArgs).toContain('--no-textconv'); - }); - it('spawns git and gh through their resolved absolute paths', async () => { mocks.execFile.mockImplementation( ( diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts index bc12b9a92..b48d71cf7 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/git-cwd-write-approve.ts @@ -1,5 +1,5 @@ import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; -import { isProjectLocalConfigPath, isWithinWorkspace } from '#/tool/path-access'; +import { isWithinWorkspace } from '#/tool/path-access'; import { IGitService } from '#/app/git/git'; import type { IGitService as GitService } from '#/app/git/git'; import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; @@ -35,9 +35,6 @@ export class GitCwdWriteApprovePermissionPolicyService implements PermissionPoli const writeAccesses = writeFileAccesses(context); if (writeAccesses.length === 0) return undefined; - if (writeAccesses.some((access) => isProjectLocalConfigPath(access.path))) { - return undefined; - } if ( !writeAccesses.every((access) => isWithinWorkspace( diff --git a/packages/agent-core-v2/src/agent/tools/edit/editTool.ts b/packages/agent-core-v2/src/agent/tools/edit/editTool.ts index 7181f52b5..a3ef1f35c 100644 --- a/packages/agent-core-v2/src/agent/tools/edit/editTool.ts +++ b/packages/agent-core-v2/src/agent/tools/edit/editTool.ts @@ -2,7 +2,6 @@ import { resolvePathAccessPath, type WorkspaceConfig, } from '#/tool/path-access'; -import { checkRealPathWriteTarget } from '#/tool/realpath-access'; import { toInputJsonSchema } from '#/tool/input-schema'; import { literalRulePattern, matchesPathRuleSubject } from '#/tool/rule-match'; import { IFileEditService } from '#/app/edit/fileEdit'; @@ -78,10 +77,6 @@ export class EditTool implements IEditTool { if (lease.runtime.identity.generation !== inspected.identity.generation) { return { isError: true, output: 'Runtime changed before execution. Retry the tool call.' }; } - const accessError = await checkRealPathWriteTarget(lease.runtime.fs!, path, workspace, env.pathClass); - if (accessError !== undefined) { - return { isError: true, output: accessError.message }; - } return await this.execution(args, path, lease.runtime.fs!); } finally { lease.dispose(); diff --git a/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts b/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts index 16c5e3c66..7be6dda07 100644 --- a/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts @@ -31,7 +31,6 @@ import { SENSITIVE_DOT_VARIANT_SUFFIXES, type WorkspaceConfig, } from '#/tool/path-access'; -import { checkRealPathWithinWorkspace } from '#/tool/realpath-access'; import { toInputJsonSchema } from '#/tool/input-schema'; import { literalRulePattern, matchesGlobRuleSubject } from '#/tool/rule-match'; import globDescription from './glob.md?raw'; @@ -127,10 +126,6 @@ export class GlobTool implements IGlobTool { if (lease.runtime.identity.generation !== inspected.identity.generation) { return { isError: true, output: 'Runtime changed before execution. Retry the tool call.' }; } - const accessError = await checkRealPathWithinWorkspace(lease.runtime.fs!, searchRoots[0]!, workspace, env.pathClass, { checkSensitive: false }); - if (accessError !== undefined) { - return { isError: true, output: accessError.message }; - } return await this.execution( lease.runtime.fs!, lease.runtime.process!, diff --git a/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts b/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts index f85f8b4c4..174817c6e 100644 --- a/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts @@ -23,7 +23,6 @@ import { SENSITIVE_DOT_VARIANT_SUFFIXES, type WorkspaceConfig, } from '#/tool/path-access'; -import { checkRealPathWithinWorkspace } from '#/tool/realpath-access'; import { toInputJsonSchema } from '#/tool/input-schema'; import { literalRulePattern, matchesGlobRuleSubject } from '#/tool/rule-match'; import { @@ -113,10 +112,6 @@ export class GrepTool implements IGrepTool { if (lease.runtime.identity.generation !== inspected.identity.generation) { return { isError: true, output: 'Runtime changed before execution. Retry the tool call.' }; } - const accessError = await checkRealPathWithinWorkspace(lease.runtime.fs!, searchPaths[0]!, workspace, env.pathClass, { checkSensitive: false }); - if (accessError !== undefined) { - return { isError: true, output: accessError.message }; - } return await this.execution(lease.runtime.process!, lease.runtime.fs!, env, workspace, args, signal, searchPaths); } finally { lease.dispose(); diff --git a/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts b/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts index 4c33d7c92..ca6562dc8 100644 --- a/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts @@ -22,7 +22,6 @@ import { resolvePathAccessPath, type WorkspaceConfig, } from '#/tool/path-access'; -import { checkRealPathWithinWorkspace } from '#/tool/realpath-access'; import { MEDIA_SNIFF_BYTES, detectFileType } from '#/agent/media/file-type'; import { toInputJsonSchema } from '#/tool/input-schema'; import { literalRulePattern, matchesGlobRuleSubject, matchesPathRuleSubject } from '#/tool/rule-match'; @@ -239,10 +238,6 @@ export class ReadTool implements IReadTool { if (lease.runtime.identity.generation !== inspected.identity.generation) { return { isError: true, output: 'Runtime changed before execution. Retry the tool call.' }; } - const accessError = await checkRealPathWithinWorkspace(lease.runtime.fs!, path, workspace, env.pathClass); - if (accessError !== undefined) { - return { isError: true, output: accessError.message }; - } const eventLog = this.resultTruncation.isWireJournalPath(path); const result = await this.execution(runtimeFileSource(lease.runtime.fs!, path), args, eventLog); return { ...result, spillExempt: true }; diff --git a/packages/agent-core-v2/src/agent/tools/os/write/writeTool.ts b/packages/agent-core-v2/src/agent/tools/os/write/writeTool.ts index dd1725007..2423b37ce 100644 --- a/packages/agent-core-v2/src/agent/tools/os/write/writeTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/write/writeTool.ts @@ -17,7 +17,6 @@ import { sensitiveTargetError, type WorkspaceConfig, } from '#/tool/path-access'; -import { checkRealPathWriteTarget } from '#/tool/realpath-access'; import { toInputJsonSchema } from '#/tool/input-schema'; import { literalRulePattern, matchesPathRuleSubject } from '#/tool/rule-match'; import { IWriteTool, WriteInputSchema, type WriteInput } from './write'; @@ -74,10 +73,6 @@ export class WriteTool implements IWriteTool { } const denied = await sensitiveTargetError(lease.runtime.fs!, args.path, path); if (denied !== undefined) return { isError: true, output: denied }; - const accessError = await checkRealPathWriteTarget(lease.runtime.fs!, path, workspace, env.pathClass); - if (accessError !== undefined) { - return { isError: true, output: accessError.message }; - } return await this.execution(lease.runtime.fs!, args, path); } finally { lease.dispose(); diff --git a/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts b/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts index 49ae9ed09..fff7ef5ba 100644 --- a/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts +++ b/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts @@ -17,7 +17,6 @@ import { type ToolExecution, } from '#/tool/toolContract'; import { resolvePathAccessPath, type WorkspaceConfig } from '#/tool/path-access'; -import { checkRealPathWithinWorkspace } from '#/tool/realpath-access'; import { MEDIA_SNIFF_BYTES, detectFileType, @@ -261,10 +260,6 @@ export class ReadMediaFileTool implements AgentTool { if (lease.runtime.identity.generation !== inspected.identity.generation) { return { isError: true, output: 'Runtime changed before execution. Retry the tool call.' }; } - const accessError = await checkRealPathWithinWorkspace(lease.runtime.fs!, path, workspace, env.pathClass); - if (accessError !== undefined) { - return { isError: true, output: accessError.message }; - } return await this.execution(args, runtimeFileSource(lease.runtime.fs!, path), env); } finally { lease.dispose(); diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts index d56b3dbed..aef8a3a4e 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts @@ -1,5 +1,4 @@ import type { ILogger } from '#/_base/log/log'; -import type { IGitService } from '#/app/git/git'; import type { IHostProcessService } from '#/os/interface/hostProcess'; export const DEFAULT_AGENT_PROFILE_NAME = 'agent'; @@ -8,7 +7,6 @@ export interface AgentProfilePromptPrefixContext { readonly cwd: string; readonly process: IHostProcessService; readonly log?: ILogger; - readonly git?: IGitService; } export interface AgentProfileContext { diff --git a/packages/agent-core-v2/src/app/git/git.ts b/packages/agent-core-v2/src/app/git/git.ts index 1dc81eff9..c4291df5d 100644 --- a/packages/agent-core-v2/src/app/git/git.ts +++ b/packages/agent-core-v2/src/app/git/git.ts @@ -53,24 +53,12 @@ export const fsDiffResponseSchema = z.object({ }); export type FsDiffResponse = z.infer; -export interface RunGitOptions { - readonly timeoutMs?: number; - readonly env?: Record; -} - -export interface RunGitResult { - readonly exitCode: number; - readonly stdout: string; - readonly stderr: string; -} - export interface IGitService { readonly _serviceBrand: undefined; status(cwd: string, pathFilter?: ReadonlySet): Promise; diff(cwd: string, relPath: string, absPath: string): Promise; findWorkTree(cwd: string): Promise; - runGit(cwd: string, args: readonly string[], options?: RunGitOptions): Promise; } export const IGitService: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/app/git/gitService.ts b/packages/agent-core-v2/src/app/git/gitService.ts index 7ff37b028..512b5c4d9 100644 --- a/packages/agent-core-v2/src/app/git/gitService.ts +++ b/packages/agent-core-v2/src/app/git/gitService.ts @@ -1,13 +1,6 @@ -import type { - FsDiffResponse, - FsGitStatusResponse, - FsPullRequest, - RunGitOptions, - RunGitResult, -} from './git'; +import type { FsDiffResponse, FsGitStatusResponse, FsPullRequest } from './git'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { GIT_DIFF_ARGS, hardenedGitConfigArgs } from '#/app/git/hardening'; import { ErrorCodes, Error2 } from '#/errors'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { IRuntimeResolver, IWorkspaceInstanceManager } from '#/workspace/workspaceInstance/workspaceInstanceManager'; @@ -18,7 +11,6 @@ import { findGitWorkTree, type GitWorkTree } from './workTree'; const DIFF_MAX_BYTES = 1_048_576; -const CONFIG_PROBE_TIMEOUT_MS = 5_000; const PR_SPAWN_TIMEOUT_MS = 5_000; const PULL_REQUEST_TTL_MS = 60_000; @@ -55,11 +47,7 @@ export class GitService implements IGitService { if (dirty) { const head = await this.runCommand('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], cwd); if (head.exitCode === 0) { - const numstat = await this.runCommand( - 'git', - ['diff', '--no-color', ...GIT_DIFF_ARGS, '--numstat', 'HEAD', '--'], - cwd, - ); + const numstat = await this.runCommand('git', ['diff', '--no-color', '--numstat', 'HEAD', '--'], cwd); if (numstat.exitCode === 0) { const stats = parseNumstat(numstat.stdout); result.additions = stats.additions; @@ -91,7 +79,7 @@ export class GitService implements IGitService { if (untracked || !hasHead) { const res = await this.runCommand( 'git', - ['diff', '--no-color', ...GIT_DIFF_ARGS, '--no-index', '--', '/dev/null', relPath], + ['diff', '--no-color', '--no-index', '--', '/dev/null', relPath], cwd, ); if (res.exitCode !== 0 && res.exitCode !== 1) { @@ -99,11 +87,7 @@ export class GitService implements IGitService { } diffStdout = res.stdout; } else { - const res = await this.runCommand( - 'git', - ['diff', '--no-color', ...GIT_DIFF_ARGS, 'HEAD', '--', relPath], - cwd, - ); + const res = await this.runCommand('git', ['diff', '--no-color', 'HEAD', '--', relPath], cwd); if (res.exitCode !== 0) { throw this.gitUnavailable(cwd, res.stderr.trim() || `git diff exit ${res.exitCode}`); } @@ -133,30 +117,6 @@ export class GitService implements IGitService { return findGitWorkTree(this.fs, cwd); } - async runGit( - cwd: string, - args: readonly string[], - options: RunGitOptions = {}, - ): Promise { - try { - const configArgs = await hardenedGitConfigArgs(cwd, (probeArgs) => - this.spawnAndCollect('git', probeArgs, cwd, { - timeoutMs: CONFIG_PROBE_TIMEOUT_MS, - }), - ); - if (configArgs === null) { - return { exitCode: -1, stdout: '', stderr: 'git config probe failed' }; - } - return await this.spawnAndCollect('git', [...configArgs, ...args], cwd, options); - } catch (error) { - return { - exitCode: -1, - stdout: '', - stderr: error instanceof Error ? error.message : String(error), - }; - } - } - private async readPullRequest(cwd: string): Promise { const cached = this.pullRequestCache.get(cwd); const now = Date.now(); @@ -182,20 +142,8 @@ export class GitService implements IGitService { cmd: string, args: readonly string[], cwd: string, - options: RunGitOptions = {}, - ): Promise { - if (cmd === 'git') { - return this.runGit(cwd, args, options); - } - return this.spawnAndCollect(cmd, args, cwd, options); - } - - private async spawnAndCollect( - cmd: string, - args: readonly string[], - cwd: string, - options: RunGitOptions, - ): Promise { + options: RunOptions = {}, + ): Promise { const workspaceId = this.resolveWorkspaceId(cwd); const lease = this.resolver.acquire({ workspaceId, runtimeId: 'local' }, ['process']); const spawned = await lease.runtime.process! @@ -263,6 +211,17 @@ export class GitService implements IGitService { } } +interface RunResult { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +} + +interface RunOptions { + readonly timeoutMs?: number; + readonly env?: Record; +} + async function collect(stream: AsyncIterable): Promise { const decoder = new TextDecoder(); let out = ''; diff --git a/packages/agent-core-v2/src/app/git/hardening.ts b/packages/agent-core-v2/src/app/git/hardening.ts deleted file mode 100644 index 8b3a06722..000000000 --- a/packages/agent-core-v2/src/app/git/hardening.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { open, readFile, realpath } from 'node:fs/promises'; -import { dirname, isAbsolute, join, normalize, resolve } from 'node:path'; - -const NULL_DEVICE = process.platform === 'win32' ? 'NUL' : '/dev/null'; - -export const GIT_CONFIG_ARGS: readonly string[] = [ - '-c', - 'core.fsmonitor=false', - '-c', - `core.hooksPath=${NULL_DEVICE}`, - '-c', - 'commit.gpgSign=false', - '-c', - 'log.showSignature=false', - '-c', - 'merge.verifySignatures=false', - '-c', - 'core.editor=', - '-c', - 'gpg.program=', - '-c', - 'submodule.recurse=false', -]; - -export const GIT_DIFF_ARGS: readonly string[] = ['--no-ext-diff', '--no-textconv']; - -export const INCLUDE_SECTION_RE = /^\s*\[\s*include(?:\.|\s|\]|if)/im; - -export function parseGitDirPointer(content: string): string | undefined { - const stripped = content.codePointAt(0) === 0xfeff ? content.slice(1) : content; - const line = stripped.trimStart().split(/\r?\n/, 1)[0]?.trim(); - if (line === undefined || !line.startsWith('gitdir:')) return undefined; - const rawPath = line.slice('gitdir:'.length).trim(); - return rawPath.length > 0 ? rawPath : undefined; -} - -export function resolveConfigPaths(gitDir: string, commondirContent: string | undefined): string[] { - const configPaths = [join(gitDir, 'config'), join(gitDir, 'config.worktree')]; - const commonDir = commondirContent?.trim(); - if (commonDir !== undefined && commonDir.length > 0) { - configPaths.push(join(resolve(gitDir, commonDir), 'config')); - } - return configPaths; -} - -export function buildDriverOverrides(outputs: readonly string[]): readonly string[] | null { - const filterDrivers = new Set(); - const mergeDrivers = new Set(); - for (const output of outputs) { - for (const line of output.split('\n')) { - const filter = /^filter\.(.+)\.(?:clean|process|smudge)$/.exec(line); - const filterDriver = filter?.[1]; - if (filterDriver !== undefined) { - if (filterDriver.includes('=')) return null; - filterDrivers.add(filterDriver); - } - const merge = /^merge\.(.+)\.driver$/.exec(line); - const mergeDriver = merge?.[1]; - if (mergeDriver !== undefined) { - if (mergeDriver.includes('=')) return null; - mergeDrivers.add(mergeDriver); - } - } - } - const args: string[] = []; - for (const driver of filterDrivers) { - args.push( - '-c', - `filter.${driver}.clean=`, - '-c', - `filter.${driver}.process=`, - '-c', - `filter.${driver}.smudge=`, - ); - } - for (const driver of mergeDrivers) { - args.push('-c', `merge.${driver}.driver=`); - } - return args; -} - -export function isCoreWorktreeSafe( - raw: string, - resolvedGitDir: string, - workTreeRoot: string, -): boolean { - const configured = isAbsolute(raw) ? normalize(raw) : resolve(resolvedGitDir, raw); - if (process.platform === 'win32') { - return normalize(configured).toLowerCase() === normalize(workTreeRoot).toLowerCase(); - } - return normalize(configured) === normalize(workTreeRoot); -} - -export interface GitProbeResult { - readonly exitCode: number; - readonly stdout: string; -} - -export type GitProbe = (args: readonly string[]) => Promise; - -interface FilterArgsCacheEntry { - readonly stamp: string | null; - readonly args: readonly string[]; -} - -const filterArgsCache = new Map(); - -export async function hardenedGitConfigArgs( - cwd: string, - probe: GitProbe, -): Promise { - const gitDir = await findGitDir(cwd); - const stamp = await gitConfigStamp(cwd, gitDir); - const cached = filterArgsCache.get(cwd); - if (stamp !== null && cached?.stamp === stamp) return cached.args; - if (!(await coreWorktreeSafe(cwd, probe, gitDir))) return null; - const filterArgs = await probeFilterArgs(cwd, probe); - if (filterArgs === null) return null; - const args = [...GIT_CONFIG_ARGS, ...filterArgs]; - filterArgsCache.set(cwd, { stamp, args }); - return args; -} - -async function coreWorktreeSafe( - cwd: string, - probe: GitProbe, - gitDir: string | null, -): Promise { - if (gitDir === null) return true; - let resolvedGitDir: string; - let workTreeRoot: string; - try { - const realGitPath = await realpath(gitDir); - workTreeRoot = await realpath(dirname(gitDir)); - const opened = await readGitPath(realGitPath); - if (opened.directory) { - resolvedGitDir = realGitPath; - } else { - const pointer = parseGitDirPointer(opened.text); - if (pointer === undefined) return true; - resolvedGitDir = resolve(dirname(realGitPath), pointer); - } - } catch { - return false; - } - const results = await Promise.all( - ['--local', '--worktree'].map((scope) => - probe([ - ...GIT_CONFIG_ARGS, - '-C', - cwd, - 'config', - scope, - '--includes', - '--get', - 'core.worktree', - ]).catch(() => null), - ), - ); - for (const result of results) { - if (result === null || result.exitCode < 0) return false; - if (result.exitCode !== 0) continue; - const raw = result.stdout.trim(); - if (raw === '' || isCoreWorktreeSafe(raw, resolvedGitDir, workTreeRoot)) continue; - return false; - } - return true; -} - -async function probeFilterArgs(cwd: string, probe: GitProbe): Promise { - const results = await Promise.all( - ['--local', '--worktree'].map((scope) => - probe([ - ...GIT_CONFIG_ARGS, - '-C', - cwd, - 'config', - scope, - '--includes', - '--get-regexp', - '--name-only', - '^(filter|merge)\\.', - ]).catch(() => null), - ), - ); - const outputs: string[] = []; - for (const result of results) { - if (result === null || result.exitCode < 0) return null; - if (result.exitCode !== 0) continue; - outputs.push(result.stdout); - } - return buildDriverOverrides(outputs); -} - -async function gitConfigStamp(cwd: string, found: string | null): Promise { - try { - if (found === null) return null; - let gitDir = found; - const opened = await readGitPath(gitDir); - if (!opened.directory) { - const pointer = parseGitDirPointer(opened.text); - if (pointer === undefined) return null; - gitDir = resolve(dirname(found), pointer); - } - const commondir = await readFile(join(gitDir, 'commondir'), 'utf8').catch(() => undefined); - const configPaths = resolveConfigPaths(gitDir, commondir); - const reads = await Promise.all(configPaths.map(readConfigStamp)); - for (const read of reads) { - if (read.content !== null && INCLUDE_SECTION_RE.test(read.content)) return null; - } - return reads.map((read) => read.stamp).join('|'); - } catch { - return null; - } -} - -async function findGitDir(start: string): Promise { - let dir = start; - for (;;) { - const candidate = join(dir, '.git'); - try { - const handle = await open(candidate, 'r'); - await handle.close(); - return candidate; - } catch { - } - const parent = dirname(dir); - if (parent === dir) return null; - dir = parent; - } -} - -interface GitPathRead { - readonly directory: boolean; - readonly text: string; -} - -async function readGitPath(path: string): Promise { - const handle = await open(path, 'r'); - try { - const info = await handle.stat(); - if (info.isDirectory()) return { directory: true, text: '' }; - return { directory: false, text: await handle.readFile('utf8') }; - } finally { - await handle.close(); - } -} - -async function readConfigStamp(path: string): Promise<{ readonly stamp: string; readonly content: string | null }> { - try { - const handle = await open(path, 'r'); - try { - const info = await handle.stat(); - return { - stamp: `${path}:${String(info.mtimeMs)}:${String(info.size)}`, - content: await handle.readFile('utf8'), - }; - } finally { - await handle.close(); - } - } catch { - return { stamp: `${path}:missing`, content: null }; - } -} diff --git a/packages/agent-core-v2/src/app/projectLocalConfig/projectLocalConfig.ts b/packages/agent-core-v2/src/app/projectLocalConfig/projectLocalConfig.ts index 1f39e6114..5a566760d 100644 --- a/packages/agent-core-v2/src/app/projectLocalConfig/projectLocalConfig.ts +++ b/packages/agent-core-v2/src/app/projectLocalConfig/projectLocalConfig.ts @@ -1,18 +1,14 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -export interface ProjectAdditionalDirsLocation { +export interface ProjectAdditionalDirsLoadResult { readonly projectRoot: string; readonly configPath: string; -} - -export interface ProjectAdditionalDirsLoadResult extends ProjectAdditionalDirsLocation { readonly additionalDirs: readonly string[]; } export interface IProjectLocalConfigService { readonly _serviceBrand: undefined; - locateAdditionalDirsConfig(workDir: string): Promise; readAdditionalDirs(workDir: string): Promise; resolveAdditionalDirs(baseDir: string, additionalDirs: readonly string[]): Promise; appendAdditionalDir( diff --git a/packages/agent-core-v2/src/features/tower/protocol/git.ts b/packages/agent-core-v2/src/features/tower/protocol/git.ts index 5c5eca398..fa18c0fb5 100644 --- a/packages/agent-core-v2/src/features/tower/protocol/git.ts +++ b/packages/agent-core-v2/src/features/tower/protocol/git.ts @@ -2,10 +2,7 @@ import { execFile } from 'node:child_process'; import { realpath } from 'node:fs/promises'; import { isAbsolute, join, relative, resolve } from 'node:path'; -import { GIT_DIFF_ARGS, hardenedGitConfigArgs, type GitProbeResult } from '#/app/git/hardening'; - const GIT_TIMEOUT_MS = 60_000; -const CONFIG_PROBE_TIMEOUT_MS = 5_000; export class GitError extends Error { constructor( @@ -26,16 +23,10 @@ export async function git( args: readonly string[], options: GitOptions = {}, ): Promise { - const configArgs = await hardenedGitConfigArgs(cwd, (probeArgs) => - probeGitConfig(cwd, probeArgs), - ); - if (configArgs === null) { - throw new GitError(args, 'git config probe failed'); - } return new Promise((resolve, reject) => { execFile( 'git', - [...configArgs, ...args], + [...args], { cwd, timeout: GIT_TIMEOUT_MS, @@ -53,27 +44,6 @@ export async function git( }); } -function probeGitConfig(cwd: string, args: readonly string[]): Promise { - return new Promise((resolve) => { - execFile( - 'git', - [...args], - { cwd, timeout: CONFIG_PROBE_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 }, - (error, stdout) => { - if (error === null) { - resolve({ exitCode: 0, stdout }); - return; - } - const code: unknown = (error as { code?: unknown }).code; - resolve({ - exitCode: typeof code === 'number' ? code : -1, - stdout: typeof stdout === 'string' ? stdout : '', - }); - }, - ); - }); -} - export async function tryGit(cwd: string, args: readonly string[]): Promise { try { return await git(cwd, args); @@ -198,6 +168,6 @@ export async function diffNameOnly( base: string, ref: string, ): Promise { - const out = await git(cwd, ['diff', ...GIT_DIFF_ARGS, '--name-only', `${base}...${ref}`]); + const out = await git(cwd, ['diff', '--name-only', `${base}...${ref}`]); return out.length === 0 ? [] : out.split('\n').filter((line) => line.trim().length > 0); } diff --git a/packages/agent-core-v2/src/persistence/backends/node-fs/projectLocalConfigService.ts b/packages/agent-core-v2/src/persistence/backends/node-fs/projectLocalConfigService.ts index 54892c2ed..356239e17 100644 --- a/packages/agent-core-v2/src/persistence/backends/node-fs/projectLocalConfigService.ts +++ b/packages/agent-core-v2/src/persistence/backends/node-fs/projectLocalConfigService.ts @@ -7,12 +7,10 @@ import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IProjectLocalConfigService, type ProjectAdditionalDirsLoadResult, - type ProjectAdditionalDirsLocation, } from '#/app/projectLocalConfig/projectLocalConfig'; import { ErrorCodes, Error2, unwrapErrorCause } from '#/errors'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { StorageError, StorageErrors, toStorageIoError } from '#/persistence/interface/storage'; -import { isWithinDirectory } from '#/tool/path-access'; const ProjectLocalTomlSchema = z.object({ workspace: z @@ -37,13 +35,9 @@ export class FileProjectLocalConfigService implements IProjectLocalConfigService @IHostFileSystem private readonly fs: IHostFileSystem, ) {} - async locateAdditionalDirsConfig(workDir: string): Promise { - const projectRoot = await this.findProjectRoot(workDir); - return { projectRoot, configPath: this.getProjectLocalConfigPath(projectRoot) }; - } - async readAdditionalDirs(workDir: string): Promise { - const { projectRoot, configPath } = await this.locateAdditionalDirsConfig(workDir); + const projectRoot = await this.findProjectRoot(workDir); + const configPath = this.getProjectLocalConfigPath(projectRoot); const file = await this.readProjectLocalToml(configPath); const additionalDirs = file?.parsed.workspace?.additional_dir; @@ -71,7 +65,7 @@ export class FileProjectLocalConfigService implements IProjectLocalConfigService const additionalDir = await this.resolveAdditionalDir(workDir, inputPath); const file = (await this.readProjectLocalToml(configPath)) ?? { raw: {}, parsed: {} }; const fileAdditionalDirs = file.parsed.workspace?.additional_dir ?? []; - const fileExistingDirs = await this.resolveExistingAdditionalDirs( + const fileExistingDirs = this.resolveExistingAdditionalDirs( projectRoot, fileAdditionalDirs, ); @@ -162,14 +156,14 @@ export class FileProjectLocalConfigService implements IProjectLocalConfigService return resolvedDirs; } - private async resolveExistingAdditionalDirs( + private resolveExistingAdditionalDirs( projectRoot: string, additionalDirs: readonly string[], - ): Promise { + ): string[] { const resolvedDirs: string[] = []; for (const additionalDir of normalizeAdditionalDirs(additionalDirs)) { - const resolvedDir = await this.resolvePath(projectRoot, additionalDir); + const resolvedDir = this.resolvePath(projectRoot, additionalDir); if (this.hasSameAdditionalDir(resolvedDirs, resolvedDir)) continue; resolvedDirs.push(resolvedDir); } @@ -182,38 +176,14 @@ export class FileProjectLocalConfigService implements IProjectLocalConfigService additionalDir: string, ): Promise { const normalizedInput = normalizeAdditionalDirInput(additionalDir); - const resolvedDir = await this.resolvePath(baseDir, normalizedInput); + const resolvedDir = this.resolvePath(baseDir, normalizedInput); await this.assertDirectory(resolvedDir); return resolvedDir; } - private async resolvePath(baseDir: string, additionalDir: string): Promise { + private resolvePath(baseDir: string, additionalDir: string): string { const expanded = this.expandHome(additionalDir); - const resolvedDir = isAbsolute(expanded) ? normalize(expanded) : resolve(baseDir, expanded); - if (await this.isBroadScopeDir(resolvedDir)) { - throw new Error2( - ErrorCodes.CONFIG_INVALID, - 'workspace.additional_dir must not be the user home directory or the filesystem root', - ); - } - return resolvedDir; - } - - private async isBroadScopeDir(resolvedDir: string): Promise { - const homeDir = normalize(this.bootstrap.osHomeDir); - if (dirname(resolvedDir) === resolvedDir) return true; - const realDir = await this.realpathOrLexical(resolvedDir); - if (dirname(realDir) === realDir) return true; - const realHome = await this.realpathOrLexical(homeDir); - return isWithinDirectory(homeDir, resolvedDir) || isWithinDirectory(realHome, realDir); - } - - private async realpathOrLexical(path: string): Promise { - try { - return normalize(await this.fs.realpath(path)); - } catch { - return path; - } + return isAbsolute(expanded) ? normalize(expanded) : resolve(baseDir, expanded); } private expandHome(value: string): string { diff --git a/packages/agent-core-v2/src/program/program.ts b/packages/agent-core-v2/src/program/program.ts index a12636855..d8339e779 100644 --- a/packages/agent-core-v2/src/program/program.ts +++ b/packages/agent-core-v2/src/program/program.ts @@ -279,11 +279,11 @@ export class Program { try { const state = own(new WorkspaceStateService(this.dependencies.appState)); const localConfig = new FileProjectLocalConfigService(this.dependencies.bootstrap, runtime.fs!); - const trust = own(new WorkspaceTrustService(this.context, this.dependencies.docs, state, this.dependencies.telemetry)); - const dirs = own(new WorkspaceDirsService(this.context, localConfig, this.dependencies.log, state, trust)); + const dirs = own(new WorkspaceDirsService(this.context, localConfig, this.dependencies.log, state)); const git = new WorkspaceGitService(this.context, this.dependencies.git); const fs = new WorkspaceFsService(this.context, dirs, runtime.fs!, this.resolver, this.dependencies.telemetry, git); const instructions = own(new WorkspaceInstructionsService(this.context, runtime.fs!, runtime.environment, this.dependencies.bootstrap, this.dependencies.log, state)); + const trust = own(new WorkspaceTrustService(this.context, this.dependencies.docs, state, this.dependencies.telemetry)); const mcpConfig = own(new WorkspaceMcpConfigService(this.context, this.dependencies.bootstrap, this.dependencies.plugins, this.dependencies.log, this.dependencies.config, runtime.fs!, trust, this.dependencies.configStore)); const mcp = own(new WorkspaceMcpService(this.context, this.resolver, mcpConfig, this.dependencies.oauth, this.dependencies.log, this.dependencies.telemetry, this.dependencies.identity, this.dependencies.sessionManager)); const userAgentProfiles = own(new UserAgentProfileLoaderService(this.dependencies.bootstrap, runtime.fs!, this.dependencies.log, this.dependencies.builtinAgentProfiles, this.context, this.dependencies.agentProfiles)); diff --git a/packages/agent-core-v2/src/session/agentLifecycle/profile/gitContext.ts b/packages/agent-core-v2/src/session/agentLifecycle/profile/gitContext.ts index d9356f65a..bf6a32e57 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/profile/gitContext.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/profile/gitContext.ts @@ -1,5 +1,7 @@ +import type { Readable } from 'node:stream'; + import type { ILogger } from '#/_base/log/log'; -import type { IGitService, RunGitResult } from '#/app/git/git'; +import type { IHostProcess, IHostProcessService } from '#/os/interface/hostProcess'; const GIT_TIMEOUT_MS = 5_000; const MAX_DIRTY_FILES = 20; @@ -14,17 +16,26 @@ const ALLOWED_HOSTS = [ 'git.sr.ht', ] as const; -type TaggedGitResult = { readonly args: readonly string[]; readonly result: RunGitResult }; +type GitFailure = + | { readonly kind: 'timeout' } + | { readonly kind: 'spawn-error' } + | { readonly kind: 'command-failed'; readonly exitCode?: number; readonly stderr?: string }; + +type GitResult = + | { readonly ok: true; readonly stdout: string } + | ({ readonly ok: false } & GitFailure); + +type TaggedGitResult = { readonly args: readonly string[]; readonly result: GitResult }; export async function collectGitContext( - git: IGitService, + process: IHostProcessService, cwd: string, log?: ILogger, ): Promise { const revParseArgs = ['rev-parse', '--is-inside-work-tree'] as const; - const revParse = await git.runGit(cwd, revParseArgs, { timeoutMs: GIT_TIMEOUT_MS }); - if (revParse.exitCode !== 0) { - if (isNotARepo(revParse.stderr)) { + const revParse = await runGit(process, cwd, revParseArgs); + if (!revParse.ok) { + if (revParse.kind === 'command-failed' && isNotARepo(revParse.stderr)) { return ``; } logGitFailure(cwd, revParseArgs, revParse, log); @@ -38,14 +49,11 @@ export async function collectGitContext( ['log', '-3', '--format=%h %s'], ] as const; const [remote, branch, status, gitLog] = (await Promise.all( - commandArgs.map(async (args) => ({ - args, - result: await git.runGit(cwd, args, { timeoutMs: GIT_TIMEOUT_MS }), - })), + commandArgs.map(async (args) => ({ args, result: await runGit(process, cwd, args) })), )) as unknown as [TaggedGitResult, TaggedGitResult, TaggedGitResult, TaggedGitResult]; for (const { args, result } of [remote, branch, status, gitLog]) { - if (result.exitCode !== 0) logGitFailure(cwd, args, result, log); + if (!result.ok) logGitFailure(cwd, args, result, log); } const remoteUrl = stdoutOf(remote.result); @@ -127,30 +135,94 @@ function tryUrlPath(remoteUrl: string): string | null { } } -function stdoutOf(result: RunGitResult): string { - return result.exitCode === 0 ? result.stdout.trim() : ''; +function stdoutOf(result: GitResult): string { + return result.ok ? result.stdout : ''; } -function isNotARepo(stderr: string): boolean { - return stderr.includes('not a git repository'); +function isNotARepo(stderr: string | undefined): boolean { + return stderr !== undefined && stderr.includes('not a git repository'); } function logGitFailure( cwd: string, args: readonly string[], - result: RunGitResult, + failure: GitFailure, log?: ILogger, ): void { if (log === undefined) return; const command = `git ${args.join(' ')}`; - if (result.exitCode === -1) { - log.warn('git context command failed to spawn', { cwd, command, stderr: result.stderr }); + if (failure.kind === 'timeout') { + log.debug('git context command timed out', { cwd, command }); + } else if (failure.kind === 'spawn-error') { + log.warn('git context command failed to spawn', { cwd, command }); } else { log.debug('git context command failed', { cwd, command, - exitCode: result.exitCode, - stderr: result.stderr, + exitCode: failure.exitCode, + stderr: failure.stderr, }); } } + +async function runGit( + process: IHostProcessService, + cwd: string, + args: readonly string[], +): Promise { + let proc: IHostProcess | undefined; + try { + proc = await process.spawn('git', ['-C', cwd, ...args], { cwd }); + } catch { + return { ok: false, kind: 'spawn-error' }; + } + + try { + proc.stdin.end(); + } catch { + } + + const work = Promise.all([collectStream(proc.stdout), collectStream(proc.stderr), proc.wait()]); + work.catch(() => {}); + let timer: ReturnType | undefined; + let timedOut = false; + try { + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + timedOut = true; + reject(new Error(`git ${args.join(' ')} timed out`)); + }, GIT_TIMEOUT_MS); + }); + const [stdout, stderr, exitCode] = await Promise.race([work, timeout]); + if (exitCode !== 0) { + return { ok: false, kind: 'command-failed', exitCode, stderr: stderr.trim() }; + } + return { ok: true, stdout: stdout.trim() }; + } catch { + try { + await proc.kill('SIGKILL'); + } catch { + } + await work.catch(() => {}); + if (timedOut) return { ok: false, kind: 'timeout' }; + return { ok: false, kind: 'command-failed' }; + } finally { + if (timer !== undefined) clearTimeout(timer); + if (proc !== undefined) await disposeProcess(proc); + } +} + +async function collectStream(stream: Readable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string)); + } + return Buffer.concat(chunks).toString('utf-8'); +} + +async function disposeProcess(proc: IHostProcess): Promise { + try { + await proc.dispose(); + } catch { + } +} 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 43c91e121..ea38e446c 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts @@ -115,10 +115,9 @@ registerAgentProfile({ tools: EXPLORE_TOOLS, renderSystemPrompt: (context) => renderSystemPromptResult(EXPLORE_ROLE, context, { skillActive: skillActiveFor(EXPLORE_TOOLS) }), - promptPrefix: async ({ cwd, git, log }) => { - if (git === undefined) return ''; + promptPrefix: async ({ cwd, process, log }) => { try { - return await collectGitContext(git, cwd, log); + return await collectGitContext(process, cwd, log); } catch { return ''; } diff --git a/packages/agent-core-v2/src/session/subagent/subagentService.ts b/packages/agent-core-v2/src/session/subagent/subagentService.ts index 1a5cd3656..9d2a0ecbc 100644 --- a/packages/agent-core-v2/src/session/subagent/subagentService.ts +++ b/packages/agent-core-v2/src/session/subagent/subagentService.ts @@ -22,7 +22,6 @@ import { IAgentUserToolService } from '#/agent/userTool/userTool'; import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; import type { Runtime } from '#/runtime/runtime'; import { IConfigService } from '#/app/config/config'; -import { IGitService } from '#/app/git/git'; import { IModelCatalog, type Model } from '#/llm-adapter/model/catalog'; import { ILogService } from '#/_base/log/log'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; @@ -71,7 +70,6 @@ export class SessionSubagentService extends Service implements ISessionSubagentS @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, @ISessionAgentProfileCatalog private readonly catalog: ISessionAgentProfileCatalog, @IConfigService private readonly configService: IConfigService, - @IGitService private readonly git: IGitService, @IModelCatalog private readonly modelCatalog: IModelCatalog, @ISessionContext private readonly sessionContext: ISessionContext, @ILogService private readonly log: ILogService, @@ -227,7 +225,6 @@ export class SessionSubagentService extends Service implements ISessionSubagentS cwd: view.workDir, process: runtime.process!, log: this.log, - git: this.git, }); } diff --git a/packages/agent-core-v2/src/tool/path-access.ts b/packages/agent-core-v2/src/tool/path-access.ts index e519ade39..68e6672d7 100644 --- a/packages/agent-core-v2/src/tool/path-access.ts +++ b/packages/agent-core-v2/src/tool/path-access.ts @@ -83,7 +83,7 @@ export function isSensitiveFile(path: string): boolean { } export type PathClass = 'posix' | 'win32'; -export type PathSecurityCode = 'PATH_OUTSIDE_WORKSPACE' | 'PATH_SENSITIVE' | 'PATH_INVALID' | 'PATH_SYMLINK_ESCAPE'; +export type PathSecurityCode = 'PATH_OUTSIDE_WORKSPACE' | 'PATH_SENSITIVE' | 'PATH_INVALID'; export type PathAccessOperation = 'read' | 'write' | 'search'; export type WorkspaceGuardMode = 'absolute-outside-allowed' | 'disabled'; @@ -190,10 +190,6 @@ export function isWithinWorkspace( return false; } -export function isProjectLocalConfigPath(targetPath: string): boolean { - return targetPath.replaceAll('\\', '/').toLowerCase().endsWith('/.pythinker-code/local.toml'); -} - export function extendWorkspaceWithSkillRoots( workspace: T, skillRoots: readonly string[], diff --git a/packages/agent-core-v2/src/tool/realpath-access.ts b/packages/agent-core-v2/src/tool/realpath-access.ts deleted file mode 100644 index aaebec687..000000000 --- a/packages/agent-core-v2/src/tool/realpath-access.ts +++ /dev/null @@ -1,183 +0,0 @@ -import * as pathe from 'pathe'; - -import { isError2, unwrapErrorCause } from '#/_base/errors/errors'; -import { OsFsErrors } from '#/os/interface/hostFsErrors'; -import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; -import { - isProjectLocalConfigPath, - isSensitiveFile, - isWithinDirectory, - isWithinWorkspace, - PathSecurityError, - type PathClass, - type WorkspaceConfig, -} from '#/tool/path-access'; - -function errnoCode(error: unknown): string | undefined { - const unwrapped = unwrapErrorCause(error); - if (typeof unwrapped === 'object' && unwrapped !== null && 'code' in unwrapped) { - const code = (unwrapped as { code: unknown }).code; - return typeof code === 'string' ? code : undefined; - } - return undefined; -} - -function isMissingPathError(error: unknown): boolean { - if (isError2(error)) { - return ( - error.code === OsFsErrors.codes.OS_FS_NOT_FOUND || - error.code === OsFsErrors.codes.OS_FS_NOT_DIRECTORY - ); - } - const code = errnoCode(error); - return code === 'ENOENT' || code === 'ENOTDIR'; -} - -async function realpathExistingPrefix(fs: IHostFileSystem, absPath: string): Promise { - const tail: string[] = []; - let current = absPath; - for (let i = 0; i < 256; i++) { - try { - const real = await fs.realpath(current); - return tail.length === 0 ? real : pathe.join(real, ...tail.toReversed()); - } catch (error) { - if (!isMissingPathError(error)) throw error; - const exists = await fs - .lstat(current) - .then( - () => true, - (lstatError) => { - if (isMissingPathError(lstatError)) return false; - throw lstatError; - }, - ); - if (exists) { - throw new PathSecurityError( - 'PATH_SYMLINK_ESCAPE', - absPath, - current, - `"${current}" is a symbolic link whose target does not exist. Access is blocked.`, - ); - } - const parent = pathe.dirname(current); - if (parent === current) return absPath; - tail.push(pathe.basename(current)); - current = parent; - } - } - throw new PathSecurityError( - 'PATH_SYMLINK_ESCAPE', - absPath, - absPath, - `"${absPath}" is too deep to resolve to a real path. Access is blocked.`, - ); -} - -async function realRoots( - fs: IHostFileSystem, - workspace: WorkspaceConfig, -): Promise { - const roots: string[] = []; - for (const dir of [workspace.workspaceDir, ...workspace.additionalDirs]) { - try { - roots.push(await fs.realpath(dir)); - } catch { - roots.push(dir); - } - } - return roots; -} - -export interface RealPathAccessOptions { - readonly checkSensitive?: boolean; -} - -export async function assertRealPathWithinWorkspace( - fs: IHostFileSystem, - absPath: string, - workspace: WorkspaceConfig, - pathClass: PathClass, - options?: RealPathAccessOptions, -): Promise { - if (!isWithinWorkspace(absPath, workspace, pathClass)) { - const resolved = await realpathExistingPrefix(fs, absPath); - if (options?.checkSensitive !== false && isSensitiveFile(resolved)) { - throw new PathSecurityError( - 'PATH_SENSITIVE', - absPath, - resolved, - `"${absPath}" resolves to "${resolved}" through a symbolic link, which matches a sensitive-file pattern (env / credential / SSH key). ` + - 'Access is blocked to protect secrets.', - ); - } - return absPath; - } - const resolved = await realpathExistingPrefix(fs, absPath); - if (options?.checkSensitive !== false && isSensitiveFile(resolved)) { - throw new PathSecurityError( - 'PATH_SENSITIVE', - absPath, - resolved, - `"${absPath}" resolves to "${resolved}" through a symbolic link, which matches a sensitive-file pattern (env / credential / SSH key). ` + - 'Access is blocked to protect secrets.', - ); - } - const roots = await realRoots(fs, workspace); - if (roots.some((root) => isWithinDirectory(resolved, root, pathClass))) return resolved; - throw new PathSecurityError( - 'PATH_SYMLINK_ESCAPE', - absPath, - resolved, - `"${absPath}" resolves to "${resolved}" through a symbolic link that points outside the working directory. ` + - 'Access is blocked; use the real path directly or add the target directory to the workspace.', - ); -} - -export async function assertRealPathWriteTarget( - fs: IHostFileSystem, - absPath: string, - workspace: WorkspaceConfig, - pathClass: PathClass, -): Promise { - const resolved = await assertRealPathWithinWorkspace(fs, absPath, workspace, pathClass); - if (!isProjectLocalConfigPath(absPath) && isProjectLocalConfigPath(resolved)) { - throw new PathSecurityError( - 'PATH_SYMLINK_ESCAPE', - absPath, - resolved, - `"${absPath}" resolves to the project-local config "${resolved}" through a symbolic link. ` + - 'Access is blocked; use the real path so the write goes through approval.', - ); - } -} - -export async function checkRealPathWithinWorkspace( - fs: IHostFileSystem, - absPath: string, - workspace: WorkspaceConfig, - pathClass: PathClass, - options?: RealPathAccessOptions, -): Promise { - try { - await assertRealPathWithinWorkspace(fs, absPath, workspace, pathClass, options); - return undefined; - } catch (error) { - if (error instanceof PathSecurityError) return error; - throw error; - } -} - -export async function checkRealPathWriteTarget( - fs: IHostFileSystem, - absPath: string, - workspace: WorkspaceConfig, - pathClass: PathClass, -): Promise { - try { - await assertRealPathWriteTarget(fs, absPath, workspace, pathClass); - return undefined; - } catch (error) { - if (error instanceof PathSecurityError) return error; - throw error; - } -} diff --git a/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts b/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts index c62656f84..d11b0a20b 100644 --- a/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceDirs/workspaceDirsService.ts @@ -6,12 +6,10 @@ import { TimeoutTimer } from '#/_base/utils/timer'; import { subtreeWatchFilter } from '#/_base/utils/paths'; import { IProjectLocalConfigService, - type ProjectAdditionalDirsLoadResult, } from '#/app/projectLocalConfig/projectLocalConfig'; import type { ISessionWorkspaceInfo } from '#/session/workspaceInfo/workspaceInfo'; import { IWorkspaceStateService } from '#/workspace/state/workspaceState'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; -import { IWorkspaceTrust } from '#/workspace/workspaceTrust/workspaceTrust'; import { watchCandidates } from '#human/utils/watch'; import { @@ -47,7 +45,6 @@ export class WorkspaceDirsService extends Disposable implements IWorkspaceDirs { @IProjectLocalConfigService private readonly localConfig: IProjectLocalConfigService, @ILogService private readonly log: ILogService, @IWorkspaceStateService private readonly states: IWorkspaceStateService, - @IWorkspaceTrust private readonly trust: IWorkspaceTrust, ) { super(); this.states.contributeState(workspaceDirsFileDirsKey); @@ -56,16 +53,6 @@ export class WorkspaceDirsService extends Disposable implements IWorkspaceDirs { this.configPath = ''; this.ready = this.enqueue(() => this.reloadFromDisk()); void this.ready.then(() => this.watchLocalToml()); - this._register( - this.trust.onDidChange(() => { - if (!this.trust.isTrusted() && this.setFileDirs([])) { - this.onDidChangeEmitter.fire(); - } - void this.enqueue(() => this.reloadFromDisk()).catch((error) => { - this.log.warn(`local.toml trust reload failed: ${String(error)}`); - }); - }), - ); } private get fileDirs(): readonly string[] { @@ -124,15 +111,7 @@ export class WorkspaceDirsService extends Disposable implements IWorkspaceDirs { ); this.projectRoot = persisted.projectRoot; this.configPath = persisted.configPath; - let changed: boolean; - if (this.trust.isTrusted()) { - changed = this.setFileDirs(persisted.additionalDirs); - } else { - const explicit = await this.localConfig.resolveAdditionalDirs(this.workspace.cwd, [ - input.path, - ]); - changed = this.unionEphemeral(explicit); - } + const changed = this.setFileDirs(persisted.additionalDirs); if (changed) { this.onDidChangeEmitter.fire(); } @@ -144,7 +123,7 @@ export class WorkspaceDirsService extends Disposable implements IWorkspaceDirs { }; } - const onDisk = await this.localConfig.locateAdditionalDirsConfig(this.workspace.cwd); + const onDisk = await this.localConfig.readAdditionalDirs(this.workspace.cwd); this.projectRoot = onDisk.projectRoot; this.configPath = onDisk.configPath; const resolved = await this.localConfig.resolveAdditionalDirs(this.workspace.cwd, [ @@ -163,18 +142,10 @@ export class WorkspaceDirsService extends Disposable implements IWorkspaceDirs { } private async reloadFromDisk(): Promise { - await this.trust.ready; - const trustedAtStart = this.trust.isTrusted(); - const onDisk: ProjectAdditionalDirsLoadResult = trustedAtStart - ? await this.localConfig.readAdditionalDirs(this.workspace.cwd) - : { - ...(await this.localConfig.locateAdditionalDirsConfig(this.workspace.cwd)), - additionalDirs: [], - }; + const onDisk = await this.localConfig.readAdditionalDirs(this.workspace.cwd); this.projectRoot = onDisk.projectRoot; this.configPath = onDisk.configPath; - const dirs = this.trust.isTrusted() ? onDisk.additionalDirs : []; - if (this.setFileDirs(dirs)) { + if (this.setFileDirs(onDisk.additionalDirs)) { this.onDidChangeEmitter.fire(); } } diff --git a/packages/agent-core-v2/test/agent/media/tools/read-media.test.ts b/packages/agent-core-v2/test/agent/media/tools/read-media.test.ts index 4768c3a9c..e71572862 100644 --- a/packages/agent-core-v2/test/agent/media/tools/read-media.test.ts +++ b/packages/agent-core-v2/test/agent/media/tools/read-media.test.ts @@ -1,6 +1,3 @@ -import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; import * as posixPath from 'node:path/posix'; import { Readable } from 'node:stream'; @@ -8,7 +5,7 @@ import { UNKNOWN_CAPABILITY, type ModelCapability } from '#/llm-adapter/contract import type { ContentPart } from '#human/llm/message'; import { VideoUploadUnsupportedError } from '#/llm-adapter/contract/errors'; import { Jimp } from 'jimp'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { Emitter } from '#/_base/event'; import { @@ -16,7 +13,6 @@ import { setUnexpectedErrorHandler, } from '#/_base/errors/unexpectedError'; import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; -import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; import type { IHostEnvironment } from '#/os/interface/hostEnvironment'; import type { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; import type { Runtime } from '#/runtime/runtime'; @@ -163,7 +159,6 @@ function createTestFs(files: Record): IHostFileSystem { size: file?.size ?? file?.data.length ?? 0, }; }), - realpath: vi.fn(async (path: string) => path), } as unknown as IHostFileSystem; } @@ -1265,42 +1260,3 @@ describe('createVideoUploader', () => { expect(result.output).toContain('Convert it to JPEG first'); }); }); - -describe('ReadMediaFileTool symlink escape', () => { - let tmpDir: string; - let wsDir: string; - let outsideDir: string; - - beforeEach(async () => { - tmpDir = await mkdtemp(join(tmpdir(), 'read-media-symlink-')); - wsDir = join(tmpDir, 'ws'); - outsideDir = join(tmpDir, 'outside'); - await mkdir(wsDir); - await mkdir(outsideDir); - }); - - afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }); - }); - - function makeRealFsTool() { - return new ReadMediaFileTool( - runtimeFor(new HostFileSystem()), - { workspaceDir: wsDir, additionalDirs: [] }, - capabilities(), - ); - } - - it('rejects reading media through a symlink that points outside the workspace', async () => { - const target = join(outsideDir, 'secret.png'); - await writeFile(target, pngBuffer()); - const link = join(wsDir, 'pic.png'); - await symlink(target, link); - - const result = await execute(makeRealFsTool(), { path: link }); - - expect(result.isError).toBe(true); - expect(result.output).toContain('symbolic link'); - }); - -}); diff --git a/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts b/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts index 55556aa95..5ff675d32 100644 --- a/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts +++ b/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts @@ -596,41 +596,8 @@ describe('AgentPermissionPolicyService git cwd write approval', () => { }); }); - it('asks for .pythinker-code/local.toml writes inside the git cwd', async () => { - await expect(evaluate({ - toolName: 'Write', - args: { path: '.pythinker-code/local.toml', content: 'x' }, - accesses: ToolAccesses.writeFile(join(workspaceDir, '.pythinker-code/local.toml')), - })).resolves.toMatchObject({ - policyName: 'fallback-ask', - result: { kind: 'ask' }, - }); - }); - it('asks for .pythinker-code/local.toml edits inside the git cwd', async () => { - await expect(evaluate({ - toolName: 'Edit', - args: { path: '.pythinker-code/local.toml', old_string: 'a', new_string: 'b' }, - accesses: ToolAccesses.readWriteFile(join(workspaceDir, '.pythinker-code/local.toml')), - })).resolves.toMatchObject({ - policyName: 'fallback-ask', - result: { kind: 'ask' }, - }); - }); - it.each(['local.toml', '.pythinker-code/local.toml.bak', '.pythinker-code/other.toml'])( - 'still approves %s inside the git cwd', - async (relativePath) => { - await expect(evaluate({ - toolName: 'Write', - args: { path: relativePath, content: 'x' }, - accesses: ToolAccesses.writeFile(join(workspaceDir, relativePath)), - })).resolves.toMatchObject({ - policyName: 'git-cwd-write-approve', - result: { kind: 'approve' }, - }); - }, - ); it('asks for git control files before git-cwd approval', async () => { await expect(evaluate({ diff --git a/packages/agent-core-v2/test/app/edit/tools/edit.test.ts b/packages/agent-core-v2/test/app/edit/tools/edit.test.ts index 2aacfd465..7d7e6cf48 100644 --- a/packages/agent-core-v2/test/app/edit/tools/edit.test.ts +++ b/packages/agent-core-v2/test/app/edit/tools/edit.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import * as posixPath from 'node:path/posix'; @@ -50,8 +50,7 @@ function createSpiedEditFs( const readText = options.readText ?? vi.fn(async () => ''); const writeText = options.writeText ?? vi.fn(async () => undefined); const stat = vi.fn(async () => ({ isFile: true, isDirectory: false, size: 0 })); - const realpath = vi.fn(async (path: string) => path); - const fs = { readText, writeText, stat, realpath } as unknown as IHostFileSystem; + const fs = { readText, writeText, stat } as unknown as IHostFileSystem; return { fs, readText, writeText }; } @@ -612,41 +611,3 @@ describe('EditTool', () => { } }); }); - -describe('EditTool symlink escape', () => { - let tmpDir: string; - let wsDir: string; - let outsideDir: string; - - beforeEach(() => { - disposables = new DisposableStore(); - }); - - beforeEach(async () => { - tmpDir = await mkdtemp(join(tmpdir(), 'edit-symlink-')); - wsDir = join(tmpDir, 'ws'); - outsideDir = join(tmpDir, 'outside'); - await mkdir(wsDir); - await mkdir(outsideDir); - }); - - afterEach(async () => { - disposables.dispose(); - await rm(tmpDir, { recursive: true, force: true }); - }); - - it('rejects editing through a symlink that points outside the workspace', async () => { - const target = join(outsideDir, 'config.txt'); - await writeFile(target, 'alpha beta'); - const link = join(wsDir, 'config.txt'); - await symlink(target, link); - const tool = buildTool(new HostFileSystem(), createTestEnv(), stubWorkspaceContext(wsDir)); - - const result = await execute(tool, { path: link, old_string: 'beta', new_string: 'gamma' }); - - expect(result).toMatchObject({ isError: true }); - expect(result.output).toContain('symbolic link'); - await expect(readFile(target, 'utf8')).resolves.toBe('alpha beta'); - }); - -}); diff --git a/packages/agent-core-v2/test/app/git/gitService.test.ts b/packages/agent-core-v2/test/app/git/gitService.test.ts index 18ec8dbe3..aafe30dee 100644 --- a/packages/agent-core-v2/test/app/git/gitService.test.ts +++ b/packages/agent-core-v2/test/app/git/gitService.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process'; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -173,78 +173,6 @@ describe('GitService', () => { }); }); - describe('repo-local config hardening', () => { - it.skipIf(process.platform === 'win32')( - 'does not execute fsmonitor or external diff commands from repo config', - async () => { - const outside = mkdtempSync(join(tmpdir(), 'git-service-evil-')); - try { - const marker = join(outside, 'ran'); - const helper = join(outside, 'helper.sh'); - writeFileSync(helper, `#!/bin/sh\ntouch "${marker}"\n`); - chmodSync(helper, 0o755); - - writeFileSync(join(repo, 'a.txt'), 'line1\n'); - commitAll('init'); - git(repo, 'config', 'core.fsmonitor', helper); - git(repo, 'config', 'diff.external', helper); - writeFileSync(join(repo, 'a.txt'), 'line1\nline2\n'); - - const status = await service.status(repo); - expect(status.entries).toEqual({ 'a.txt': 'modified' }); - const diff = await service.diff(repo, 'a.txt', join(repo, 'a.txt')); - expect(diff.diff).toContain('+line2'); - - expect(existsSync(marker)).toBe(false); - } finally { - rmSync(outside, { recursive: true, force: true }); - } - }, - 15000, - ); - - it.skipIf(process.platform === 'win32')( - 'does not execute repo-configured clean or process filters', - async () => { - const outside = mkdtempSync(join(tmpdir(), 'git-service-filter-')); - try { - const cleanMarker = join(outside, 'clean-ran'); - const processMarker = join(outside, 'process-ran'); - writeFileSync( - join(repo, '.gitattributes'), - '*.txt filter=evilclean\n*.md filter=evilproc\n', - ); - writeFileSync(join(repo, 'a.txt'), 'line1\n'); - writeFileSync(join(repo, 'b.md'), 'mark1\n'); - commitAll('init'); - git(repo, 'config', 'filter.evilclean.clean', `touch "${cleanMarker}"`); - git(repo, 'config', 'filter.evilclean.smudge', 'cat'); - git(repo, 'config', 'filter.evilproc.process', `touch "${processMarker}"; cat`); - - writeFileSync(join(repo, 'a.txt'), 'line2\n'); - writeFileSync(join(repo, 'b.md'), 'mark2\n'); - - git(repo, 'status', '--porcelain'); - expect(existsSync(cleanMarker)).toBe(true); - git(repo, 'diff', '--numstat', 'HEAD', '--'); - expect(existsSync(processMarker)).toBe(true); - rmSync(cleanMarker); - rmSync(processMarker); - - const status = await service.status(repo); - expect(status.entries).toEqual({ 'a.txt': 'modified', 'b.md': 'modified' }); - const diff = await service.diff(repo, 'a.txt', join(repo, 'a.txt')); - expect(diff.diff).toContain('+line2'); - - expect(existsSync(cleanMarker)).toBe(false); - expect(existsSync(processMarker)).toBe(false); - } finally { - rmSync(outside, { recursive: true, force: true }); - } - }, - 15000, - ); - }); describe('findWorkTree', () => { it('finds the repo root from a nested subdirectory', async () => { diff --git a/packages/agent-core-v2/test/app/git/hardening.test.ts b/packages/agent-core-v2/test/app/git/hardening.test.ts deleted file mode 100644 index 5d87c23b5..000000000 --- a/packages/agent-core-v2/test/app/git/hardening.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { mkdtemp, mkdir, symlink } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { describe, expect, it } from 'vitest'; - -import { - buildDriverOverrides, - hardenedGitConfigArgs, - INCLUDE_SECTION_RE, - isCoreWorktreeSafe, - parseGitDirPointer, - resolveConfigPaths, -} from '#/app/git/hardening'; - -describe('buildDriverOverrides', () => { - it('neutralizes filter and merge drivers found in probe output', () => { - expect(buildDriverOverrides(['filter.evil.clean\nfilter.evil.process\nfilter.evil.smudge\nmerge.evil.driver\n'])).toEqual([ - '-c', - 'filter.evil.clean=', - '-c', - 'filter.evil.process=', - '-c', - 'filter.evil.smudge=', - '-c', - 'merge.evil.driver=', - ]); - }); - - it('returns null when a driver name contains an equals sign', () => { - expect(buildDriverOverrides(['filter.evil=x.clean\n'])).toBeNull(); - expect(buildDriverOverrides(['merge.evil=x.driver\n'])).toBeNull(); - }); - - it('ignores unrelated keys and empty output', () => { - expect(buildDriverOverrides(['core.fsmonitor\n', ''])).toEqual([]); - }); -}); - -describe('parseGitDirPointer', () => { - it('reads the pointer from the first line', () => { - expect(parseGitDirPointer('gitdir: ../actual-git\n')).toBe('../actual-git'); - expect(parseGitDirPointer('\uFEFF gitdir: /abs/gitdir\r\nsecond')).toBe('/abs/gitdir'); - }); - - it('returns undefined for content without a gitdir pointer', () => { - expect(parseGitDirPointer('not a pointer')).toBeUndefined(); - expect(parseGitDirPointer('gitdir:\n')).toBeUndefined(); - }); -}); - -describe('resolveConfigPaths', () => { - it('lists the per-worktree config files', () => { - expect(resolveConfigPaths('/repo/.git', undefined)).toEqual([ - '/repo/.git/config', - '/repo/.git/config.worktree', - ]); - }); - - it('adds the common config when a commondir file exists', () => { - expect(resolveConfigPaths('/repo/.git/worktrees/wt', '../..\n')).toEqual([ - '/repo/.git/worktrees/wt/config', - '/repo/.git/worktrees/wt/config.worktree', - '/repo/.git/config', - ]); - }); -}); - -describe('isCoreWorktreeSafe', () => { - it('accepts a worktree that resolves to the work tree root', () => { - expect(isCoreWorktreeSafe('/repo', '/repo/.git', '/repo')).toBe(true); - expect(isCoreWorktreeSafe('..', '/repo/.git', '/repo')).toBe(true); - }); - - it('rejects a worktree that resolves anywhere else', () => { - expect(isCoreWorktreeSafe('/outside', '/repo/.git', '/repo')).toBe(false); - expect(isCoreWorktreeSafe('../..', '/repo/.git', '/repo')).toBe(false); - }); - - it('compares with Windows path semantics', () => { - const originalPlatform = process.platform; - Object.defineProperty(process, 'platform', { value: 'win32' }); - try { - expect(isCoreWorktreeSafe('/REPO', '/repo/.git', '/repo')).toBe(true); - expect(isCoreWorktreeSafe('/OTHER', '/repo/.git', '/repo')).toBe(false); - } finally { - Object.defineProperty(process, 'platform', { value: originalPlatform }); - } - }); -}); - -describe('hardenedGitConfigArgs', () => { - it('rejects a work tree that matches the symlink target of .git, not the workspace', async () => { - const root = await mkdtemp(join(tmpdir(), 'pythinker-git-link-')); - const workspace = join(root, 'workspace'); - const outside = join(root, 'outside', 'project'); - await mkdir(join(outside, '.git'), { recursive: true }); - await mkdir(workspace, { recursive: true }); - await symlink(join(outside, '.git'), join(workspace, '.git')); - - const probe = async () => ({ exitCode: 0, stdout: `${outside}\n` }); - await expect(hardenedGitConfigArgs(workspace, probe)).resolves.toBeNull(); - }); -}); - -describe('INCLUDE_SECTION_RE', () => { - it('matches include and includeIf section headers', () => { - expect(INCLUDE_SECTION_RE.test('[include]\n\tpath = extra.conf')).toBe(true); - expect(INCLUDE_SECTION_RE.test('[includeIf "gitdir:~/src/**"]')).toBe(true); - expect(INCLUDE_SECTION_RE.test(' [IncludeIf "gitdir:~/src/**"]')).toBe(true); - }); - - it('does not match include mentions outside section headers', () => { - expect(INCLUDE_SECTION_RE.test('url = https://example.com/include.git')).toBe(false); - expect(INCLUDE_SECTION_RE.test('[includefoo]')).toBe(false); - }); -}); diff --git a/packages/agent-core-v2/test/features/dynamic_workflow/dynamic_workflow.test.ts b/packages/agent-core-v2/test/features/dynamic_workflow/dynamic_workflow.test.ts index edd7c822d..11332a227 100644 --- a/packages/agent-core-v2/test/features/dynamic_workflow/dynamic_workflow.test.ts +++ b/packages/agent-core-v2/test/features/dynamic_workflow/dynamic_workflow.test.ts @@ -10,7 +10,6 @@ import { Event } from '#/_base/event'; import { ILogService } from '#/_base/log/log'; import { Error2, ErrorCodes } from '#/errors'; import { IModelCatalog, type Model } from '#/llm-adapter/model/catalog'; -import { IGitService } from '#/app/git/git'; import { stubLog } from '../../_base/log/stubs'; import { stubFlag } from '../../app/flag/stubs'; import { stubAgentContext } from '../../agent/agentContext/stubs'; @@ -265,15 +264,10 @@ function realSubagents( }, } as unknown as IModelCatalog; const sessionContext = { _serviceBrand: undefined, cwd: '/repo' } as unknown as ISessionContext; - const git = { - _serviceBrand: undefined, - runGit: vi.fn(async () => ({ exitCode: 1, stdout: '', stderr: '' })), - } as unknown as IGitService; return new SessionSubagentService( agentLifecycle, catalog, config, - git, modelCatalog, sessionContext, stubLog(), diff --git a/packages/agent-core-v2/test/features/tower/store.test.ts b/packages/agent-core-v2/test/features/tower/store.test.ts index f108b46e3..c965c11c7 100644 --- a/packages/agent-core-v2/test/features/tower/store.test.ts +++ b/packages/agent-core-v2/test/features/tower/store.test.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process'; -import { chmod, mkdir, mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { promisify } from 'node:util'; @@ -12,7 +12,6 @@ import { TowerProtocolError, TowerStore, commitPaths, - isWorktreeDirty, parseFrontmatter, worktreeAddNewBranch, } from '../../../src/features/tower/protocol'; @@ -317,74 +316,7 @@ describe('init', () => { }); }); -describe('git invocation hardening', () => { - it.skipIf(process.platform === 'win32')( - 'does not run repo-configured hooks when committing', - async () => { - const hooksDir = join(repo, 'evil-hooks'); - await mkdir(hooksDir, { recursive: true }); - const marker = join(repo, 'hook-ran'); - await writeFile(join(hooksDir, 'pre-commit'), `#!/bin/sh\ntouch "${marker}"\n`); - await chmod(join(hooksDir, 'pre-commit'), 0o755); - await git(repo, 'config', 'core.hooksPath', hooksDir); - await writeFile(join(repo, 'x.txt'), 'x\n'); - - await commitPaths(repo, ['x.txt'], 'commit x'); - - expect(await git(repo, 'log', '-1', '--format=%s')).toBe('commit x'); - await expect(stat(marker)).rejects.toThrow(); - }, - ); - - it.skipIf(process.platform === 'win32')( - 'does not run a repo-configured fsmonitor command on status', - async () => { - const outside = await mkdtemp(join(tmpdir(), 'tower-fsm-')); - try { - const marker = join(outside, 'fsm-ran'); - const helper = join(outside, 'helper.sh'); - await writeFile(helper, `#!/bin/sh\ntouch "${marker}"\n`); - await chmod(helper, 0o755); - await git(repo, 'config', 'core.fsmonitor', helper); - - expect(await isWorktreeDirty(repo)).toBe(false); - await expect(stat(marker)).rejects.toThrow(); - } finally { - await rm(outside, { recursive: true, force: true }); - } - }, - ); - - it.skipIf(process.platform === 'win32')( - 'does not run repo-configured clean filters on status and add', - async () => { - const outside = await mkdtemp(join(tmpdir(), 'tower-filter-')); - try { - const marker = join(outside, 'filter-ran'); - await writeFile(join(repo, '.gitattributes'), '*.txt filter=evil\n'); - await writeFile(join(repo, 'f.txt'), 'aaaa\n'); - await git(repo, 'add', '-A'); - await git(repo, 'commit', '-m', 'add f'); - await git(repo, 'config', 'filter.evil.clean', `touch "${marker}"`); - await git(repo, 'config', 'filter.evil.smudge', 'cat'); - - await writeFile(join(repo, 'f.txt'), 'bbbb\n'); - await git(repo, 'status', '--porcelain'); - await stat(marker); - await rm(marker); - - expect(await isWorktreeDirty(repo)).toBe(true); - await expect(stat(marker)).rejects.toThrow(); - - await commitPaths(repo, ['f.txt'], 'commit f'); - expect(await git(repo, 'log', '-1', '--format=%s')).toBe('commit f'); - await expect(stat(marker)).rejects.toThrow(); - } finally { - await rm(outside, { recursive: true, force: true }); - } - }, - ); - +describe('tower commit identity', () => { it('falls back to the tower identity when the repository has no committer identity', async () => { await git(repo, 'config', '--unset', 'user.name'); await git(repo, 'config', '--unset', 'user.email'); diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/glob.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/glob.test.ts index c0ea2303a..373c7cb18 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/glob.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/glob.test.ts @@ -51,8 +51,7 @@ function fileStat(): HostFileStat { function createTestFs(opts: { stat?: ReturnType; readdir?: ReturnType } = {}) { const stat = opts.stat ?? vi.fn(async (): Promise => dirStat()); const readdir = opts.readdir ?? vi.fn(async (): Promise => []); - const realpath = vi.fn(async (path: string) => path); - const fs = { stat, readdir, realpath } as unknown as IHostFileSystem; + const fs = { stat, readdir } as unknown as IHostFileSystem; return { fs, stat, readdir }; } @@ -1047,59 +1046,3 @@ describe('GlobTool integration (real ripgrep)', () => { } }); }); - -describe('GlobTool symlink escape', () => { - let tmpDir: string; - let wsDir: string; - let outsideDir: string; - - beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'glob-symlink-')); - wsDir = path.join(tmpDir, 'ws'); - outsideDir = path.join(tmpDir, 'outside'); - await fs.mkdir(wsDir); - await fs.mkdir(outsideDir); - }); - - afterEach(async () => { - await fs.rm(tmpDir, { recursive: true, force: true }); - }); - - function makeRealFsTool(spawn: ReturnType) { - return new GlobTool( - createRuntime(new HostFileSystem(), createTestEnv(), createTestProcessService(spawn)), - stubWorkspaceContext(wsDir), - noopTelemetryService, - ); - } - - it('rejects a search root symlink that points outside the workspace', async () => { - await fs.writeFile(path.join(outsideDir, 'secret.ts'), ''); - await fs.symlink(outsideDir, path.join(wsDir, 'external')); - const spawn = execReturning(''); - const tool = makeRealFsTool(spawn); - - const result = await execute(tool, { pattern: '*.ts', path: path.join(wsDir, 'external') }); - - expect(result).toMatchObject({ isError: true }); - expect(toolContentString(result)).toMatch(/symbolic link/); - expect(spawn).not.toHaveBeenCalled(); - }); - - it('allows searching when the workspace directory has a sensitive name', async () => { - const credDir = path.join(tmpDir, 'credentials'); - await fs.mkdir(credDir); - await fs.writeFile(path.join(credDir, 'a.ts'), ''); - const spawn = execReturning(''); - const tool = new GlobTool( - createRuntime(new HostFileSystem(), createTestEnv(), createTestProcessService(spawn)), - stubWorkspaceContext(credDir), - noopTelemetryService, - ); - - const result = await execute(tool, { pattern: '*.ts' }); - - expect(result.isError).not.toBe(true); - expect(spawn).toHaveBeenCalled(); - }); -}); 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 b4c274cc6..40e26c9b8 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 @@ -1,9 +1,6 @@ -import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; import { Readable, type Writable } from 'node:stream'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { DisposableStore, toDisposable } from '#/_base/di/lifecycle'; import { Service } from '#/_base/di/service'; @@ -36,7 +33,6 @@ import { import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem, type HostFileStat } from '#/os/interface/hostFileSystem'; import { IHostProcessService, type IHostProcess } from '#/os/interface/hostProcess'; -import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; import { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog'; import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; import { Event } from '#/_base/event'; @@ -164,7 +160,7 @@ function createTestFs(pyaos: FakePyaos): IHostFileSystem { readdir: () => notImplemented('readdir'), mkdir: () => notImplemented('mkdir'), remove: () => notImplemented('remove'), - realpath: (path) => Promise.resolve(path), + realpath: () => notImplemented('realpath'), }; } @@ -1663,9 +1659,7 @@ describe('GrepTool', () => { const tool = new GrepTool(createFakePyaos({ exec }), workspace); const resultPromise = executeTool(tool, context({ pattern: 'hit' }, controller.signal)); - setTimeout(() => { - controller.abort(); - }, 0); + controller.abort(); const result = await Promise.race([ resultPromise, new Promise<'timed out'>((resolve) => { @@ -2136,58 +2130,3 @@ describe('GrepTool', () => { expect(exec).not.toHaveBeenCalled(); }); }); - -describe('GrepTool symlink escape', () => { - let tmpDir: string; - let wsDir: string; - let outsideDir: string; - - beforeEach(async () => { - tmpDir = await mkdtemp(join(tmpdir(), 'grep-symlink-')); - wsDir = join(tmpDir, 'ws'); - outsideDir = join(tmpDir, 'outside'); - await mkdir(wsDir); - await mkdir(outsideDir); - }); - - afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }); - }); - - function makeRealFsTool(spawn: ReturnType) { - const environment = createTestEnv(createFakePyaos()); - const backend = Object.assign( - new FakeRuntime( - { workspaceId: 'workspace', runtimeId: 'local', generation: 'test' }, - { capabilities: ['fs', 'process'], pathClass: environment.pathClass }, - ), - { - process: { _serviceBrand: undefined, spawn } as unknown as IHostProcessService, - fs: new HostFileSystem(), - environment, - }, - ); - const runtime: IAgentRuntimeService = { - _serviceBrand: undefined, - onDidChange: () => ({ dispose: () => {} }), - isAvailable: () => true, - inspect: () => backend, - acquire: () => ({ runtime: backend, track: (resource) => resource, dispose: () => {} }), - }; - return new ProductionGrepTool(runtime, stubWorkspaceContext(wsDir), noopTelemetryService); - } - - it('rejects a search root symlink that points outside the workspace', async () => { - await writeFile(join(outsideDir, 'secret.txt'), 'hit'); - await symlink(outsideDir, join(wsDir, 'external')); - const spawn = vi.fn(); - const tool = makeRealFsTool(spawn); - - const result = await executeTool(tool, context({ pattern: 'hit', path: join(wsDir, 'external') })); - - expect(result).toMatchObject({ isError: true }); - expect(toolContentString(result)).toMatch(/symbolic link/); - expect(spawn).not.toHaveBeenCalled(); - }); - -}); diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts index 8a7cf2377..2e5fd178d 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts @@ -1,15 +1,10 @@ -import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { PathSecurityError } from '#/tool/path-access'; import { MEDIA_SNIFF_BYTES } from '#/agent/media/file-type'; import type { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog'; import { stubWorkspaceContext } from '../../../../session/workspaceContext/stub-workspace-context'; import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; -import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; import { type ReadInput, ReadInputSchema, @@ -119,8 +114,7 @@ function createSpiedFs(content: string) { const readLines = vi.fn().mockImplementation(() => generateLines(content)); const readText = vi.fn(async () => content); const stat = vi.fn(async () => ({ isFile: true, isDirectory: false, size: bytes.length })); - const realpath = vi.fn(async (path: string) => path); - const fs = { cwd: '/', readBytes, readLines, readText, stat, realpath } as unknown as IHostFileSystem; + const fs = { cwd: '/', readBytes, readLines, readText, stat } as unknown as IHostFileSystem; return { fs, readBytes, readLines, readText, stat }; } @@ -162,7 +156,7 @@ function createSpiedMapFs(files: Record) { size: file.size ?? file.bytes.length, }; }); - const fs = { cwd: '/', readBytes, readLines, readText, stat, realpath: vi.fn(async (path: string) => path) } as unknown as IHostFileSystem; + const fs = { cwd: '/', readBytes, readLines, readText, stat } as unknown as IHostFileSystem; return { fs, readBytes, readLines, readText, stat }; } @@ -1264,8 +1258,7 @@ describe('ReadTool', () => { n === undefined ? bytes : bytes.subarray(0, n), ); const stat = vi.fn(async () => ({ isFile: true, isDirectory: false, size: bytes.length })); - const realpath = vi.fn(async (path: string) => path); - const fs = { cwd: '/', readBytes, readLines, readText, stat, realpath } as unknown as IHostFileSystem; + const fs = { cwd: '/', readBytes, readLines, readText, stat } as unknown as IHostFileSystem; const tool = createReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE); const result = await execute(tool, { path: '/tmp/large.txt' }); @@ -1507,77 +1500,3 @@ describe('ReadTool', () => { ).rejects.toMatchObject({ code: 'runtime.unavailable' }); }); }); - -describe('ReadTool symlink escape', () => { - let tmpDir: string; - let wsDir: string; - let outsideDir: string; - - beforeEach(async () => { - tmpDir = await mkdtemp(join(tmpdir(), 'read-symlink-')); - wsDir = join(tmpDir, 'ws'); - outsideDir = join(tmpDir, 'outside'); - await mkdir(wsDir); - await mkdir(outsideDir); - }); - - afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }); - }); - - function makeRealFsTool(workDir: string, additionalDirs: readonly string[] = []) { - return createReadTool(new HostFileSystem(), createTestEnv(), stubWorkspaceContext(workDir, additionalDirs)); - } - - it('rejects reading through a symlink that points outside the workspace', async () => { - const target = join(outsideDir, 'secret.txt'); - await writeFile(target, 'top-secret'); - const link = join(wsDir, 'notes.md'); - await symlink(target, link); - - const result = await execute(makeRealFsTool(wsDir), { path: link }); - - expect(result).toMatchObject({ isError: true }); - expect(toolContentString(result)).toMatch(/symbolic link/); - expect(toolContentString(result)).not.toContain('top-secret'); - }); - - it('blocks reading through a symlink that resolves to a sensitive file', async () => { - const target = join(outsideDir, 'id_rsa'); - await writeFile(target, 'secret-key'); - const link = join(wsDir, 'notes.md'); - await symlink(target, link); - - const result = await execute(makeRealFsTool(wsDir), { path: link }); - - expect(result).toMatchObject({ isError: true }); - expect(toolContentString(result)).toContain('sensitive-file pattern'); - expect(toolContentString(result)).not.toContain('secret-key'); - }); - - it('blocks reading an absolute outside symlink that resolves to a sensitive file', async () => { - const target = join(outsideDir, 'id_rsa'); - await writeFile(target, 'secret-key'); - const link = join(outsideDir, 'notes.md'); - await symlink(target, link); - - const result = await execute(makeRealFsTool(wsDir), { path: link }); - - expect(result).toMatchObject({ isError: true }); - expect(toolContentString(result)).toContain('sensitive-file pattern'); - expect(toolContentString(result)).not.toContain('secret-key'); - }); - - it('allows reading through a symlink that stays inside the workspace', async () => { - const target = join(wsDir, 'real.txt'); - await writeFile(target, 'alpha\n'); - const link = join(wsDir, 'alias.txt'); - await symlink(target, link); - - const result = await execute(makeRealFsTool(wsDir), { path: link }); - - expect(result.isError).not.toBe(true); - expect(toolContentString(result)).toContain('1\talpha'); - }); - -}); diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/write.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/write.test.ts index 265ca786f..910258929 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/write.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/write.test.ts @@ -1,12 +1,8 @@ -import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { PathSecurityError } from '#/tool/path-access'; import type { HostFileStat, IHostFileSystem } from '#/os/interface/hostFileSystem'; -import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; import { stubWorkspaceContext } from '../../../../session/workspaceContext/stub-workspace-context'; import { type WriteInput, WriteInputSchema } from '#/agent/tools/os/write/write'; import { WriteTool } from '#/agent/tools/os/write/writeTool'; @@ -427,59 +423,3 @@ describe('WriteTool', () => { expect(writeText).toHaveBeenCalledWith('/workspace-sneaky/file.txt', 'content'); }); }); - -describe('WriteTool symlink escape', () => { - let tmpDir: string; - let wsDir: string; - let outsideDir: string; - - beforeEach(async () => { - tmpDir = await mkdtemp(join(tmpdir(), 'write-symlink-')); - wsDir = join(tmpDir, 'ws'); - outsideDir = join(tmpDir, 'outside'); - await mkdir(wsDir); - await mkdir(outsideDir); - }); - - afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }); - }); - - it('rejects writes through a symlink that points outside the workspace', async () => { - const target = join(outsideDir, 'target.txt'); - await writeFile(target, 'original'); - const link = join(wsDir, 'link.txt'); - await symlink(target, link); - const tool = makeToolWithFs(new HostFileSystem(), stubWorkspaceContext(wsDir)); - - const result = await execute(tool, { path: link, content: 'pwned' }); - - expect(result).toMatchObject({ isError: true }); - expect(toolContentString(result)).toMatch(/symbolic link/); - await expect(readFile(target, 'utf8')).resolves.toBe('original'); - }); - - it('allows writes through a symlink that stays inside the workspace', async () => { - const target = join(wsDir, 'real.txt'); - await writeFile(target, 'original'); - const link = join(wsDir, 'alias.txt'); - await symlink(target, link); - const tool = makeToolWithFs(new HostFileSystem(), stubWorkspaceContext(wsDir)); - - const result = await execute(tool, { path: link, content: 'updated' }); - - expect(result.isError).toBeFalsy(); - await expect(readFile(target, 'utf8')).resolves.toBe('updated'); - }); - - it('allows writes to the project config through its real path', async () => { - const configDir = join(wsDir, '.pythinker-code'); - await mkdir(configDir); - const tool = makeToolWithFs(new HostFileSystem(), stubWorkspaceContext(wsDir)); - - const result = await execute(tool, { path: join(configDir, 'local.toml'), content: 'updated' }); - - expect(result.isError).toBeFalsy(); - await expect(readFile(join(configDir, 'local.toml'), 'utf8')).resolves.toBe('updated'); - }); -}); diff --git a/packages/agent-core-v2/test/persistence/backends/node-fs/projectLocalConfigService.test.ts b/packages/agent-core-v2/test/persistence/backends/node-fs/projectLocalConfigService.test.ts deleted file mode 100644 index 95347ac30..000000000 --- a/packages/agent-core-v2/test/persistence/backends/node-fs/projectLocalConfigService.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { mkdtempSync } from 'node:fs'; -import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; - -import { dirname, join } from 'pathe'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import type { IBootstrapService } from '#/app/bootstrap/bootstrap'; -import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; -import { FileProjectLocalConfigService } from '#/persistence/backends/node-fs/projectLocalConfigService'; - -describe('FileProjectLocalConfigService additional_dir scope', () => { - let homeDir: string; - let workDir: string; - let cleanupDirs: string[]; - - beforeEach(() => { - homeDir = mkdtempSync(join(tmpdir(), 'pythinker-local-config-home-')); - workDir = mkdtempSync(join(tmpdir(), 'pythinker-local-config-work-')); - cleanupDirs = [homeDir, workDir]; - }); - - afterEach(async () => { - await Promise.all(cleanupDirs.map((dir) => rm(dir, { recursive: true, force: true }))); - }); - - function createService(): FileProjectLocalConfigService { - const bootstrap = { _serviceBrand: undefined, osHomeDir: homeDir } as IBootstrapService; - return new FileProjectLocalConfigService(bootstrap, new HostFileSystem()); - } - - async function writeLocalToml(additionalDirs: readonly string[]): Promise { - const dir = join(workDir, '.pythinker-code'); - await mkdir(dir, { recursive: true }); - const entries = additionalDirs.map((dir) => `"${dir}"`).join(', '); - await writeFile(join(dir, 'local.toml'), `[workspace]\nadditional_dir = [${entries}]\n`, 'utf8'); - } - - it('rejects an additional_dir that resolves to the user home directory', async () => { - await writeLocalToml([homeDir]); - - await expect(createService().readAdditionalDirs(workDir)).rejects.toMatchObject({ - code: 'config.invalid', - }); - }); - - it('rejects a bare ~ additional_dir', async () => { - await writeLocalToml(['~']); - - await expect(createService().readAdditionalDirs(workDir)).rejects.toMatchObject({ - code: 'config.invalid', - }); - }); - - it('rejects an additional_dir that resolves to the filesystem root', async () => { - await writeLocalToml(['/']); - - await expect(createService().readAdditionalDirs(workDir)).rejects.toMatchObject({ - code: 'config.invalid', - }); - }); - - it('rejects an additional_dir that is an ancestor of the user home directory', async () => { - await writeLocalToml([dirname(homeDir)]); - - await expect(createService().readAdditionalDirs(workDir)).rejects.toMatchObject({ - code: 'config.invalid', - }); - }); - - it('rejects an additional_dir that is the real target of a symlinked home directory', async () => { - const realHome = await mkdtemp(join(tmpdir(), 'pythinker-local-config-realhome-')); - cleanupDirs.push(realHome); - const homeLink = join(workDir, 'home-link'); - await symlink(realHome, homeLink); - const bootstrap = { _serviceBrand: undefined, osHomeDir: homeLink } as IBootstrapService; - const service = new FileProjectLocalConfigService(bootstrap, new HostFileSystem()); - await writeLocalToml([realHome]); - - await expect(service.readAdditionalDirs(workDir)).rejects.toMatchObject({ - code: 'config.invalid', - }); - }); - - it('still allows a subdirectory of the home directory', async () => { - const shared = join(homeDir, 'shared'); - await mkdir(shared, { recursive: true }); - cleanupDirs.push(shared); - await writeLocalToml([shared]); - - await expect(createService().readAdditionalDirs(workDir)).resolves.toMatchObject({ - additionalDirs: [shared], - }); - }); - - it('rejects an additional_dir that symlinks to the user home directory', async () => { - const link = join(workDir, 'home-link'); - await symlink(homeDir, link); - await writeLocalToml([link]); - - await expect(createService().readAdditionalDirs(workDir)).rejects.toMatchObject({ - code: 'config.invalid', - }); - }); - - it('rejects an additional_dir that symlinks to the filesystem root', async () => { - const link = join(workDir, 'root-link'); - await symlink('/', link); - await writeLocalToml([link]); - - await expect(createService().readAdditionalDirs(workDir)).rejects.toMatchObject({ - code: 'config.invalid', - }); - }); - - it('still allows a symlink into a subdirectory of the home directory', async () => { - const shared = join(homeDir, 'shared'); - await mkdir(shared, { recursive: true }); - const link = join(workDir, 'shared-link'); - await symlink(shared, link); - await writeLocalToml([link]); - - await expect(createService().readAdditionalDirs(workDir)).resolves.toMatchObject({ - additionalDirs: [link], - }); - }); -}); diff --git a/packages/agent-core-v2/test/session/agentLifecycle/profile/gitContext.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/profile/gitContext.test.ts index 3bb6dc30c..b6b643cec 100644 --- a/packages/agent-core-v2/test/session/agentLifecycle/profile/gitContext.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/profile/gitContext.test.ts @@ -1,3 +1,5 @@ +import { Readable, type Writable } from 'node:stream'; + import { describe, expect, it, vi } from 'vitest'; import { @@ -6,25 +8,37 @@ import { sanitizeRemoteUrl, } from '#/session/agentLifecycle/profile/gitContext'; import type { ILogger } from '#/_base/log/log'; -import type { IGitService, RunGitResult } from '#/app/git/git'; +import type { IHostProcess, IHostProcessService } from '#/os/interface/hostProcess'; + +function processWith(stdout: string, exitCode: number, stderr = ''): IHostProcess { + const stdoutStream = Readable.from([Buffer.from(stdout)]); + const stderrStream = Readable.from([Buffer.from(stderr)]); + return { + _serviceBrand: undefined, + stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable, + stdout: stdoutStream, + stderr: stderrStream, + pid: 1, + exitCode, + wait: vi.fn().mockResolvedValue(exitCode), + kill: vi.fn(async () => {}), + dispose: vi.fn(async () => { + stdoutStream.destroy(); + stderrStream.destroy(); + }), + }; +} type GitScript = Record; -function gitService(script: GitScript): { git: IGitService; runGit: ReturnType } { - const runGit = vi.fn(async (_cwd: string, args: readonly string[]): Promise => { - const key = args.join(' '); +function gitRunner(script: GitScript): { process: IHostProcessService; spawn: ReturnType } { + const spawn = vi.fn(async (_command: string, args: readonly string[]) => { + const key = args.slice(2).join(' '); const out = script[key]; - if (out === undefined) return { exitCode: 1, stdout: '', stderr: '' }; - return { exitCode: out.exitCode ?? 0, stdout: out.stdout ?? '', stderr: out.stderr ?? '' }; + if (out === undefined) return processWith('', 1); + return processWith(out.stdout ?? '', out.exitCode ?? 0, out.stderr ?? ''); }); - const git = { - _serviceBrand: undefined, - status: vi.fn(), - diff: vi.fn(), - findWorkTree: vi.fn(), - runGit, - } as unknown as IGitService; - return { git, runGit }; + return { process: { _serviceBrand: undefined, spawn } as IHostProcessService, spawn }; } function spyLogger(): { @@ -46,7 +60,7 @@ function spyLogger(): { describe('collectGitContext', () => { it('builds a git-context block with all sections', async () => { - const { git } = gitService({ + const { process: hostProcess } = gitRunner({ 'rev-parse --is-inside-work-tree': { stdout: 'true\n' }, 'remote get-url origin': { stdout: 'git@github.com:owner/repo.git\n' }, 'symbolic-ref --short HEAD': { stdout: 'main\n' }, @@ -54,7 +68,7 @@ describe('collectGitContext', () => { 'log -3 --format=%h %s': { stdout: 'abc123 Initial commit\ndef456 second commit' }, }); - const block = await collectGitContext(git, '/repo'); + const block = await collectGitContext(hostProcess, '/repo'); expect(block.startsWith('\n')).toBe(true); expect(block.endsWith('\n')).toBe(true); @@ -69,7 +83,7 @@ describe('collectGitContext', () => { }); it('returns an unavailable block when the directory is not a git repository', async () => { - const { git } = gitService({ + const { process: hostProcess } = gitRunner({ 'rev-parse --is-inside-work-tree': { exitCode: 128, stderr: 'fatal: not a git repository (or any of the parent directories): .git', @@ -77,7 +91,7 @@ describe('collectGitContext', () => { }); const { logger, debug, warn } = spyLogger(); - await expect(collectGitContext(git, '/not-a-repo', logger)).resolves.toBe( + await expect(collectGitContext(hostProcess, '/not-a-repo', logger)).resolves.toBe( '', ); expect(debug).not.toHaveBeenCalled(); @@ -85,12 +99,12 @@ describe('collectGitContext', () => { }); it('returns an empty string when rev-parse fails for a reason other than not-a-repo', async () => { - const { git } = gitService({ + const { process: hostProcess } = gitRunner({ 'rev-parse --is-inside-work-tree': { exitCode: 1, stderr: 'fatal: some other git error' }, }); const { logger, debug } = spyLogger(); - await expect(collectGitContext(git, '/repo', logger)).resolves.toBe(''); + await expect(collectGitContext(hostProcess, '/repo', logger)).resolves.toBe(''); expect(debug).toHaveBeenCalledWith( 'git context command failed', expect.objectContaining({ @@ -102,12 +116,15 @@ describe('collectGitContext', () => { }); it('returns an empty string when git fails to spawn', async () => { - const { git } = gitService({ - 'rev-parse --is-inside-work-tree': { exitCode: -1, stderr: 'spawn failed' }, - }); + const hostProcess = { + _serviceBrand: undefined, + spawn: vi.fn(async (): Promise => { + throw new Error('spawn failed'); + }), + } as IHostProcessService; const { logger, warn } = spyLogger(); - await expect(collectGitContext(git, '/repo', logger)).resolves.toBe(''); + await expect(collectGitContext(hostProcess, '/repo', logger)).resolves.toBe(''); expect(warn).toHaveBeenCalledWith( 'git context command failed to spawn', expect.objectContaining({ command: 'git rev-parse --is-inside-work-tree' }), @@ -116,7 +133,7 @@ describe('collectGitContext', () => { it('caps dirty files at 20 and reports the remainder', async () => { const dirty = Array.from({ length: 25 }, (_, i) => ` M src/f${String(i)}.ts`).join('\n'); - const { git } = gitService({ + const { process: hostProcess } = gitRunner({ 'rev-parse --is-inside-work-tree': { stdout: 'true' }, 'remote get-url origin': { stdout: '' }, 'symbolic-ref --short HEAD': { stdout: '' }, @@ -124,22 +141,22 @@ describe('collectGitContext', () => { 'log -3 --format=%h %s': { stdout: '' }, }); - const block = await collectGitContext(git, '/repo'); + const block = await collectGitContext(hostProcess, '/repo'); expect(block).toContain('Dirty files (25):'); expect(block).toContain(' ... and 5 more'); }); it('returns an empty string when only the working directory is known', async () => { - const { git } = gitService({ + const { process: hostProcess } = gitRunner({ 'rev-parse --is-inside-work-tree': { stdout: 'true' }, }); - await expect(collectGitContext(git, '/repo')).resolves.toBe(''); + await expect(collectGitContext(hostProcess, '/repo')).resolves.toBe(''); }); it('omits both Remote and Project for a disallowed remote host', async () => { - const { git } = gitService({ + const { process: hostProcess } = gitRunner({ 'rev-parse --is-inside-work-tree': { stdout: 'true' }, 'remote get-url origin': { stdout: 'git@internal.example.test:secret/repo.git' }, 'symbolic-ref --short HEAD': { stdout: 'main' }, @@ -147,7 +164,7 @@ describe('collectGitContext', () => { 'log -3 --format=%h %s': { stdout: '' }, }); - const block = await collectGitContext(git, '/repo'); + const block = await collectGitContext(hostProcess, '/repo'); expect(block).not.toContain('Remote:'); expect(block).not.toContain('Project:'); @@ -156,7 +173,7 @@ describe('collectGitContext', () => { }); it('keeps branch and status when the origin remote is absent', async () => { - const { git } = gitService({ + const { process: hostProcess } = gitRunner({ 'rev-parse --is-inside-work-tree': { stdout: 'true' }, 'remote get-url origin': { exitCode: 2, stderr: "error: No such remote 'origin'" }, 'symbolic-ref --short HEAD': { stdout: 'main' }, @@ -165,7 +182,7 @@ describe('collectGitContext', () => { }); const { logger, debug } = spyLogger(); - const block = await collectGitContext(git, '/repo', logger); + const block = await collectGitContext(hostProcess, '/repo', logger); expect(block).toContain('Branch: main'); expect(block).toContain('Dirty files (1):'); @@ -179,7 +196,7 @@ describe('collectGitContext', () => { }); it('keeps branch and status when the repository has no commits yet', async () => { - const { git } = gitService({ + const { process: hostProcess } = gitRunner({ 'rev-parse --is-inside-work-tree': { stdout: 'true' }, 'remote get-url origin': { stdout: 'https://github.com/acme/widgets.git' }, 'symbolic-ref --short HEAD': { stdout: 'main' }, @@ -190,7 +207,7 @@ describe('collectGitContext', () => { }, }); - const block = await collectGitContext(git, '/repo'); + const block = await collectGitContext(hostProcess, '/repo'); expect(block).toContain('Branch: main'); expect(block).toContain('Remote: https://github.com/acme/widgets.git'); @@ -199,7 +216,7 @@ describe('collectGitContext', () => { }); it('omits the Branch section in detached HEAD state', async () => { - const { git } = gitService({ + const { process: hostProcess } = gitRunner({ 'rev-parse --is-inside-work-tree': { stdout: 'true' }, 'symbolic-ref --short HEAD': { exitCode: 128, @@ -210,24 +227,50 @@ describe('collectGitContext', () => { 'log -3 --format=%h %s': { stdout: 'abc123 first commit' }, }); - const block = await collectGitContext(git, '/repo'); + const block = await collectGitContext(hostProcess, '/repo'); expect(block).not.toContain('Branch:'); expect(block).toContain('Remote: https://github.com/acme/widgets.git'); expect(block).toContain('Recent commits:'); }); - it('treats a timed-out git command as a failure', async () => { - const { git } = gitService({ - 'rev-parse --is-inside-work-tree': { exitCode: -1, stderr: '' }, - }); - const { logger, warn } = spyLogger(); - - await expect(collectGitContext(git, '/repo', logger)).resolves.toBe(''); - expect(warn).toHaveBeenCalledWith( - 'git context command failed to spawn', - expect.objectContaining({ command: 'git rev-parse --is-inside-work-tree' }), - ); + it('treats a hanging git command as a failure (timeout)', async () => { + vi.useFakeTimers(); + try { + const hostProcess = { + _serviceBrand: undefined, + spawn: vi.fn(async (): Promise => { + let release: (code: number) => void = () => {}; + const exited = new Promise((resolve) => { + release = resolve; + }); + return { + _serviceBrand: undefined, + stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable, + stdout: Readable.from(['']), + stderr: Readable.from(['']), + pid: 1, + exitCode: null, + wait: vi.fn(() => exited), + kill: vi.fn(async () => { + release(137); + }), + dispose: vi.fn(), + }; + }), + } as IHostProcessService; + const { logger, debug } = spyLogger(); + + const promise = collectGitContext(hostProcess, '/repo', logger); + await vi.advanceTimersByTimeAsync(6_000); + await expect(promise).resolves.toBe(''); + expect(debug).toHaveBeenCalledWith( + 'git context command timed out', + expect.objectContaining({ command: 'git rev-parse --is-inside-work-tree' }), + ); + } finally { + vi.useRealTimers(); + } }); }); diff --git a/packages/agent-core-v2/test/tool/path-access.test.ts b/packages/agent-core-v2/test/tool/path-access.test.ts index 9fa2347b6..d2b0c8e87 100644 --- a/packages/agent-core-v2/test/tool/path-access.test.ts +++ b/packages/agent-core-v2/test/tool/path-access.test.ts @@ -4,22 +4,11 @@ import type { ShellPathBridge } from '#/_base/execEnv/shellPathBridge'; import { DEFAULT_WORKSPACE_ACCESS_POLICY, extendWorkspaceWithSkillRoots, - isProjectLocalConfigPath, isSensitiveFile, resolvePathAccess, resolvePathAccessPath, } from '#/tool/path-access'; -describe('isProjectLocalConfigPath', () => { - it('matches posix, Windows, and case variants of the project-local config', () => { - expect(isProjectLocalConfigPath('/repo/.pythinker-code/local.toml')).toBe(true); - expect(isProjectLocalConfigPath('C:\\repo\\.pythinker-code\\local.toml')).toBe(true); - expect(isProjectLocalConfigPath('/repo/.PYTHINKER-CODE/LOCAL.TOML')).toBe(true); - expect(isProjectLocalConfigPath('/repo/.pythinker-code/local.toml.bak')).toBe(false); - expect(isProjectLocalConfigPath('/repo/other/local.toml')).toBe(false); - }); -}); - describe('isSensitiveFile', () => { it('flags base .env files in any directory', () => { for (const path of ['.env', '/app/.env', 'project/.env']) { diff --git a/packages/agent-core-v2/test/tool/realpath-access.test.ts b/packages/agent-core-v2/test/tool/realpath-access.test.ts deleted file mode 100644 index c1092a85d..000000000 --- a/packages/agent-core-v2/test/tool/realpath-access.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; -import type { IHostFileSystem } from '#/os/interface/hostFileSystem'; -import type { WorkspaceConfig } from '#/tool/path-access'; -import { assertRealPathWithinWorkspace, assertRealPathWriteTarget } from '#/tool/realpath-access'; - -describe('realpath access guard', () => { - let tmpDir: string; - let wsDir: string; - let outsideDir: string; - let fs: HostFileSystem; - let workspace: WorkspaceConfig; - - beforeEach(async () => { - tmpDir = await mkdtemp(join(tmpdir(), 'realpath-access-')); - wsDir = join(tmpDir, 'ws'); - outsideDir = join(tmpDir, 'outside'); - await mkdir(wsDir); - await mkdir(outsideDir); - fs = new HostFileSystem(); - workspace = { workspaceDir: wsDir, additionalDirs: [] }; - }); - - afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }); - }); - - it('allows a symlink that stays inside the workspace', async () => { - const target = join(wsDir, 'real.txt'); - await writeFile(target, 'original'); - const link = join(wsDir, 'alias.txt'); - await symlink(target, link); - - await expect(assertRealPathWriteTarget(fs, link, workspace, 'posix')).resolves.toBeUndefined(); - }); - - it('rejects a symlink that points outside the workspace', async () => { - const target = join(outsideDir, 'target.txt'); - await writeFile(target, 'original'); - const link = join(wsDir, 'link.txt'); - await symlink(target, link); - - await expect(assertRealPathWriteTarget(fs, link, workspace, 'posix')).rejects.toThrow( - /symbolic link/, - ); - }); - - it('rejects a path whose symlinked parent directory points outside the workspace', async () => { - const fakeHome = join(tmpDir, 'fake-home'); - await mkdir(fakeHome); - const target = join(fakeHome, '.bashrc'); - await writeFile(target, 'original'); - await symlink(fakeHome, join(wsDir, 'home')); - - await expect( - assertRealPathWriteTarget(fs, join(wsDir, 'home', '.bashrc'), workspace, 'posix'), - ).rejects.toThrow(/symbolic link/); - }); - - it('rejects a path that is too deep to verify', async () => { - const target = join(outsideDir, 'target.txt'); - await writeFile(target, 'original'); - const link = join(wsDir, 'link'); - await symlink(outsideDir, link); - - const deep = join(link, ...Array.from({ length: 300 }, () => 'a'), 'file.txt'); - await expect(assertRealPathWriteTarget(fs, deep, workspace, 'posix')).rejects.toThrow( - /too deep/, - ); - }); - - it('blocks a target that resolves to a sensitive file', async () => { - const target = join(outsideDir, 'id_rsa'); - await writeFile(target, 'secret-key'); - const link = join(wsDir, 'notes.md'); - await symlink(target, link); - - await expect(assertRealPathWriteTarget(fs, link, workspace, 'posix')).rejects.toThrow( - /sensitive-file pattern/, - ); - }); - - it('rejects a dangling symlink whose target does not exist', async () => { - const link = join(wsDir, 'dangling.txt'); - await symlink(join(outsideDir, 'missing.txt'), link); - - await expect(assertRealPathWriteTarget(fs, link, workspace, 'posix')).rejects.toThrow( - /symbolic link/, - ); - }); - - it('rejects the project config through a symlink alias', async () => { - const configDir = join(wsDir, '.pythinker-code'); - await mkdir(configDir); - await writeFile(join(configDir, 'local.toml'), 'original'); - const alias = join(wsDir, 'config-link'); - await symlink(configDir, alias); - - await expect( - assertRealPathWriteTarget(fs, join(alias, 'local.toml'), workspace, 'posix'), - ).rejects.toThrow(/project-local config/); - }); - - it('allows the project config through its real path', async () => { - const configDir = join(wsDir, '.pythinker-code'); - await mkdir(configDir); - await writeFile(join(configDir, 'local.toml'), 'original'); - - await expect( - assertRealPathWriteTarget(fs, join(configDir, 'local.toml'), workspace, 'posix'), - ).resolves.toBeUndefined(); - }); - - it('allows a symlink that points into an additional dir', async () => { - const target = join(outsideDir, 'shared.txt'); - await writeFile(target, 'original'); - const link = join(wsDir, 'shared.txt'); - await symlink(target, link); - const withAdditional: WorkspaceConfig = { workspaceDir: wsDir, additionalDirs: [outsideDir] }; - - await expect(assertRealPathWriteTarget(fs, link, withAdditional, 'posix')).resolves.toBeUndefined(); - }); - - it('blocks an absolute outside symlink that resolves to a sensitive file', async () => { - const target = join(outsideDir, 'id_rsa'); - await writeFile(target, 'secret-key'); - const link = join(tmpDir, 'notes.md'); - await symlink(target, link); - - await expect(assertRealPathWithinWorkspace(fs, link, workspace, 'posix')).rejects.toThrow( - /sensitive-file pattern/, - ); - }); - - it('allows an absolute outside path that is not sensitive', async () => { - const target = join(outsideDir, 'plain.txt'); - await writeFile(target, 'data'); - - await expect(assertRealPathWithinWorkspace(fs, target, workspace, 'posix')).resolves.toBe( - target, - ); - }); - - it('compares resolved config paths with Windows semantics', async () => { - const realpath = vi.fn(async (path: string) => { - if (path === 'C:/ws/alias/local.toml') return 'C:\\ws\\.pythinker-code\\local.toml'; - return path.replaceAll('/', '\\'); - }); - const winFs = { realpath } as unknown as IHostFileSystem; - const winWorkspace: WorkspaceConfig = { workspaceDir: 'C:/ws', additionalDirs: [] }; - - await expect( - assertRealPathWriteTarget(winFs, 'C:/ws/.pythinker-code/local.toml', winWorkspace, 'win32'), - ).resolves.toBeUndefined(); - await expect( - assertRealPathWriteTarget(winFs, 'C:/ws/alias/local.toml', winWorkspace, 'win32'), - ).rejects.toThrow(/project-local config/); - }); -}); diff --git a/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts b/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts index bee9d4b32..f8efa4c47 100644 --- a/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts +++ b/packages/agent-core-v2/test/tools/fixtures/fake-exec.ts @@ -30,7 +30,7 @@ export function createFakeHostFs(overrides: Partial = {}): IHos readdir: () => notImplemented('FakeHostFs.readdir'), mkdir: () => notImplemented('FakeHostFs.mkdir'), remove: () => notImplemented('FakeHostFs.remove'), - realpath: (path) => Promise.resolve(path), + realpath: () => notImplemented('FakeHostFs.realpath'), }; return { ...fs, ...overrides }; } diff --git a/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts b/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts deleted file mode 100644 index 97c55d343..000000000 --- a/packages/agent-core-v2/test/workspace/workspaceDirs/workspaceDirs.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { mkdtempSync } from 'node:fs'; -import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; - -import { join } from 'pathe'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { DisposableStore } from '#/_base/di/lifecycle'; -import { createServices } from '#/_base/di/test'; -import { Emitter } from '#/_base/event'; -import { ILogService } from '#/_base/log/log'; -import type { IBootstrapService } from '#/app/bootstrap/bootstrap'; -import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; -import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; -import { FileProjectLocalConfigService } from '#/persistence/backends/node-fs/projectLocalConfigService'; -import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; -import { IWorkspaceDirs } from '#/workspace/workspaceDirs/workspaceDirs'; -import { WorkspaceDirsService } from '#/workspace/workspaceDirs/workspaceDirsService'; -import { - IWorkspaceTrust, - type WorkspaceTrustChange, -} from '#/workspace/workspaceTrust/workspaceTrust'; -import type { WatchChange } from '#human/utils/watch'; - -import { stubLog } from '../../_base/log/stubs'; -import { registerStateServices } from '../../state/stubs'; - -const watchFires = new Map>(); - -vi.mock('#human/utils/watch', () => { - const watch = (path: string) => { - let emitter = watchFires.get(path); - if (emitter === undefined) { - emitter = new Emitter(); - watchFires.set(path, emitter); - } - return { ready: Promise.resolve(), onDidChange: emitter.event, dispose: () => {} }; - }; - return { - watch, - watchCandidates: (root: string) => watch(root), - }; -}); - -describe('WorkspaceDirsService trust gating', () => { - let cwd: string; - let homeDir: string; - let extraDir: string; - let disposables: DisposableStore; - let trusted: boolean; - let trustFlips: Emitter; - let changes: number; - - beforeEach(() => { - cwd = mkdtempSync(join(tmpdir(), 'pythinker-workspace-dirs-cwd-')); - homeDir = mkdtempSync(join(tmpdir(), 'pythinker-workspace-dirs-home-')); - extraDir = mkdtempSync(join(tmpdir(), 'pythinker-workspace-dirs-extra-')); - disposables = new DisposableStore(); - watchFires.clear(); - trusted = true; - trustFlips = new Emitter(); - changes = 0; - }); - - afterEach(async () => { - disposables.dispose(); - await Promise.all( - [cwd, homeDir, extraDir].map((dir) => rm(dir, { recursive: true, force: true })), - ); - }); - - function createService(): IWorkspaceDirs { - const ix = createServices(disposables, { - strict: true, - additionalServices: (reg) => { - registerStateServices(reg); - reg.definePartialInstance(IWorkspaceContext, { cwd }); - reg.defineInstance( - IProjectLocalConfigService, - new FileProjectLocalConfigService( - { _serviceBrand: undefined, osHomeDir: homeDir } as IBootstrapService, - new HostFileSystem(), - ), - ); - reg.defineInstance(ILogService, stubLog()); - reg.definePartialInstance(IWorkspaceTrust, { - ready: Promise.resolve(), - isTrusted: () => trusted, - onDidChange: trustFlips.event, - }); - reg.define(IWorkspaceDirs, WorkspaceDirsService); - }, - }); - const service = ix.get(IWorkspaceDirs); - service.onDidChange(() => { - changes += 1; - }); - return service; - } - - async function writeLocalToml(additionalDirs: readonly string[]): Promise { - const dir = join(cwd, '.pythinker-code'); - await mkdir(dir, { recursive: true }); - const file = join(dir, 'local.toml'); - const entries = additionalDirs.map((dir) => `"${dir}"`).join(', '); - await writeFile(file, `[workspace]\nadditional_dir = [${entries}]\n`, 'utf8'); - return file; - } - - it('loads local.toml additional dirs when the workspace is trusted', async () => { - await writeLocalToml([extraDir]); - - const service = createService(); - await service.ready; - - expect(service.additionalDirs).toEqual([extraDir]); - }); - - it('does not restore configured dirs when trust is lost during a reload', async () => { - await writeLocalToml([extraDir]); - const service = createService(); - await service.ready; - expect(service.additionalDirs).toEqual([extraDir]); - - const original = FileProjectLocalConfigService.prototype.readAdditionalDirs; - const read = vi - .spyOn(FileProjectLocalConfigService.prototype, 'readAdditionalDirs') - .mockImplementation(async function (this: FileProjectLocalConfigService, workDir: string) { - const result = await original.call(this, workDir); - trusted = false; - return result; - }); - try { - const file = join(cwd, '.pythinker-code', 'local.toml'); - watchFires.get(cwd)?.fire({ path: file, action: 'modified', kind: 'file' }); - await vi.waitFor(() => { - expect(service.additionalDirs).toEqual([]); - }); - } finally { - read.mockRestore(); - } - }); - - it('ignores local.toml additional dirs while the workspace is untrusted', async () => { - await writeLocalToml([extraDir]); - trusted = false; - - const service = createService(); - await service.ready; - - expect(service.additionalDirs).toEqual([]); - }); - - it('loads the additional dirs when the workspace becomes trusted', async () => { - await writeLocalToml([extraDir]); - trusted = false; - const service = createService(); - await service.ready; - expect(service.additionalDirs).toEqual([]); - - trusted = true; - trustFlips.fire({ trusted: true }); - - await vi.waitFor( - () => { - expect(service.additionalDirs).toEqual([extraDir]); - }, - { timeout: 10000, interval: 50 }, - ); - expect(changes).toBe(1); - }, 20000); - - it('clears the additional dirs when the workspace loses trust', async () => { - await writeLocalToml([extraDir]); - const service = createService(); - await service.ready; - expect(service.additionalDirs).toEqual([extraDir]); - - trusted = false; - trustFlips.fire({ trusted: false }); - - expect(service.additionalDirs).toEqual([]); - }, 20000); - - it('ignores watched local.toml changes while the workspace is untrusted', async () => { - trusted = false; - const service = createService(); - await service.ready; - - const file = await writeLocalToml([extraDir]); - watchFires.get(cwd)?.fire({ path: file, action: 'modified', kind: 'file' }); - - await new Promise((resolve) => setTimeout(resolve, 500)); - expect(service.additionalDirs).toEqual([]); - expect(changes).toBe(0); - }, 20000); - - it('adds an ephemeral dir without parsing a planted local.toml while untrusted', async () => { - await writeLocalToml([homeDir]); - trusted = false; - const service = createService(); - await service.ready; - - const result = await service.addDir({ path: extraDir, persist: false }); - - expect(result.persisted).toBe(false); - expect(result.additionalDirs).toEqual([extraDir]); - }); - - it('persists the explicit dir but loads only it while the workspace is untrusted', async () => { - const plantedDir = join(homeDir, 'planted'); - await mkdir(plantedDir, { recursive: true }); - await writeLocalToml([plantedDir]); - trusted = false; - const service = createService(); - await service.ready; - expect(service.additionalDirs).toEqual([]); - - const result = await service.addDir({ path: extraDir }); - - expect(result.persisted).toBe(true); - expect(result.additionalDirs).toEqual([extraDir]); - expect(service.additionalDirs).toEqual([extraDir]); - const onDisk = await readFile(join(cwd, '.pythinker-code', 'local.toml'), 'utf8'); - expect(onDisk).toContain(plantedDir); - expect(onDisk).toContain(extraDir); - }); - - it('keeps the explicitly added dir after a watched reload while untrusted', async () => { - trusted = false; - const service = createService(); - await service.ready; - await service.addDir({ path: extraDir }); - expect(service.additionalDirs).toEqual([extraDir]); - - const file = join(cwd, '.pythinker-code', 'local.toml'); - watchFires.get(cwd)?.fire({ path: file, action: 'modified', kind: 'file' }); - - await new Promise((resolve) => setTimeout(resolve, 500)); - expect(service.additionalDirs).toEqual([extraDir]); - }, 20000); - - it('loads every persisted dir after add-dir when the workspace is trusted', async () => { - const plantedDir = join(homeDir, 'planted'); - await mkdir(plantedDir, { recursive: true }); - await writeLocalToml([plantedDir]); - const service = createService(); - await service.ready; - expect(service.additionalDirs).toEqual([plantedDir]); - - const result = await service.addDir({ path: extraDir }); - - expect(result.persisted).toBe(true); - expect(result.additionalDirs).toEqual([plantedDir, extraDir]); - expect(service.additionalDirs).toEqual([plantedDir, extraDir]); - }); -}); diff --git a/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts b/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts index cdb283381..68c338f3e 100644 --- a/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceFs/fsService.test.ts @@ -357,7 +357,6 @@ function defaultGitStub(): IGitService { }), diff: async () => ({ path: '', diff: '', truncated: false }), findWorkTree: async () => null, - runGit: async () => ({ exitCode: 0, stdout: '', stderr: '' }), }; } @@ -424,8 +423,7 @@ describe('WorkspaceFsService.gitStatus', () => { }, diff: async () => ({ path: '', diff: '', truncated: false }), findWorkTree: async () => null, - runGit: async () => ({ exitCode: 0, stdout: '', stderr: '' }), - }; + }; const fs = makeSession({}, emptyHandler, [], git); const result = await fs.gitStatus({ paths: ['src/a.ts'] }); expect(calls).toHaveLength(1); @@ -444,8 +442,7 @@ describe('WorkspaceFsService.gitStatus', () => { }, diff: async () => ({ path: '', diff: '', truncated: false }), findWorkTree: async () => null, - runGit: async () => ({ exitCode: 0, stdout: '', stderr: '' }), - }; + }; const fs = makeSession({}, emptyHandler, [], git); await expect(fs.gitStatus({})).rejects.toMatchObject({ code: 'fs.git_unavailable' }); }); @@ -470,8 +467,7 @@ describe('WorkspaceFsService.diff', () => { return { path: rel, diff: '-old\n+new\n', truncated: false }; }, findWorkTree: async () => null, - runGit: async () => ({ exitCode: 0, stdout: '', stderr: '' }), - }; + }; const fs = makeSession({ 'src/a.ts': 'content' }, emptyHandler, [], git); const result = await fs.diff({ path: 'src/a.ts' }); expect(calls).toHaveLength(1); diff --git a/packages/agent-gateway/test/v2Sessions.test.ts b/packages/agent-gateway/test/v2Sessions.test.ts index 04adf3078..128734ee0 100644 --- a/packages/agent-gateway/test/v2Sessions.test.ts +++ b/packages/agent-gateway/test/v2Sessions.test.ts @@ -154,7 +154,6 @@ const gitStub: IGitService = { throw new Error2(ErrorCodes.FS_GIT_UNAVAILABLE, 'not used in these tests'); }, findWorkTree: async () => null, - runGit: async () => ({ exitCode: 0, stdout: '', stderr: '' }), }; describe('server /api/v2/sessions', () => { From 5ed0f05d694088db30332051adf05578444cd836 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 30 Sep 2026 20:02:14 -0400 Subject: [PATCH 2/6] fix: restore filesystem watch defaults and align agent status and completion budget behavior Watchers default back on, NotifyUser nudges respect the host update panel, permission mode changes publish agent.status.updated, undo drops its turn's interruption reminder, forks clear inherited cron tasks with a notice, and the completion token cap is only sent when configured. --- apps/vis/server/src/lib/agent-record-types.ts | 88 ++++++++---------- docs/configuration/config-files.md | 4 +- docs/configuration/env-vars.md | 2 +- .../agent-core-v2/docs/wire-manifest.d.ts | 4 +- .../interruptionReminderService.ts | 5 +- .../agent/llmRequester/llmRequesterService.ts | 25 ++++-- .../permissionMode/permissionModeService.ts | 4 + .../src/agent/usage/usageEvents.ts | 1 + .../src/app/config/configService.ts | 2 +- .../src/features/cron/cronAgentRuntime.ts | 79 +++++++++++----- .../src/features/cron/cronOps.ts | 5 +- .../src/features/cron/cronService.ts | 78 +++++++++++----- .../src/features/goal/goalOps.ts | 11 --- .../src/features/goal/goalService.ts | 8 +- .../features/notify/notifyUserNudgeService.ts | 9 +- .../src/human/llm-pythinker/provider.ts | 3 +- .../src/human/llm-pythinker/trait.ts | 17 ++++ .../src/human/llm/protocol/format.ts | 2 +- .../llm/requester/bases/anthropic/profile.ts | 2 +- .../bases/openai-responses/requester.ts | 6 +- .../requester/bases/openai-responses/trait.ts | 6 ++ .../llm/requester/bases/openai/requester.ts | 6 +- .../human/llm/requester/bases/openai/trait.ts | 6 ++ .../src/human/test/llm/trait.test.ts | 18 +++- .../src/human/test/utils/watch.test.ts | 20 ++--- .../agent-core-v2/src/human/utils/watch.ts | 2 +- packages/agent-core-v2/src/index.ts | 1 + .../llm-adapter/model/completion-budget.ts | 33 ++----- .../src/llm-adapter/model/model.types.ts | 5 -- .../provider/provider-definition.ts | 2 + .../src/session/agentLifecycle/forked.ts | 15 ++++ .../fullCompaction/fullCompaction.test.ts | 4 +- .../test/agent/loop/loop.test.ts | 64 +++++++++---- .../permissionMode/permissionMode.test.ts | 23 ++++- .../workspaceAliasesService.test.ts | 3 - .../test/features/cron/sessionCron.test.ts | 90 ++++++++++++++----- .../notify/notifyUserNudgeService.test.ts | 25 +++++- .../test/features/plan/plan.test.ts | 10 ++- .../model/completionBudget.test.ts | 38 ++++---- .../protocol/protocolAdapterRegistry.test.ts | 4 +- .../agentProfileLoader.test.ts | 5 -- packages/agent-gateway/test/sessions.test.ts | 5 +- .../agent-gateway/test/workspaces.test.ts | 3 - 43 files changed, 477 insertions(+), 266 deletions(-) create mode 100644 packages/agent-core-v2/src/session/agentLifecycle/forked.ts diff --git a/apps/vis/server/src/lib/agent-record-types.ts b/apps/vis/server/src/lib/agent-record-types.ts index c2364c0e8..d01de9b87 100644 --- a/apps/vis/server/src/lib/agent-record-types.ts +++ b/apps/vis/server/src/lib/agent-record-types.ts @@ -1,7 +1,7 @@ // @ts-nocheck // apps/vis/server/src/lib/agent-record-types.ts // Single source of truth: engine shapes come from agent-core-v2 directly. -// Do NOT add local interfaces that duplicate engine shapes — the only +// Do NOT add local interfaces that duplicate upstream shapes — the only // exceptions are the legacy records below, which v2 never writes but old // (v1-written / pre-migration) wires still contain on disk. @@ -20,25 +20,26 @@ export { WIRE_PROTOCOL_VERSION } from '@pymodel/agent-core-v2/wire/migration/mig export type { AgentTaskInfo as BackgroundTaskInfo, AgentTaskStatus as BackgroundTaskStatus, -} from '@pymodel/agent-core-v2/agent/task/types'; +} from '@pymodel/agent-core-v2'; export type { SubagentTaskInfo as AgentBackgroundTaskInfo } from '@pymodel/agent-core-v2'; export type { ProcessTaskInfo as ProcessBackgroundTaskInfo } from '@pymodel/agent-core-v2/agent/tools/os/bash/process-task'; export type { QuestionTaskInfo as QuestionBackgroundTaskInfo } from '@pymodel/agent-core-v2/agent/tools/ask-user-question/question-background-task'; -import type { AgentTaskInfo as BackgroundTaskInfo } from '@pymodel/agent-core-v2/agent/task/types'; import type { + AgentTaskInfo as BackgroundTaskInfo, CronAddPayload, - CronTask, CronCursorPayload, CronDeletePayload, + CronTask, + ExportSessionManifest, + FileHistoryCheckpointed, + FileHistoryTracked, + Forked, FullCompactionBegin, FullCompactionCancel, FullCompactionComplete, - FileHistoryCheckpointed, - FileHistoryTracked, GoalClear, GoalCreate, - GoalForked, GoalUpdate, InteractionRequestEvent, InteractionResolvedEvent, @@ -78,7 +79,7 @@ import type { } from '@pymodel/agent-core-v2/agent/contextMemory/contextEvents'; import type { TurnCancel, TurnEnded, TurnPrompt, TurnSteer } from '@pymodel/agent-core-v2/agent/loop/turnOps'; import type { TurnStepInterrupted } from '@pymodel/agent-core-v2/agent/loop/turnEvents'; -import type { TurnStepRetrying } from '@pymodel/agent-core-v2/agent/stepRetry/stepRetryService'; +import type { TurnStepRetrying } from '@pymodel/agent-core-v2/agent/loop/turnEvents'; import type { UsageRecord } from '@pymodel/agent-core-v2/agent/usage/usageOps'; import type { ConfigUpdate, @@ -89,10 +90,7 @@ import type { import type { PermissionSetMode } from '@pymodel/agent-core-v2/agent/permissionMode/permissionModeOps'; import type { PermissionRecordApprovalResult } from '@pymodel/agent-core-v2/agent/permissionRules/permissionRulesOps'; import type { RuntimeSetBinding } from '@pymodel/agent-core-v2/agent/runtimeBinding/runtimeBindingOps'; -import type { - DynamicWorkflowModeEnter, - DynamicWorkflowModeExit, -} from '@pymodel/agent-core-v2/features/dynamic_workflow/dynamicWorkflowOps'; +import type { SwarmModeEnter, SwarmModeExit } from '@pymodel/agent-core-v2/features/swarm/swarmOps'; import type { TowerModeEnter, TowerModeExit } from '@pymodel/agent-core-v2/features/tower/towerOps'; import type { ToolsUpdateStore } from '@pymodel/agent-core-v2/features/todo/todoOps'; @@ -136,9 +134,19 @@ export interface StaleGuardClearedRecord { readonly time?: number; } +/** v2-dropped durable record: removed with the loop-side prompt admission + * facility, but old wires still contain it. */ +export interface PromptAcceptedRecord { + readonly type: 'prompt.accepted'; + readonly agentId: string; + readonly promptId: string; + readonly content?: unknown; + readonly time?: number; +} + /** The wire file header record. Declared locally (rather than via v2's * `WireMetadataRecord`) so the union member keeps concrete field types — - * the engine interface carries an index signature that would widen + * the upstream interface carries an index signature that would widen * `protocol_version` / `created_at` to `unknown`. */ export interface WireMetadataHeader { readonly type: 'metadata'; @@ -165,11 +173,9 @@ export type AgentRecord = | WireRecordOf<'cron.add', CronAddPayload> | WireRecordOf<'cron.cursor', CronCursorPayload> | WireRecordOf<'cron.delete', CronDeletePayload> - | WireRecordOf<'dynamic_workflow_mode.enter', DynamicWorkflowModeEnter> - | WireRecordOf<'dynamic_workflow_mode.exit', DynamicWorkflowModeExit> | WireRecordOf<'file_history.checkpoint', FileHistoryCheckpointed> | WireRecordOf<'file_history.tracked', FileHistoryTracked> - | WireRecordOf<'forked', GoalForked> + | WireRecordOf<'forked', Forked> | WireRecordOf<'full_compaction.begin', FullCompactionBegin> | WireRecordOf<'full_compaction.cancel', FullCompactionCancel> | WireRecordOf<'full_compaction.complete', FullCompactionComplete> @@ -191,7 +197,7 @@ export type AgentRecord = | WireRecordOf<'plugin.session_start', PluginSessionStartEvent> | WireRecordOf<'profile.bind', ProfileBind> | WireRecordOf<'prompt.aborted', PromptAborted> - | WireRecordOf<'prompt.accepted', PromptAcceptedRecord> + | PromptAcceptedRecord | WireRecordOf<'prompt.completed', PromptCompleted> | WireRecordOf<'prompt.steered', PromptSteered> | WireRecordOf<'runtime.set_binding', RuntimeSetBinding> @@ -200,6 +206,8 @@ export type AgentRecord = | WireRecordOf<'subagent.failed', SubagentFailed> | WireRecordOf<'subagent.spawned', SubagentSpawned> | WireRecordOf<'subagent.started', SubagentStarted> + | WireRecordOf<'swarm_mode.enter', SwarmModeEnter> + | WireRecordOf<'swarm_mode.exit', SwarmModeExit> | WireRecordOf<'task.started', TaskStarted> | WireRecordOf<'task.terminated', TaskTerminated> | WireRecordOf<'task.waitDelivered', TaskWaitDelivered> @@ -234,35 +242,10 @@ export type AgentRecordOf = Extract< /** * `manifest.json` shape inside a `/export-debug-zip` bundle. Structural - * mirror of the engine's `ExportSessionManifest`, which is not re-exported - * from the package entry. All fields optional-tolerant because the manifest - * comes from another machine / pythinker-code version. + * current engine manifest with every field optional because the bundle may + * come from another machine or an older pythinker-code version. */ -export interface ImportManifest { - sessionId?: string; - exportedAt?: string; - pythinkerCodeVersion?: string; - wireProtocolVersion?: string; - os?: string; - nodejsVersion?: string; - sessionFirstActivity?: string; - sessionLastActivity?: string; - title?: string; - workspaceDir?: string; - sessionLogPath?: string; - globalLogPath?: string; - desktopLogPath?: string; - webLogPath?: string; - desktopVersion?: string; - installSource?: string; - shellEnv?: { - term?: string; - termProgram?: string; - termProgramVersion?: string; - multiplexer?: string; - shell?: string; - }; -} +export type ImportManifest = Partial; /** vis-side bookkeeping for one imported bundle, written to * `imported//import-meta.json`. */ @@ -325,13 +308,12 @@ export interface AgentInfo { wireExists: boolean; wireRecordCount: number; wireProtocolVersion: string | null; - /** Per-item dynamic_workflow work label persisted by the engine for - * dynamic-workflow-spawned sub-agents (`AgentMeta.dynamicWorkflowItem`, or - * `AgentMeta.labels.dynamicWorkflowItem` on v2-written sessions). `null` - * when the agent is not a dynamic_workflow item or when the value cannot - * be recovered (e.g. disk-only inventory of a session with a corrupt - * `state.json`). */ - dynamicWorkflowItem: string | null; + /** Per-item swarm work label persisted by the engine for swarm-spawned + * sub-agents (`AgentMeta.swarmItem`, or `AgentMeta.labels.swarmItem` on + * v2-written sessions). `null` when the agent is not a swarm item or when + * the value cannot be recovered (e.g. disk-only inventory of a session + * with a corrupt `state.json`). */ + swarmItem: string | null; } export interface SessionDetail { @@ -341,7 +323,7 @@ export interface SessionDetail { * which can drift after fork/rename. */ sessionDir: string; workDir: string; - state: unknown; // Preserve the source shape; the UI renders the actual state.json form. + state: unknown; // 原样透传,前端按 state.json 真实形状渲染 agents: AgentInfo[]; /** True for sessions imported from a debug zip. */ imported: boolean; diff --git a/docs/configuration/config-files.md b/docs/configuration/config-files.md index 91ebd573b..98163ae81 100644 --- a/docs/configuration/config-files.md +++ b/docs/configuration/config-files.md @@ -474,11 +474,11 @@ Both values must be positive integers. A call's `max_chars` overrides the defaul ## `watch` -`watch` controls filesystem watchers that reload local.toml, AGENTS.md, skills, MCP config, and `config.toml` itself. It defaults to off. Set `enabled` to `true` to attach watchers; with watchers off, changing the file later will not be picked up until restart. +`watch` controls filesystem watchers that reload local.toml, AGENTS.md, skills, MCP config, and `config.toml` itself. It defaults to on. Set `enabled` to `false` to start with no watchers; changing the file later will not be picked up until restart. | Field | Type | Default | Description | | --- | --- | --- | --- | -| `enabled` | `boolean` | `false` | Attach filesystem watchers; `false` disables every `watch()` for the process | +| `enabled` | `boolean` | `true` | Attach filesystem watchers; `false` disables every `watch()` for the process | `enabled` can be overridden by the `PYTHINKER_CODE_WATCH` environment variable, which takes higher priority than `config.toml`. diff --git a/docs/configuration/env-vars.md b/docs/configuration/env-vars.md index bebad2470..80fb8e139 100644 --- a/docs/configuration/env-vars.md +++ b/docs/configuration/env-vars.md @@ -146,7 +146,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | `PYTHINKER_CODE_EXPERIMENTAL_SUBAGENT_FORK` | Enable the experimental `fork` parameter on the `Agent` and `AgentDynamicWorkflow` tools, letting the model start a subagent with a snapshot of the calling agent's conversation history instead of an empty context; the master `PYTHINKER_CODE_EXPERIMENTAL_FLAG=1` also enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `PYTHINKER_CODE_EXPERIMENTAL_TOOL_SELECT` | Experimental on-demand tool loading: tools of MCP servers marked `deferred: true` stay out of the top-level tool list and are loaded via `select_tools`; also requires the model to declare the `dynamically_loaded_tools` capability — see [MCP](../customization/mcp.md#loading-tools-on-demand) | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `PYTHINKER_CODE_EXPERIMENTAL_TOWER` | Enable the experimental [`/tower`](../reference/slash-commands.md#modes--run-control) command for workspace-wide subagent coordination; the master `PYTHINKER_CODE_EXPERIMENTAL_FLAG=1` also enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | -| `PYTHINKER_CODE_WATCH` | Attach filesystem watchers that reload config and workspace files; higher priority than `[watch] enabled` (default `false`) | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | +| `PYTHINKER_CODE_WATCH` | Attach filesystem watchers that reload config and workspace files; higher priority than `[watch] enabled` (default `true`) | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `PYTHINKER_CODE_SEARCH_WORKER` | Run the global search index in a dedicated worker thread; takes higher priority than `[database] search` in `config.toml` (default `true`) | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `PYTHINKER_CODE_PERSISTENCE_MINIDB_READMODEL` | Use the minidb-backed read model for session indexing; takes higher priority than `[database] base` in `config.toml` (default `true`) | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `PYTHINKER_MCP_STARTUP_TIMEOUT_MS` | Global default connection timeout (ms) for all MCP servers; takes higher priority than `[mcp] startup_timeout_ms` in `config.toml`, but a per-server `startupTimeoutMs` in `mcp.json` still wins (default `30000`) | Integer from `1` to `2147483647`; invalid values are ignored | diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index d690ec53d..0ff629a4b 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -38,7 +38,7 @@ // dynamic_workflow_mode.exit contextMemory, dynamic_workflow src/features/dynamic_workflow/dynamicWorkflowOps.ts // file_history.checkpoint fileHistory src/features/fileHistory/fileHistoryOps.ts // file_history.tracked fileHistory src/features/fileHistory/fileHistoryOps.ts -// forked (none) src/features/goal/goalOps.ts +// forked (none) src/session/agentLifecycle/forked.ts // full_compaction.begin fullCompaction src/agent/fullCompaction/compactionOps.ts // full_compaction.cancel fullCompaction src/agent/fullCompaction/compactionOps.ts // full_compaction.complete fullCompaction src/agent/fullCompaction/compactionOps.ts @@ -268,7 +268,7 @@ interface FileHistoryTrackedPayload { /** * states: (none) - * owner: src/features/goal/goalOps.ts + * owner: src/session/agentLifecycle/forked.ts */ interface ForkedPayload { _name: 'forked'; diff --git a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts index acea65627..9083d4b62 100644 --- a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts +++ b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts @@ -2,6 +2,7 @@ import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { isUndoAnchor } from '#/agent/contextMemory/conversationTime'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent'; import { TurnEnded } from '#/agent/loop/turnOps'; @@ -35,10 +36,12 @@ export class AgentInterruptionReminderService this._register( eventBus.subscribe(TurnEnded, (event) => { if (event.reason !== 'cancelled' || event.interruptReason !== 'user_cancelled') return; - const origin = lastComparableMessage(this.context.get())?.origin; + const history = this.context.get(); + const origin = lastComparableMessage(history)?.origin; if (origin?.kind === 'injection' && origin.variant === INTERRUPTION_REMINDER_VARIANT) return; this.reminder.notify(INTERRUPTION_REMINDER, { variant: INTERRUPTION_REMINDER_VARIANT, + ownerPromptId: history.findLast(isUndoAnchor)?.id, }); }), ); diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index fcef80b0c..178248543 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -660,26 +660,33 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { const turnConfig = this.resolveTurnConfig(overrides.source); const resolved = turnConfig?.resolved ?? this.profile.resolveModelContext(); const baseParams = turnConfig?.params ?? this.profile.resolveRequestParams(); + const requester = this.modelCatalog.getRequester(resolved.modelAlias); + const maxCompletionTokensCap = + this.config.get('modelOverrides')?.maxCompletionTokens; + const usedContextTokens = + overrides.messages === undefined + ? this.tokenCounting.get(this.scopeContext.agentContext).size + : undefined; const budgetParams = completionBudgetParams({ budget: resolveCompletionBudget({ maxOutputSize: overrides.maxOutputSize ?? resolved.maxOutputSize, - reservedContextSize: resolved.reservedContextSize, - maxCompletionTokensCap: - this.config.get('modelOverrides')?.maxCompletionTokens, + maxCompletionTokensCap, }), capability: resolved.modelCapabilities, - usedContextTokens: - overrides.messages === undefined - ? this.tokenCounting.get(this.scopeContext.agentContext).measured - : undefined, + usedContextTokens, }); - const requester = this.modelCatalog.getRequester(resolved.modelAlias); + const optedOut = maxCompletionTokensCap !== undefined && maxCompletionTokensCap <= 0; const messages = overrides.messages ?? this.context.get(); return { requester, model: requester.model, - params: { ...baseParams, ...budgetParams }, + params: { + ...baseParams, + ...budgetParams, + ...(usedContextTokens === undefined ? {} : { usedContextTokens }), + ...(optedOut ? { maxCompletionTokens: maxCompletionTokensCap } : {}), + }, modelAlias: resolved.modelAlias, thinkingEffort: resolved.thinkingLevel, systemPrompt: overrides.systemPrompt ?? turnConfig?.systemPrompt ?? this.profile.getSystemPrompt(), diff --git a/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts b/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts index 5aaca3f17..a5f40575a 100644 --- a/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts +++ b/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts @@ -14,6 +14,7 @@ import { MAIN_AGENT_ID, } from '#/session/agentLifecycle/agentLifecycle'; import { IAgentStateService } from '#/agent/state/agentState'; +import { AgentStatusUpdated } from '#/agent/usage/usageEvents'; import { IEventDispatcher } from '#/state/eventDispatcher'; import { IAgentPermissionModeService, type PermissionModeChangedContext } from './permissionMode'; import { @@ -58,6 +59,9 @@ export class AgentPermissionModeService extends Service implements IAgentPermiss void this.dispatcher.dispatch( new PermissionSetMode({ agentId: this.scopeContext.agentId, mode }), ); + void this.dispatcher.dispatch( + new AgentStatusUpdated({ agentId: this.scopeContext.agentId, permission: mode }), + ); if (changed) this._onDidChangeMode.fire({ mode, previousMode }); } diff --git a/packages/agent-core-v2/src/agent/usage/usageEvents.ts b/packages/agent-core-v2/src/agent/usage/usageEvents.ts index be7a22809..d1e1f2466 100644 --- a/packages/agent-core-v2/src/agent/usage/usageEvents.ts +++ b/packages/agent-core-v2/src/agent/usage/usageEvents.ts @@ -15,6 +15,7 @@ export interface AgentStatusUpdatedPayload { thinkingEffort?: string; maxContextTokens?: number; contextTokens?: number; + permission?: PermissionMode; } export class AgentStatusUpdated extends AgentEvent2 { diff --git a/packages/agent-core-v2/src/app/config/configService.ts b/packages/agent-core-v2/src/app/config/configService.ts index 4c3905a44..76400b0bf 100644 --- a/packages/agent-core-v2/src/app/config/configService.ts +++ b/packages/agent-core-v2/src/app/config/configService.ts @@ -669,7 +669,7 @@ export class ConfigService extends Disposable implements IConfigService { } private applyWatchEnabled(): void { - setWatchEnabled(this.get(WATCH_SECTION)?.enabled ?? false); + setWatchEnabled(this.get(WATCH_SECTION)?.enabled ?? true); } private deliveredValue(domain: string): unknown { diff --git a/packages/agent-core-v2/src/features/cron/cronAgentRuntime.ts b/packages/agent-core-v2/src/features/cron/cronAgentRuntime.ts index d527347b8..47387a369 100644 --- a/packages/agent-core-v2/src/features/cron/cronAgentRuntime.ts +++ b/packages/agent-core-v2/src/features/cron/cronAgentRuntime.ts @@ -3,6 +3,8 @@ import { assign, fromCallback, sendTo, setup, type Snapshot } from 'xstate'; import { IntervalTimer } from '#/_base/utils/timer'; import type { CronJobOrigin, CronMissedOrigin } from '#/agent/contextMemory/types'; +import { ContextAppendMessage } from '#/agent/contextMemory/contextEvents'; +import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentLoopService, type Turn } from '#/agent/loop/loop'; import { defineAgentRuntimeContract, @@ -21,7 +23,9 @@ import type { CronDeletedEvent, CronScheduledEvent } from '#/app/telemetry/event import { ITelemetryService } from '#/app/telemetry/telemetry'; import { BugIndicatingError } from '#/errors'; import type { ContentPart } from '#/kosong/contract/message'; +import { IAgentReminderService } from '#/features/reminder/reminderService'; import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; +import { Forked } from '#/session/agentLifecycle/forked'; import { CronAdd, CronCursor, CronDelete, CronFired, type CronModelState } from './cronOps'; @@ -36,14 +40,27 @@ export const CRON_FIRED = 'cron_fired' as const; export const CRON_MISSED = 'cron_missed' as const; export const CRON_DELETED = 'cron_deleted' as const; +const CRON_FORK_CLEARED_REMINDER = [ + 'This fork does not have any scheduled cron tasks.', + 'Tasks from the source session continue to run in the source session.', + 'Create new tasks here if needed.', +].join(' '); + +const CRON_FORK_CLEARED_REMINDER_NAME = 'cron_fork_cleared'; + +function isCronForkClearedReminder(message: ContextMessage): boolean { + const origin = message.origin; + return origin?.kind === 'injection' && origin.variant === CRON_FORK_CLEARED_REMINDER_NAME; +} + interface CronActorContext { - readonly tasks: CronModelState; + readonly model: CronModelState; readonly runtime: AgentRuntimeContext; } interface CronCommitEvent { readonly type: 'cron.commit'; - readonly tasks: CronModelState; + readonly model: CronModelState; } interface CronTickEvent { @@ -151,7 +168,7 @@ function removeTasks( runtime: AgentRuntimeContext, ids: readonly string[], ): readonly string[] { - const removed = ids.filter((id) => runtime.getState().has(id)); + const removed = ids.filter((id) => runtime.getState().tasks.has(id)); if (removed.length > 0) void runtime.dispatch(new CronDelete({ ids: removed })); return removed; } @@ -263,7 +280,7 @@ async function processDue( } const advancedTo = lastDueMs ?? now; state.lastSeenAt.set(task.id, advancedTo); - if (runtime.getState().has(task.id)) { + if (runtime.getState().tasks.has(task.id)) { void runtime.dispatch(new CronCursor({ id: task.id, lastFiredAt: advancedTo })); } } @@ -276,11 +293,11 @@ async function tickCron( ): Promise { await config.ready; if (isDisposed()) return; - if (readCronConfig(config).disabled || runtime.getState().size === 0) return; + if (readCronConfig(config).disabled || runtime.getState().tasks.size === 0) return; if (runtime.get(IAgentLoopService).snapshot().state === 'running') return; const now = state.clocks.wallNow(); await Promise.all( - [...runtime.getState().values()].map((task) => processDue(runtime, state, task, now, isDisposed)), + [...runtime.getState().tasks.values()].map((task) => processDue(runtime, state, task, now, isDisposed)), ); } @@ -297,6 +314,11 @@ const cronEffects = fromCallback(({ sendBack: (event: CronActorEvent) => void; }) => { if (input.runtime.agent.agentId !== MAIN_AGENT_ID) return; + if (input.runtime.getState().forkNotice.reminderPending) { + input.runtime.get(IAgentReminderService).notify(CRON_FORK_CLEARED_REMINDER, { + variant: CRON_FORK_CLEARED_REMINDER_NAME, + }); + } const config = configOf(input.runtime); const timer = new IntervalTimer({ unref: true }); const state: CronEffectState = { @@ -378,7 +400,7 @@ export class CronRuntime { } addTask(init: CronTaskInit): CronTask { - const tasks = this.runtime.getState(); + const tasks = this.runtime.getState().tasks; let id: string | undefined; for (let attempt = 0; attempt < MAX_ID_ATTEMPTS; attempt += 1) { const candidate = ulid(); @@ -400,11 +422,11 @@ export class CronRuntime { } getTask(id: string): CronTask | undefined { - return this.runtime.getState().get(id); + return this.runtime.getState().tasks.get(id); } list(): readonly CronTask[] { - return [...this.runtime.getState().values()]; + return [...this.runtime.getState().tasks.values()]; } isStale(task: CronTask): boolean { @@ -413,7 +435,7 @@ export class CronRuntime { getNextFireTime(): number | null { let min: number | null = null; - for (const task of this.runtime.getState().values()) { + for (const task of this.runtime.getState().tasks.values()) { const next = nextFireFor(this.runtime, task); if (next !== null && (min === null || next < min)) min = next; } @@ -421,7 +443,7 @@ export class CronRuntime { } getNextFireForTask(taskId: string): number | null { - const task = this.runtime.getState().get(taskId); + const task = this.runtime.getState().tasks.get(taskId); return task === undefined ? null : nextFireFor(this.runtime, task); } @@ -482,7 +504,10 @@ const cronActorLogic = setup({ }, actors: { cronEffects }, }).createMachine({ - context: ({ input }) => ({ tasks: new Map(), runtime: input }), + context: ({ input }) => ({ + model: { tasks: new Map(), forkNotice: { reminderPending: false } }, + runtime: input, + }), initial: 'beforeRestore', states: { beforeRestore: { @@ -509,7 +534,7 @@ const cronActorLogic = setup({ }, on: { 'cron.commit': { - actions: assign({ tasks: ({ event }) => event.tasks }), + actions: assign({ model: ({ event }) => event.model }), }, }, }); @@ -521,28 +546,40 @@ export const cronAgentRuntimeProvider = defineAgentRuntimeProvider { if (event instanceof CronAdd) { - state.set(event.task.id, event.task); + state.tasks.set(event.task.id, event.task); return; } if (event instanceof CronDelete) { - for (const id of event.ids) state.delete(id); + for (const id of event.ids) state.tasks.delete(id); return; } if (event instanceof CronCursor) { - const task = state.get(event.id); - if (task !== undefined) state.set(event.id, { ...task, lastFiredAt: event.lastFiredAt }); + const task = state.tasks.get(event.id); + if (task !== undefined) state.tasks.set(event.id, { ...task, lastFiredAt: event.lastFiredAt }); + return; + } + if (event instanceof Forked) { + state.forkNotice.reminderPending = + state.tasks.size > 0 || state.forkNotice.reminderPending; + state.tasks.clear(); + return; + } + if (event instanceof ContextAppendMessage) { + if (state.forkNotice.reminderPending && isCronForkClearedReminder(event.message)) { + state.forkNotice.reminderPending = false; + } } }, - read: (snapshot) => (snapshot as CronActorSnapshot).context.tasks, - commit: (actor, tasks) => { actor.send({ type: 'cron.commit', tasks }); }, + read: (snapshot) => (snapshot as CronActorSnapshot).context.model, + commit: (actor, model) => { actor.send({ type: 'cron.commit', model }); }, }, createApi: (context) => new CronRuntime(context), inspect: (snapshot) => - [...(snapshot as CronActorSnapshot).context.tasks.values()].map((task) => ({ + [...(snapshot as CronActorSnapshot).context.model.tasks.values()].map((task) => ({ id: task.id, cron: task.cron, recurring: task.recurring !== false, diff --git a/packages/agent-core-v2/src/features/cron/cronOps.ts b/packages/agent-core-v2/src/features/cron/cronOps.ts index 544aa817c..6c282e523 100644 --- a/packages/agent-core-v2/src/features/cron/cronOps.ts +++ b/packages/agent-core-v2/src/features/cron/cronOps.ts @@ -5,7 +5,10 @@ import type { CronJobOrigin } from '#/agent/contextMemory/types'; import type { CronTask } from '#/features/cron/cronTask'; import { Event2 } from '#/app/event/event2'; -export type CronModelState = Map; +export interface CronModelState { + readonly tasks: Map; + readonly forkNotice: { reminderPending: boolean }; +} const cronTaskSchema = z.object({ id: z.string(), diff --git a/packages/agent-core-v2/src/features/cron/cronService.ts b/packages/agent-core-v2/src/features/cron/cronService.ts index 1d8d0d7e8..30a305a21 100644 --- a/packages/agent-core-v2/src/features/cron/cronService.ts +++ b/packages/agent-core-v2/src/features/cron/cronService.ts @@ -23,7 +23,11 @@ import type { CronDeletedEvent, CronScheduledEvent } from '#/app/telemetry/event import { ITelemetryService } from '#/app/telemetry/telemetry'; import { BugIndicatingError } from '#/errors'; import type { ContentPart } from '#human/llm/message'; +import { ContextAppendMessage } from '#/agent/contextMemory/contextEvents'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { IAgentReminderService } from '#/features/reminder/reminderService'; import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; +import { Forked } from '#/session/agentLifecycle/forked'; import { IEventDispatcher } from '#/state/eventDispatcher'; import { CronAdd, CronCursor, CronDelete, CronFired, type CronModelState } from './cronOps'; @@ -31,6 +35,7 @@ import { CronAdd, CronCursor, CronDelete, CronFired, type CronModelState } from registerEvent2Class(CronAdd); registerEvent2Class(CronDelete); registerEvent2Class(CronCursor); +registerEvent2Class(Forked); const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; const DEFAULT_POLL_INTERVAL_MS = 1_000; @@ -43,14 +48,27 @@ export const CRON_FIRED = 'cron_fired' as const; export const CRON_MISSED = 'cron_missed' as const; export const CRON_DELETED = 'cron_deleted' as const; +const CRON_FORK_CLEARED_REMINDER = [ + 'This fork does not have any scheduled cron tasks.', + 'Tasks from the source session continue to run in the source session.', + 'Create new tasks here if needed.', +].join(' '); + +const CRON_FORK_CLEARED_REMINDER_NAME = 'cron_fork_cleared'; + +function isCronForkClearedReminder(message: ContextMessage): boolean { + const origin = message.origin; + return origin?.kind === 'injection' && origin.variant === CRON_FORK_CLEARED_REMINDER_NAME; +} + interface CronActorContext { - readonly tasks: CronModelState; + readonly model: CronModelState; readonly runtime: AgentActorContext; } interface CronCommitEvent { readonly type: 'cron.commit'; - readonly tasks: CronModelState; + readonly model: CronModelState; } interface CronTickEvent { @@ -154,7 +172,7 @@ function removeTasks( runtime: AgentActorContext, ids: readonly string[], ): readonly string[] { - const removed = ids.filter((id) => runtime.getState().has(id)); + const removed = ids.filter((id) => runtime.getState().tasks.has(id)); if (removed.length > 0) void runtime.dispatch(new CronDelete({ ids: removed })); return removed; } @@ -254,7 +272,7 @@ async function processDue( } const advancedTo = lastDueMs ?? now; state.lastSeenAt.set(task.id, advancedTo); - if (runtime.getState().has(task.id)) { + if (runtime.getState().tasks.has(task.id)) { void runtime.dispatch(new CronCursor({ id: task.id, lastFiredAt: advancedTo })); } } @@ -264,10 +282,10 @@ async function tickCron( state: CronEffectState, ): Promise { await configOf(runtime).ready; - if (cronConfigOf(runtime).disabled || runtime.getState().size === 0) return; + if (cronConfigOf(runtime).disabled || runtime.getState().tasks.size === 0) return; if (runtime.get(IAgentLoopService).snapshot().state === 'running') return; const now = state.clocks.wallNow(); - await Promise.all([...runtime.getState().values()].map((task) => processDue(runtime, state, task, now))); + await Promise.all([...runtime.getState().tasks.values()].map((task) => processDue(runtime, state, task, now))); } const cronEffects = fromCallback(({ @@ -283,6 +301,11 @@ const cronEffects = fromCallback(({ sendBack: (event: CronActorEvent) => void; }) => { if (input.runtime.agent.agentId !== MAIN_AGENT_ID) return; + if (input.runtime.getState().forkNotice.reminderPending) { + input.runtime.get(IAgentReminderService).notify(CRON_FORK_CLEARED_REMINDER, { + variant: CRON_FORK_CLEARED_REMINDER_NAME, + }); + } const timer = new IntervalTimer({ unref: true }); const state: CronEffectState = { clocks: SYSTEM_CLOCKS, @@ -356,7 +379,10 @@ const cronActorLogic = setup({ }, actors: { cronEffects }, }).createMachine({ - context: ({ input }) => ({ tasks: new Map(), runtime: input }), + context: ({ input }) => ({ + model: { tasks: new Map(), forkNotice: { reminderPending: false } }, + runtime: input, + }), initial: 'beforeRestore', states: { beforeRestore: { @@ -383,7 +409,7 @@ const cronActorLogic = setup({ }, on: { 'cron.commit': { - actions: assign({ tasks: ({ event }) => event.tasks }), + actions: assign({ model: ({ event }) => event.model }), }, }, }); @@ -429,24 +455,36 @@ export class AgentCronService extends AgentActorService implemen this.actor = this.attachActor(cronActorLogic, { id: 'cron', durable: { - events: [CronAdd, CronDelete, CronCursor], + events: [CronAdd, CronDelete, CronCursor, Forked, ContextAppendMessage], undoable: false, transition: (state, event) => { if (event instanceof CronAdd) { - state.set(event.task.id, event.task); + state.tasks.set(event.task.id, event.task); return; } if (event instanceof CronDelete) { - for (const id of event.ids) state.delete(id); + for (const id of event.ids) state.tasks.delete(id); return; } if (event instanceof CronCursor) { - const task = state.get(event.id); - if (task !== undefined) state.set(event.id, { ...task, lastFiredAt: event.lastFiredAt }); + const task = state.tasks.get(event.id); + if (task !== undefined) state.tasks.set(event.id, { ...task, lastFiredAt: event.lastFiredAt }); + return; + } + if (event instanceof Forked) { + state.forkNotice.reminderPending = + state.tasks.size > 0 || state.forkNotice.reminderPending; + state.tasks.clear(); + return; + } + if (event instanceof ContextAppendMessage) { + if (state.forkNotice.reminderPending && isCronForkClearedReminder(event.message)) { + state.forkNotice.reminderPending = false; + } } }, - read: (snapshot) => (snapshot as CronActorSnapshot).context.tasks, - commit: (actor, tasks) => { actor.send({ type: 'cron.commit', tasks }); }, + read: (snapshot) => (snapshot as CronActorSnapshot).context.model, + commit: (actor, model) => { actor.send({ type: 'cron.commit', model }); }, }, }); } @@ -460,7 +498,7 @@ export class AgentCronService extends AgentActorService implemen } addTask(init: CronTaskInit): CronTask { - const tasks = this.actor.getState(); + const tasks = this.actor.getState().tasks; let id: string | undefined; for (let attempt = 0; attempt < MAX_ID_ATTEMPTS; attempt += 1) { const candidate = ulid(); @@ -482,11 +520,11 @@ export class AgentCronService extends AgentActorService implemen } getTask(id: string): CronTask | undefined { - return this.actor.getState().get(id); + return this.actor.getState().tasks.get(id); } list(): readonly CronTask[] { - return [...this.actor.getState().values()]; + return [...this.actor.getState().tasks.values()]; } isStale(task: CronTask): boolean { @@ -495,7 +533,7 @@ export class AgentCronService extends AgentActorService implemen getNextFireTime(): number | null { let min: number | null = null; - for (const task of this.actor.getState().values()) { + for (const task of this.actor.getState().tasks.values()) { const next = nextFireFor(this.actor, task); if (next !== null && (min === null || next < min)) min = next; } @@ -503,7 +541,7 @@ export class AgentCronService extends AgentActorService implemen } getNextFireForTask(taskId: string): number | null { - const task = this.actor.getState().get(taskId); + const task = this.actor.getState().tasks.get(taskId); return task === undefined ? null : nextFireFor(this.actor, task); } diff --git a/packages/agent-core-v2/src/features/goal/goalOps.ts b/packages/agent-core-v2/src/features/goal/goalOps.ts index 6e2169b06..ffe5f6823 100644 --- a/packages/agent-core-v2/src/features/goal/goalOps.ts +++ b/packages/agent-core-v2/src/features/goal/goalOps.ts @@ -111,17 +111,6 @@ export interface GoalClear { readonly agentId: string; } -const goalForkedSchema = z.object({ agentId: z.string() }); - -export class GoalForked extends AgentEvent2> { - static override readonly type = 'forked'; - static override readonly durable = true; - static override readonly schema = goalForkedSchema; -} -export interface GoalForked { - readonly agentId: string; -} - export interface GoalUpdatedPayload { readonly agentId: string; snapshot: GoalSnapshot | null; diff --git a/packages/agent-core-v2/src/features/goal/goalService.ts b/packages/agent-core-v2/src/features/goal/goalService.ts index 713db4506..19ce2c3c5 100644 --- a/packages/agent-core-v2/src/features/goal/goalService.ts +++ b/packages/agent-core-v2/src/features/goal/goalService.ts @@ -49,6 +49,7 @@ import { type PythinkerErrorPayload, } from '#/errors'; import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; +import { Forked } from '#/session/agentLifecycle/forked'; import { ISessionUsageService } from '#/session/usage/sessionUsage'; import { IEventDispatcher } from '#/state/eventDispatcher'; import type { ExecutableToolResult } from '#/tool/toolContract'; @@ -58,7 +59,6 @@ import { IGoalDeadlineScheduler } from './goalDeadlineScheduler'; import { GoalClear, GoalCreate, - GoalForked, GoalUpdate, GoalUpdated, type GoalModelState, @@ -79,7 +79,7 @@ import type { registerEvent2Class(GoalCreate); registerEvent2Class(GoalUpdate); registerEvent2Class(GoalClear); -registerEvent2Class(GoalForked); +registerEvent2Class(Forked); const MAX_GOAL_OBJECTIVE_LENGTH = 4000; @@ -1322,7 +1322,7 @@ export class AgentGoalService extends AgentActorService implem this.actor = this.attachActor(goalActorLogic, { id: 'goal', durable: { - events: [GoalCreate, GoalUpdate, GoalClear, GoalForked, ContextAppendMessage], + events: [GoalCreate, GoalUpdate, GoalClear, Forked, ContextAppendMessage], undoable: false, transition: (state, event) => { if (event instanceof GoalCreate) { @@ -1375,7 +1375,7 @@ export class AgentGoalService extends AgentActorService implem state.forkNotice.goalPresent = false; return; } - if (event instanceof GoalForked) { + if (event instanceof Forked) { state.goal = null; state.forkNotice.reminderPending = state.forkNotice.goalPresent || state.forkNotice.reminderPending; diff --git a/packages/agent-core-v2/src/features/notify/notifyUserNudgeService.ts b/packages/agent-core-v2/src/features/notify/notifyUserNudgeService.ts index 2a0330ad4..1a35ff9c8 100644 --- a/packages/agent-core-v2/src/features/notify/notifyUserNudgeService.ts +++ b/packages/agent-core-v2/src/features/notify/notifyUserNudgeService.ts @@ -9,11 +9,12 @@ import { import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IFlagService } from '#/app/flag/flag'; import { IAgentReminderService } from '#/features/reminder/reminderService'; import { IEventDispatcher } from '#/state/eventDispatcher'; -import { NOTIFY_USER_FLAG_ID } from './flag'; +import { notifyUserAvailable } from './notifyUserAvailability'; import { NOTIFY_USER_NUDGE_VARIANT, lastMidResponsePosition, @@ -38,11 +39,13 @@ const notifyUserNudgeReminders = fromCallback(({ }; }) => { const runtime = input.runtime; - if (!runtime.get(IFlagService).enabled(NOTIFY_USER_FLAG_ID)) return () => {}; + const available = (): boolean => + notifyUserAvailable(runtime.get(IFlagService), runtime.get(IBootstrapService)); + if (!available()) return () => {}; const registration = runtime.get(IAgentReminderService).register( NOTIFY_USER_NUDGE_VARIANT, ({ lastInjectedAt }): string | undefined => { - if (!runtime.get(IFlagService).enabled(NOTIFY_USER_FLAG_ID)) return undefined; + if (!available()) return undefined; if (runtime.get(IAgentToolRegistryService).resolve(NOTIFY_USER_TOOL_NAME) === undefined) { return undefined; } diff --git a/packages/agent-core-v2/src/human/llm-pythinker/provider.ts b/packages/agent-core-v2/src/human/llm-pythinker/provider.ts index 90bf8cc80..590a83194 100644 --- a/packages/agent-core-v2/src/human/llm-pythinker/provider.ts +++ b/packages/agent-core-v2/src/human/llm-pythinker/provider.ts @@ -3,7 +3,7 @@ import { anthropicBetaBase } from '#/llm/requester/bases/anthropic/requester'; import { openAIBase } from '#/llm/requester/bases/openai/requester'; import { openAIResponsesBase } from '#/llm/requester/bases/openai-responses/requester'; -import { pythinkerAnthropicTrait, pythinkerConnection, pythinkerOpenAITrait } from './trait'; +import { pythinkerAnthropicTrait, pythinkerConnection, pythinkerOpenAITrait, pythinkerResponsesTrait } from './trait'; import { classifyPythinkerQuotaError } from './errors'; import { pythinkerMediaContribution } from './media'; @@ -24,6 +24,7 @@ export const pythinkerProvider = createProvider({ }, openai_responses: { base: openAIResponsesBase, + trait: pythinkerResponsesTrait, connection: pythinkerConnection, classifyError: classifyPythinkerQuotaError, }, diff --git a/packages/agent-core-v2/src/human/llm-pythinker/trait.ts b/packages/agent-core-v2/src/human/llm-pythinker/trait.ts index d28ac17a0..cc6b1557b 100644 --- a/packages/agent-core-v2/src/human/llm-pythinker/trait.ts +++ b/packages/agent-core-v2/src/human/llm-pythinker/trait.ts @@ -1,3 +1,4 @@ +import type { LlmModel } from '#/llm/model'; import type { ProtocolEndpoint, ProviderConnection } from '#/llm/protocol/connection'; import type { ContentPart, ToolDescription } from '#/llm/message'; import { providerImagePolicy } from '#/llm/media/image-formats'; @@ -8,6 +9,7 @@ import type { OpenAIWireMessage, OpenAIWireToolCall, } from '#/llm/requester/bases/openai/contract'; +import type { OpenAIResponsesTrait } from '#/llm/requester/bases/openai-responses/trait'; import type { OpenAITrait } from '#/llm/requester/bases/openai/trait'; import { normalizePythinkerToolSchema } from './schema'; @@ -64,6 +66,19 @@ function convertPythinkerTool(tool: ToolDescription): Record { const pythinkerAcceptedImageMimes = (): ReadonlySet => providerImagePolicy('pythinker').acceptedMimes; +export function pythinkerUnsetCompletionTokens(input: { + readonly model: LlmModel; + readonly usedContextTokens?: number; +}): number | undefined { + const window = input.model.maxContextSize; + if (window === undefined || window <= 0 || input.usedContextTokens === undefined) return undefined; + return Math.max(1, window - input.usedContextTokens); +} + +export const pythinkerResponsesTrait: OpenAIResponsesTrait = { + completionTokensWhenUnset: pythinkerUnsetCompletionTokens, +}; + export const pythinkerOpenAITrait: OpenAITrait = { strictThinkingValidation: true, @@ -91,6 +106,8 @@ export const pythinkerOpenAITrait: OpenAITrait = { max_completion_tokens: maxCompletionTokens, }), + completionTokensWhenUnset: pythinkerUnsetCompletionTokens, + buildParams: (params) => { const { extra_body: extraBody, ...rest } = params; if (extraBody === undefined || extraBody === null) { diff --git a/packages/agent-core-v2/src/human/llm/protocol/format.ts b/packages/agent-core-v2/src/human/llm/protocol/format.ts index c25ed5e17..37fa76d42 100644 --- a/packages/agent-core-v2/src/human/llm/protocol/format.ts +++ b/packages/agent-core-v2/src/human/llm/protocol/format.ts @@ -12,7 +12,7 @@ export type FormatRequestInput = LlmRequestConfig & { export function resolveMaxCompletionCap(input: FormatRequestInput): number | undefined { const { maxCompletionTokens, usedContextTokens, maxContextTokens } = input; - if (maxCompletionTokens === undefined) { + if (maxCompletionTokens === undefined || maxCompletionTokens <= 0) { return undefined; } let cap = maxCompletionTokens; diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/profile.ts b/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/profile.ts index 4959ee973..b351d787f 100644 --- a/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/profile.ts +++ b/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/profile.ts @@ -133,7 +133,7 @@ const CEILING_BY_FAMILY_VERSION: Readonly> = { 'haiku-3': 4096, }; -const FALLBACK_MAX_TOKENS = 128000; +const FALLBACK_MAX_TOKENS = 64000; function lookupClaudeCeiling(version: AnthropicModelVersion): number | undefined { const { family, major, minor } = version; diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/requester.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/requester.ts index 357e54035..5aac0822a 100644 --- a/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/requester.ts +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/requester.ts @@ -81,7 +81,11 @@ export function prepareOpenAIResponsesRequest( encodeReasoningEffortFallback(t, ctx.model, trait?.strictThinkingValidation === true), ).kwargs; } - const cap = resolveMaxCompletionCap(input); + const requested = input.maxCompletionTokens; + const cap = + requested !== undefined && requested <= 0 + ? undefined + : (resolveMaxCompletionCap(input) ?? trait?.completionTokensWhenUnset?.(input)); if (cap !== undefined) { kwargs = { ...kwargs, diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/trait.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/trait.ts index 304837481..d202bd39c 100644 --- a/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/trait.ts +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/trait.ts @@ -1,4 +1,5 @@ import type { ToolDescription } from '#/llm/message'; +import type { LlmModel } from '#/llm/model'; import type { TraitContext } from '#/llm/protocol/base'; import type { ThinkingStrategy } from '#/llm/protocol/thinking'; import type { ToolCallIdPolicy, ToolMessageConversion } from '#/llm/requester/requester'; @@ -19,6 +20,11 @@ export interface OpenAIResponsesTrait { ctx: TraitContext, ): Record | undefined; + completionTokensWhenUnset?(input: { + readonly model: LlmModel; + readonly usedContextTokens?: number; + }): number | undefined; + convertTool?(tool: ToolDescription, ctx: TraitContext): Record | undefined; mergeHistory?( diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai/requester.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai/requester.ts index a19bfc1c6..70f631266 100644 --- a/packages/agent-core-v2/src/human/llm/requester/bases/openai/requester.ts +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai/requester.ts @@ -96,7 +96,11 @@ export function prepareOpenAIRequest( if (input.responseFormat !== undefined) { kwargs = { ...kwargs, response_format: responseFormatToOpenAI(input.responseFormat) }; } - const cap = resolveMaxCompletionCap(input); + const requested = input.maxCompletionTokens; + const cap = + requested !== undefined && requested <= 0 + ? undefined + : (resolveMaxCompletionCap(input) ?? trait?.completionTokensWhenUnset?.(input)); if (cap !== undefined) { kwargs = { ...kwargs, diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai/trait.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai/trait.ts index d9377faa8..1a4784297 100644 --- a/packages/agent-core-v2/src/human/llm/requester/bases/openai/trait.ts +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai/trait.ts @@ -1,4 +1,5 @@ import type { Message, ToolDescription } from '#/llm/message'; +import type { LlmModel } from '#/llm/model'; import type { TraitContext } from '#/llm/protocol/base'; import type { ThinkingStrategy } from '#/llm/protocol/thinking'; import type { ToolCallIdPolicy, ToolMessageConversion } from '#/llm/requester/requester'; @@ -20,6 +21,11 @@ export interface OpenAITrait { ctx: TraitContext, ): Record | undefined; + completionTokensWhenUnset?(input: { + readonly model: LlmModel; + readonly usedContextTokens?: number; + }): number | undefined; + convertTool?(tool: ToolDescription, ctx: TraitContext): Record | undefined; convertMessage?( diff --git a/packages/agent-core-v2/src/human/test/llm/trait.test.ts b/packages/agent-core-v2/src/human/test/llm/trait.test.ts index 1f3964c2c..97f386493 100644 --- a/packages/agent-core-v2/src/human/test/llm/trait.test.ts +++ b/packages/agent-core-v2/src/human/test/llm/trait.test.ts @@ -739,6 +739,20 @@ describe('withMaxCompletionTokens', () => { ); expect(client.body()['max_completion_tokens']).toBe(1000); expect(client.body()['max_tokens']).toBeUndefined(); + + await requester.generate( + { model: { ...model, maxContextSize: 1000 } }, + { messages, usedContextTokens: 40 }, + { signal: new AbortController().signal }, + ); + expect(client.body()['max_completion_tokens']).toBe(960); + + await requester.generate( + { model: { ...model, maxContextSize: 1000 }, maxCompletionTokens: 0 }, + { messages, usedContextTokens: 40 }, + { signal: new AbortController().signal }, + ); + expect(client.body()['max_completion_tokens']).toBeUndefined(); }); it('uses max_completion_tokens for reasoning models without a trait', async () => { @@ -790,7 +804,7 @@ describe('withMaxCompletionTokens', () => { { messages }, { signal: new AbortController().signal }, ); - expect(client.body()['max_tokens']).toBe(128000); + expect(client.body()['max_tokens']).toBe(64000); const sonnet35 = { ...model, model: 'claude-3-5-sonnet-20241022' }; await requester.generate( @@ -1461,7 +1475,7 @@ describe('anthropic thinking kwargs', () => { expect(body['output_config']).toEqual({ effort: 'high' }); expect(body['betaFeatures']).toBeUndefined(); expect(body['betas']).toEqual(['context-management-2025-06-27']); - expect(body['max_tokens']).toBe(128000); + expect(body['max_tokens']).toBe(64000); expect(client.betaCalled()).toBe(true); let bodyMessages = body['messages'] as Record[]; expect(bodyMessages[0]?.['content']).toEqual([ diff --git a/packages/agent-core-v2/src/human/test/utils/watch.test.ts b/packages/agent-core-v2/src/human/test/utils/watch.test.ts index 67c518784..392cc5f67 100644 --- a/packages/agent-core-v2/src/human/test/utils/watch.test.ts +++ b/packages/agent-core-v2/src/human/test/utils/watch.test.ts @@ -20,14 +20,6 @@ const wait = (ms: number): Promise => new Promise((r) => setTimeout(r, ms) const longTempDir = (prefix: string): Promise => mkdtemp(join(realpathSync.native(tmpdir()), prefix)); -beforeEach(() => { - setWatchEnabled(true); -}); - -afterEach(() => { - setWatchEnabled(false); -}); - class TestNativeWatcher { private errorListener: ((error: NodeJS.ErrnoException) => void) | undefined; closed = false; @@ -477,10 +469,14 @@ describe('watch chokidar mode', () => { it('does not start a filesystem watch when watch is disabled', async () => { root = await mkdtemp(join(tmpdir(), 'watch-disabled-')); setWatchEnabled(false); - const events = await start(); - await writeFile(join(root, 'a.txt'), 'x'); - await wait(300); - expect(events).toHaveLength(0); + try { + const events = await start(); + await writeFile(join(root, 'a.txt'), 'x'); + await wait(300); + expect(events).toHaveLength(0); + } finally { + setWatchEnabled(true); + } }); it('stops firing after the handle is disposed', async () => { diff --git a/packages/agent-core-v2/src/human/utils/watch.ts b/packages/agent-core-v2/src/human/utils/watch.ts index e8fe23fbe..c1f4ca231 100644 --- a/packages/agent-core-v2/src/human/utils/watch.ts +++ b/packages/agent-core-v2/src/human/utils/watch.ts @@ -513,7 +513,7 @@ export const WATCH_ENV = 'PYTHINKER_CODE_WATCH'; const TRUE_WATCH_ENV = new Set(['1', 'true', 'yes', 'on']); const FALSE_WATCH_ENV = new Set(['0', 'false', 'no', 'off']); -let watchEnabledFromConfig = false; +let watchEnabledFromConfig = true; export function setWatchEnabled(enabled: boolean): void { watchEnabledFromConfig = enabled; diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index f83e5fbf2..f24ea0eba 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -465,6 +465,7 @@ export * from '#/features/cron/tools/cron-delete/cron-delete'; import '#/session/agentLifecycle/profile/profiles'; export * from '#/session/agentLifecycle/agentLifecycle'; export * from '#/session/agentLifecycle/agentLifecycleService'; +export * from '#/session/agentLifecycle/forked'; export * from '#/session/agentLifecycle/mainAgent'; export * from '#/session/mcp/sessionMcpHandle'; import '#/app/mcpConfig/configSection'; diff --git a/packages/agent-core-v2/src/llm-adapter/model/completion-budget.ts b/packages/agent-core-v2/src/llm-adapter/model/completion-budget.ts index b4fe8c01e..0cbc60df4 100644 --- a/packages/agent-core-v2/src/llm-adapter/model/completion-budget.ts +++ b/packages/agent-core-v2/src/llm-adapter/model/completion-budget.ts @@ -1,50 +1,31 @@ import type { ModelCapability } from '../contract/capability'; -import type { CompletionBudgetConfig, CompletionBudgetParams } from './model.types'; +import type { CompletionBudgetParams } from './model.types'; const MIN_FLOOR = 1; -const DEFAULT_UNKNOWN_CONTEXT_FALLBACK = 32000; export function resolveCompletionBudget(args: { readonly maxOutputSize?: number; - readonly reservedContextSize?: number; readonly maxCompletionTokensCap?: number; -}): CompletionBudgetConfig | undefined { +}): number | undefined { if (args.maxCompletionTokensCap !== undefined) { if (args.maxCompletionTokensCap <= 0) return undefined; - return { hardCap: args.maxCompletionTokensCap }; + return args.maxCompletionTokensCap; } if (args.maxOutputSize !== undefined && args.maxOutputSize > 0) { - return { hardCap: args.maxOutputSize }; + return args.maxOutputSize; } - if (args.reservedContextSize !== undefined && args.reservedContextSize > 0) { - return { fallback: args.reservedContextSize }; - } - return { fallback: DEFAULT_UNKNOWN_CONTEXT_FALLBACK }; -} - -export function computeCompletionBudgetCap(args: { - readonly budget: CompletionBudgetConfig; - readonly capability: ModelCapability | undefined; -}): number { - const maxCtx = args.capability?.max_context_tokens ?? 0; - const cap = - args.budget.hardCap ?? - (maxCtx > 0 ? maxCtx : args.budget.fallback ?? DEFAULT_UNKNOWN_CONTEXT_FALLBACK); - return Math.max(MIN_FLOOR, cap); + return undefined; } export function completionBudgetParams(args: { - readonly budget: CompletionBudgetConfig | undefined; + readonly budget: number | undefined; readonly capability: ModelCapability | undefined; readonly usedContextTokens?: number; }): CompletionBudgetParams | undefined { if (args.budget === undefined) return undefined; return { - maxCompletionTokens: computeCompletionBudgetCap({ - budget: args.budget, - capability: args.capability, - }), + maxCompletionTokens: Math.max(MIN_FLOOR, args.budget), usedContextTokens: args.usedContextTokens, maxContextTokens: args.capability?.max_context_tokens, }; diff --git a/packages/agent-core-v2/src/llm-adapter/model/model.types.ts b/packages/agent-core-v2/src/llm-adapter/model/model.types.ts index 067924775..7b500d58b 100644 --- a/packages/agent-core-v2/src/llm-adapter/model/model.types.ts +++ b/packages/agent-core-v2/src/llm-adapter/model/model.types.ts @@ -8,11 +8,6 @@ export interface ModelOverrides { readonly maxCompletionTokens?: number; } -export interface CompletionBudgetConfig { - readonly hardCap?: number; - readonly fallback?: number; -} - export interface CompletionBudgetParams { readonly maxCompletionTokens: number; readonly usedContextTokens?: number; diff --git a/packages/agent-core-v2/src/llm-adapter/provider/provider-definition.ts b/packages/agent-core-v2/src/llm-adapter/provider/provider-definition.ts index f9809a471..cccfe8346 100644 --- a/packages/agent-core-v2/src/llm-adapter/provider/provider-definition.ts +++ b/packages/agent-core-v2/src/llm-adapter/provider/provider-definition.ts @@ -7,6 +7,7 @@ import { pythinkerAnthropicTrait, pythinkerConnection, pythinkerOpenAITrait, + pythinkerResponsesTrait, PYTHINKER_DEFAULT_BASE_URL, } from '#human/llm-pythinker/trait'; import { classifyPythinkerQuotaError } from '#human/llm-pythinker/errors'; @@ -246,6 +247,7 @@ registerProviderDefinition({ registerProviderDefinition({ id: 'pythinker', baseProtocol: 'openai_responses', + trait: pythinkerResponsesTrait, connection: pythinkerConnection, classifyError: classifyPythinkerQuotaError, endpoint: pythinkerEndpoint, diff --git a/packages/agent-core-v2/src/session/agentLifecycle/forked.ts b/packages/agent-core-v2/src/session/agentLifecycle/forked.ts new file mode 100644 index 000000000..30a910023 --- /dev/null +++ b/packages/agent-core-v2/src/session/agentLifecycle/forked.ts @@ -0,0 +1,15 @@ +/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ +import { z } from 'zod'; + +import { AgentEvent2 } from '#/app/event/event2'; + +const forkedSchema = z.object({ agentId: z.string() }); + +export class Forked extends AgentEvent2> { + static override readonly type = 'forked'; + static override readonly durable = true; + static override readonly schema = forkedSchema; +} +export interface Forked { + readonly agentId: string; +} diff --git a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts index 87c41d184..1817b1693 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -3017,7 +3017,7 @@ describe('FullCompaction', () => { const events = await ctx.untilTurnEnd(); expect(callCount).toBe(3); - expect(compactionMaxCompletionTokens).toEqual([32000]); + expect(compactionMaxCompletionTokens).toEqual([undefined]); expect(events).toContainEqual( expect.objectContaining({ event: 'compaction.started', @@ -3110,7 +3110,7 @@ describe('FullCompaction', () => { await ctx.untilTurnEnd(); expect(callCount).toBe(3); - expect(compactionMaxCompletionTokens).toEqual([undefined]); + expect(compactionMaxCompletionTokens).toEqual([Number(maxCompletionTokens)]); }, ); diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 131fa8771..cdb485829 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -105,7 +105,7 @@ describe('Agent loop', () => { [wire] llm.tools_snapshot { "agentId": "main", "hash": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "tools": [], "time": "