diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index 6b450b64..3ffc2059 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -41,7 +41,8 @@ import { RuntimeWorkspaceCommandSandbox } from './workspace-runtime.js'; import { NativeProcessWorkspaceCommandSandbox } from './native-process.js'; import { NativeWorkspaceCommandPool } from './native-pool.js'; import { GitWorktreeWorkspaceTools, internalWorkspaceId } from './workspace-instances.js'; -import { LINKED_WORKTREE_DIRECTORY, LinkedWorktreeWorkspaceTools } from './linked-worktrees.js'; +import { LaneGitWorkspaceTools } from './lane-git.js'; +import { LINKED_WORKTREE_DIRECTORY, LinkedWorktreeWorkspaceTools, verifyLinkedWorktree } from './linked-worktrees.js'; import { GitWorktreeManager } from './worktrees.js'; import { WorktreeRetirementScheduler, @@ -1312,6 +1313,23 @@ async function run( }); workspaceTools = linkedWorktreeTools; } + if (workspaceTools?.capabilities.operations.includes('execute_command')) { + workspaceTools = new LaneGitWorkspaceTools({ + delegate: workspaceTools, + async resolveRoot(request, signal) { + const source = roots.find((root) => root.id === request.workspaceId); + if (!source) return undefined; + if (request.workspaceInstanceId != null) { + return await conversationWorktrees?.plannedRoot(request.workspaceId, request.workspaceInstanceId); + } + if (request.worktree != null) { + signal?.throwIfAborted(); + return (await verifyLinkedWorktree(source.root, request.worktree, source.identity)).root; + } + return source.root; + }, + }); + } const retirementSources = roots.filter((root) => root.writable); const debugLogs = process.env.LIBRECHAT_CODE_LOG_LEVEL?.trim().toLowerCase() === 'debug'; diff --git a/packages/code/src/lane-git.test.ts b/packages/code/src/lane-git.test.ts new file mode 100644 index 00000000..84e01e88 --- /dev/null +++ b/packages/code/src/lane-git.test.ts @@ -0,0 +1,312 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { mkdir, mkdtemp, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { promisify } from 'node:util'; + +import { LaneGitWorkspaceTools, readLaneGit } from './lane-git.js'; +import { isValidBridgeWorkspaceToolCapabilities, isWorkspaceLaneGit, isWorkspaceToolResult } from './protocol.js'; +import { BridgeWorker } from './worker.js'; + +import type { TestContext } from 'node:test'; +import type { WorkspaceExecuteCommandRequest, WorkspaceToolRequest } from './protocol.js'; + +const exec = promisify(execFile); +const incarnationId = 'incarnation-00000001'; +const identity = ['-c', 'user.name=Test', '-c', 'user.email=test@example.com']; + +async function scratch(t: TestContext): Promise { + const root = await realpath(await mkdtemp(join(tmpdir(), 'lane-git-'))); + t.after(() => rm(root, { recursive: true, force: true })); + return root; +} + +async function repo(root: string, name: string, commit = true): Promise { + const directory = join(root, name); + await mkdir(directory, { recursive: true }); + await exec('git', ['init', '--initial-branch=main', directory]); + if (commit) await exec('git', ['-C', directory, ...identity, 'commit', '--allow-empty', '-m', 'initial']); + return directory; +} + +async function sha(directory: string): Promise { + return (await exec('git', ['-C', directory, 'rev-parse', 'HEAD'])).stdout.trim(); +} + +test('isolated worktree lane reports its own branch and head', async (t) => { + const root = await scratch(t); + const source = await repo(root, 'source'); + const lane = join(root, 'lane'); + await exec('git', ['-C', source, 'worktree', 'add', '-b', 'librechat/conversation-abcd1234-0123', lane]); + assert.deepEqual(await readLaneGit(lane), { branch: 'librechat/conversation-abcd1234-0123', head: await sha(lane) }); + assert.equal((await readLaneGit(source))?.branch, 'main'); +}); + +test('source checkout reports whatever is checked out', async (t) => { + const root = await scratch(t); + const source = await repo(root, 'source'); + await exec('git', ['-C', source, 'checkout', '-b', 'feature/pr-header']); + assert.deepEqual(await readLaneGit(source), { branch: 'feature/pr-header', head: await sha(source) }); +}); + +test('a branch renamed by the agent shows up on the next read', async (t) => { + const root = await scratch(t); + const source = await repo(root, 'source'); + assert.equal((await readLaneGit(source))?.branch, 'main'); + await exec('git', ['-C', source, 'branch', '-m', 'renamed-by-agent']); + assert.equal((await readLaneGit(source))?.branch, 'renamed-by-agent'); +}); + +test('detached HEAD reports a null branch and the commit', async (t) => { + const root = await scratch(t); + const source = await repo(root, 'source'); + await exec('git', ['-C', source, 'checkout', '--detach']); + assert.deepEqual(await readLaneGit(source), { branch: null, head: await sha(source) }); +}); + +test('a repository without commits reports the branch and a null head', async (t) => { + const root = await scratch(t); + const source = await repo(root, 'source', false); + assert.deepEqual(await readLaneGit(source), { branch: 'main', head: null }); +}); + +test('a branch name beyond the bound reports null and never forwards it', async (t) => { + const root = await scratch(t); + const source = await repo(root, 'source'); + const long = ['a'.repeat(100), 'b'.repeat(100), 'c'.repeat(100)].join('/'); + await exec('git', ['-C', source, 'checkout', '-b', long]); + assert.deepEqual(await readLaneGit(source), { branch: null, head: await sha(source) }); +}); + +test('a directory that is not a repository reports nothing', async (t) => { + const root = await scratch(t); + assert.equal(await readLaneGit(root), undefined); +}); + +test('lane Git state never carries paths, remotes or credentials', async (t) => { + const root = await scratch(t); + const source = await repo(root, 'source'); + await exec('git', ['-C', source, 'remote', 'add', 'origin', 'https://user:secret@github.com/example/app.git']); + const state = await readLaneGit(source); + assert.deepEqual(Object.keys(state ?? {}).sort(), ['branch', 'head']); + const wire = JSON.stringify(state); + assert.equal(wire.includes(root), false); + assert.equal(wire.includes('secret'), false); + assert.equal(wire.includes('github.com'), false); +}); + +test('wire validator accepts only bounded branch and head', () => { + const head = 'a'.repeat(40); + assert.equal(isWorkspaceLaneGit({ branch: 'main', head }), true); + assert.equal(isWorkspaceLaneGit({ branch: null, head: null }), true); + assert.equal(isWorkspaceLaneGit({ branch: 'main', head: 'b'.repeat(64) }), true); + for (const bad of [ + { branch: 'x'.repeat(257), head }, + { branch: 'bad\nname', head }, + { branch: '', head }, + { branch: 'main', head: 'A'.repeat(40) }, + { branch: 'main', head: 'a'.repeat(41) }, + { branch: 'main' }, + { branch: 'main', head, path: '/srv/repo' }, + { branch: undefined, head }, + null, + [], + ]) { + assert.equal(isWorkspaceLaneGit(bad), false, JSON.stringify(bad)); + } +}); + +const commandRequest: WorkspaceExecuteCommandRequest = { + protocolVersion: 1, + operation: 'execute_command', + workspaceId: 'primary', + command: 'git checkout -b agent-branch', +}; +const commandResult = { + protocolVersion: 1 as const, + operation: 'execute_command' as const, + workspaceId: 'primary', + exitCode: 0, + stdout: '', + stderr: '', + truncated: false, + timedOut: false, +}; + +function delegate(onRun?: () => Promise) { + return { + capabilities: { + protocolVersion: 1 as const, + operations: ['read_file' as const, 'execute_command' as const], + workspaces: [{ id: 'primary' }], + }, + async execute(request: WorkspaceToolRequest) { + await onRun?.(); + return request.operation === 'execute_command' + ? commandResult + : { protocolVersion: 1 as const, operation: 'read_file' as const, workspaceId: 'primary', path: 'a', content: '', startLine: 1, endLine: 1, truncated: false }; + }, + }; +} + +test('a finished command refreshes the lane state, including a branch the command created', async (t) => { + const root = await scratch(t); + const source = await repo(root, 'source'); + const tools = new LaneGitWorkspaceTools({ + delegate: delegate(async () => { + await exec('git', ['-C', source, 'checkout', '-b', 'agent-branch']); + }), + resolveRoot: async () => source, + }); + const result = await tools.execute(commandRequest); + assert.deepEqual('laneGit' in result && result.laneGit, { branch: 'agent-branch', head: await sha(source) }); + assert.equal(isWorkspaceToolResult(commandRequest, result), true); +}); + +test('file operations do not read Git', async () => { + let resolved = 0; + const tools = new LaneGitWorkspaceTools({ + delegate: delegate(), + resolveRoot: async () => { + resolved++; + return undefined; + }, + }); + const result = await tools.execute({ protocolVersion: 1, operation: 'read_file', workspaceId: 'primary', path: 'a' }); + assert.equal('laneGit' in result, false); + assert.equal(resolved, 0); +}); + +test('an unreadable lane leaves the command result untouched', async (t) => { + const root = await scratch(t); + for (const resolveRoot of [async () => root, async () => undefined, async () => Promise.reject(new Error('gone'))]) { + const result = await new LaneGitWorkspaceTools({ delegate: delegate(), resolveRoot }).execute(commandRequest); + assert.deepEqual(result, commandResult); + } +}); + +test('the feature is advertised only when commands are', () => { + const withCommands = new LaneGitWorkspaceTools({ delegate: delegate(), resolveRoot: async () => undefined }); + assert.deepEqual(withCommands.capabilities.commandResultFeatures, ['lane_git']); + assert.equal(isValidBridgeWorkspaceToolCapabilities(withCommands.capabilities), true); + const readOnly = new LaneGitWorkspaceTools({ + delegate: { ...delegate(), capabilities: { protocolVersion: 1, operations: ['read_file'], workspaces: [{ id: 'primary' }] } }, + resolveRoot: async () => undefined, + }); + assert.equal(readOnly.capabilities.commandResultFeatures, undefined); + assert.equal( + isValidBridgeWorkspaceToolCapabilities({ ...withCommands.capabilities, commandResultFeatures: ['other'] }), + false, + ); +}); + +// Compatibility: negotiation with old and new Code API servers. + +function quarantine() { + return { async assertAvailable() {}, async arm() {}, async clear() {}, async quarantine() {} }; +} + +async function run(t: TestContext, supported: boolean) { + const root = await scratch(t); + const source = await repo(root, 'source'); + const tools = new LaneGitWorkspaceTools({ delegate: delegate(), resolveRoot: async () => source }); + const registrations: Array<{ commandResultFeatures?: string[] }> = []; + const settlements: Array<{ result?: Record }> = []; + const worker = new BridgeWorker({ + codeApiUrl: 'https://code.example/v1', + token: 'worker-secret', + workerId: 'vm-1', + incarnationId, + sandboxEndpoint: 'http://127.0.0.1:2000/api/v2', + capabilities: { statefulWorkspace: false, sandboxProfile: 'anthropic-srt', runtimes: [], workspaceTools: tools.capabilities }, + workspaceTools: tools, + workspaceMutationQuarantine: quarantine(), + fetchImpl: async (input, init) => { + const url = String(input); + const body = init?.body ? JSON.parse(String(init.body)) : {}; + if (url.endsWith('/bridge/workers/register')) { + registrations.push(body.capabilities.workspaceTools ?? {}); + return Response.json({ + protocolVersion: 1, + workerId: 'vm-1', + incarnationId, + registeredAt: new Date().toISOString(), + leaseTtlMs: 60_000, + supportedWorkspaceToolOperations: ['read_file', 'execute_command'], + ...(supported ? { supportedWorkspaceCommandResultFeatures: ['lane_git'] } : {}), + }); + } + if (url.endsWith('/settle')) settlements.push(body); + return Response.json({ protocolVersion: 1, accepted: true }); + }, + }); + await worker.register(); + await worker.executeAndSettle({ + protocolVersion: 1, + assignmentId: 'assignment-1', + workerId: 'vm-1', + incarnationId, + generation: 1, + leaseToken: 'lease-token-that-is-long-enough-for-testing', + expiresAt: new Date(Date.now() + 5_000).toISOString(), + executionKind: 'workspace_tool', + workspaceId: 'primary', + request: commandRequest, + }); + return { registrations, settlements }; +} + +test('a Code API without the feature still pairs, registers without it, and never receives laneGit', async (t) => { + const { registrations, settlements } = await run(t, false); + assert.ok(registrations.length > 0); + for (const registration of registrations) assert.equal('commandResultFeatures' in registration, false); + assert.equal(settlements.length, 1); + assert.equal(settlements[0]?.result?.exitCode, 0); + assert.equal('laneGit' in (settlements[0]?.result ?? {}), false); +}); + +test('a Code API that acknowledges the feature registers it and receives laneGit', async (t) => { + const { registrations, settlements } = await run(t, true); + assert.deepEqual(registrations.at(-1)?.commandResultFeatures, ['lane_git']); + assert.deepEqual(Object.keys((settlements[0]?.result?.laneGit as object) ?? {}).sort(), ['branch', 'head']); + assert.equal((settlements[0]?.result?.laneGit as { branch: string }).branch, 'main'); +}); + +test('a worker without the field still pairs and reports status', async () => { + const executor = { + capabilities: { protocolVersion: 1 as const, operations: ['read_file' as const], workspaces: [{ id: 'primary' }] }, + async execute(): Promise { + throw new Error('not executed'); + }, + }; + const registrations: unknown[] = []; + const worker = new BridgeWorker({ + codeApiUrl: 'https://code.example/v1', + token: 'worker-secret', + workerId: 'vm-1', + incarnationId, + sandboxEndpoint: 'http://127.0.0.1:2000/api/v2', + capabilities: { statefulWorkspace: false, sandboxProfile: 'anthropic-srt', runtimes: [], workspaceTools: executor.capabilities }, + workspaceTools: executor, + fetchImpl: async (_input, init) => { + registrations.push(JSON.parse(String(init?.body)).capabilities.workspaceTools); + return Response.json({ + protocolVersion: 1, + workerId: 'vm-1', + incarnationId, + registeredAt: new Date().toISOString(), + leaseTtlMs: 60_000, + supportedWorkspaceToolOperations: ['read_file'], + }); + }, + }); + const registration = await worker.register(); + assert.equal(registration.workerId, 'vm-1'); + assert.ok(registrations.length > 0); + for (const advertised of registrations) { + assert.equal(JSON.stringify(advertised).includes('commandResultFeatures'), false); + assert.equal(JSON.stringify(advertised).includes('laneGit'), false); + } +}); diff --git a/packages/code/src/lane-git.ts b/packages/code/src/lane-git.ts new file mode 100644 index 00000000..92852047 --- /dev/null +++ b/packages/code/src/lane-git.ts @@ -0,0 +1,80 @@ +import { boundedBranch, boundedHead } from './protocol.js'; +import { git } from './worktree-retirement.js'; + +import type { WorkspaceLaneGit, WorkspaceToolRequest, WorkspaceToolResult } from './protocol.js'; +import type { WorkspaceToolExecutor } from './workspace.js'; + +const LANE_GIT_TIMEOUT_MS = 5_000; +const LANE_GIT_OUTPUT_LIMIT = 4096; + +/** Exit codes that mean "no value" rather than "could not read": detached HEAD, unborn branch. */ +const DETACHED_EXIT = 1; +const UNBORN_EXIT = 128; + +async function read( + root: string, + args: string[], + emptyExit: number, + signal?: AbortSignal, +): Promise { + try { + return (await git(root, args, signal, LANE_GIT_TIMEOUT_MS, LANE_GIT_OUTPUT_LIMIT)).trim(); + } catch (error) { + signal?.throwIfAborted(); + return (error as { code?: unknown }).code === emptyExit ? null : undefined; + } +} + +/** + * The branch and head commit checked out in a lane root, or undefined when Git + * could not be read (timeout, missing binary, aborted). Detached HEAD and a + * repository without commits are ordinary states and report null. Values that + * fail the bounds are reported as null, never forwarded. Nothing else from the + * repository is read or returned. + */ +export async function readLaneGit(root: string, signal?: AbortSignal): Promise { + const [branch, head] = await Promise.all([ + read(root, ['symbolic-ref', '--quiet', '--short', 'HEAD'], DETACHED_EXIT, signal), + read(root, ['rev-parse', '--verify', 'HEAD'], UNBORN_EXIT, signal), + ]); + if (branch === undefined || head === undefined) return undefined; + return { branch: boundedBranch(branch), head: boundedHead(head) }; +} + +export interface LaneGitWorkspaceToolsOptions { + delegate: WorkspaceToolExecutor; + /** The lane root a request ran in (isolated worktree, linked worktree or source checkout), or undefined. */ + resolveRoot: (request: WorkspaceToolRequest, signal?: AbortSignal) => Promise; +} + +/** + * Attaches `laneGit` to every finished `execute_command` result, so a branch the + * agent created or switched is visible as soon as the command returns. Git is + * read once per command, never on a timer. The field is stripped by the worker + * unless Code API acknowledged the `lane_git` feature at registration. + */ +export class LaneGitWorkspaceTools implements WorkspaceToolExecutor { + readonly mutationFailuresAreAtomic?: true; + readonly capabilities: WorkspaceToolExecutor['capabilities']; + + constructor(private readonly options: LaneGitWorkspaceToolsOptions) { + this.mutationFailuresAreAtomic = options.delegate.mutationFailuresAreAtomic; + const base = options.delegate.capabilities; + this.capabilities = base.operations.includes('execute_command') + ? { ...base, commandResultFeatures: ['lane_git'] } + : base; + } + + async execute(request: WorkspaceToolRequest, signal?: AbortSignal): Promise { + const result = await this.options.delegate.execute(request, signal); + if (request.operation !== 'execute_command' || result.operation !== 'execute_command') return result; + try { + const root = await this.options.resolveRoot(request, signal); + const laneGit = root == null ? undefined : await readLaneGit(root, signal); + return laneGit ? { ...result, laneGit } : result; + } catch { + // Git state is advisory; a failed read must never fail or delay the command's own result. + return result; + } + } +} diff --git a/packages/code/src/projects.ts b/packages/code/src/projects.ts index 1e2fcbd3..e91694f3 100644 --- a/packages/code/src/projects.ts +++ b/packages/code/src/projects.ts @@ -4,6 +4,8 @@ import { lstat, opendir, realpath } from 'node:fs/promises'; import { isAbsolute, relative, resolve, sep } from 'node:path'; import { promisify } from 'node:util'; +import { boundedBranch, boundedHead } from './protocol.js'; + const exec = promisify(execFile); const skipped = new Set(['node_modules', 'vendor']); @@ -221,16 +223,8 @@ export async function discoverProjects( ); const path = rel.split(sep).join('/') || '.'; const normalizedRemote = remote ? projectRemote(remote) : null; - const validBranch = - branch && - branch.length <= 256 && - !/[\x00-\x1f\x7f]/.test(branch) - ? branch - : null; - const validHead = - head && /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(head) - ? head - : null; + const validBranch = boundedBranch(branch); + const validHead = boundedHead(head); if ( (remote !== null && normalizedRemote === null) || (branch !== null && validBranch === null) || diff --git a/packages/code/src/protocol.ts b/packages/code/src/protocol.ts index 216784a4..c3944f84 100644 --- a/packages/code/src/protocol.ts +++ b/packages/code/src/protocol.ts @@ -340,7 +340,48 @@ const WORKSPACE_EDIT_MATCH_STRATEGIES = new Set([ 'whitespace-normalized', 'indentation-flexible', ]); + +/** A branch name reportable to LibreChat: 1 to 256 chars, no control characters. */ +export function boundedBranch(value: unknown): string | null { + return typeof value === 'string' && + value.length > 0 && + value.length <= 256 && + !/[\x00-\x1f\x7f]/.test(value) + ? value + : null; +} + +/** A commit id reportable to LibreChat: 40 (SHA-1) or 64 (SHA-256) lowercase hex chars. */ +export function boundedHead(value: unknown): string | null { + return typeof value === 'string' && /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(value) + ? value + : null; +} + export type WorkspaceListFileFeature = 'after_path'; +/** `lane_git`: `execute_command` results may carry `laneGit`. Sent only when Code API echoes it. */ +export type WorkspaceCommandResultFeature = 'lane_git'; + +/** Git state of the lane a command ran in. Never carries paths, remotes or repository content. */ +export interface WorkspaceLaneGit { + /** Checked-out branch, or null when HEAD is detached or the name is not reportable. */ + branch: string | null; + /** HEAD commit (40 or 64 lowercase hex), or null before the first commit or when unreportable. */ + head: string | null; +} + +export function isWorkspaceLaneGit(value: unknown): value is WorkspaceLaneGit { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const state = value as Record; + const keys = Object.keys(state); + return ( + keys.length === 2 && + keys.includes('branch') && + keys.includes('head') && + (state.branch === null || (state.branch !== undefined && boundedBranch(state.branch) === state.branch)) && + (state.head === null || (state.head !== undefined && boundedHead(state.head) === state.head)) + ); +} export type WorkspaceProgrammaticLanguage = 'bash'; export interface BridgeWorkspaceDescriptor { @@ -374,6 +415,8 @@ export interface BridgeWorkspaceToolCapabilities { editFileFeatures?: WorkspaceEditFileFeature[]; /** Omitted by workers that cannot continue a bounded file listing. */ listFileFeatures?: WorkspaceListFileFeature[]; + /** Omitted by workers that do not report lane Git state on command results. */ + commandResultFeatures?: WorkspaceCommandResultFeature[]; /** Languages that can execute PTC replay inside a selected workspace. */ programmaticLanguages?: WorkspaceProgrammaticLanguage[]; } @@ -625,6 +668,8 @@ export interface WorkspaceExecuteCommandResult { stderr: string; truncated: boolean; timedOut: boolean; + /** Present only when `commandResultFeatures` negotiated `lane_git` and Git was readable. */ + laneGit?: WorkspaceLaneGit; } export type WorkspaceToolRequest = @@ -789,6 +834,7 @@ const WORKSPACE_COMMAND_RESULT_KEYS = new Set([ 'stderr', 'truncated', 'timedOut', + 'laneGit', ]); const WORKSPACE_SEARCH_MATCH_KEYS = new Set(['path', 'line', 'column', 'text']); @@ -836,6 +882,8 @@ export interface BridgeWorkerRegistrationResponse { supportedWorkspaceInstanceTypes?: ['git_worktree']; /** Scheduling scopes this Code API can admit as independent lanes. */ supportedWorkspaceScopes?: ['git_linked_worktree']; + /** Command result fields this Code API accepts in settlements. */ + supportedWorkspaceCommandResultFeatures?: WorkspaceCommandResultFeature[]; /** Added workspace tool error codes this Code API accepts in settlements. */ supportedWorkspaceToolErrorCodes?: WorkspaceToolErrorCode[]; } @@ -1717,6 +1765,7 @@ export function isWorkspaceToolResult( /^SIG[A-Z0-9]+$/.test(result.signal))) && typeof result.truncated === 'boolean' && typeof result.timedOut === 'boolean' && + (result.laneGit === undefined || isWorkspaceLaneGit(result.laneGit)) && (result.exitCode === null ? result.timedOut === true || result.signal !== undefined : result.timedOut === false && result.signal === undefined) @@ -1839,6 +1888,16 @@ export function isValidBridgeWorkspaceToolCapabilities( return false; } + if ( + capabilities.commandResultFeatures !== undefined && + (!Array.isArray(capabilities.commandResultFeatures) || + capabilities.commandResultFeatures.length !== 1 || + !capabilities.operations.includes('execute_command') || + capabilities.commandResultFeatures[0] !== 'lane_git') + ) { + return false; + } + if ( capabilities.programmaticLanguages !== undefined && (!Array.isArray(capabilities.programmaticLanguages) || diff --git a/packages/code/src/worker.ts b/packages/code/src/worker.ts index 17a9a6e1..ec467c97 100644 --- a/packages/code/src/worker.ts +++ b/packages/code/src/worker.ts @@ -218,6 +218,11 @@ function workspaceCapabilitiesMatch( (feature, index) => feature === executor.listFileFeatures?.[index], ) ?? executor.listFileFeatures == null) && + advertised.commandResultFeatures?.length === executor.commandResultFeatures?.length && + (advertised.commandResultFeatures?.every( + (feature, index) => feature === executor.commandResultFeatures?.[index], + ) ?? + executor.commandResultFeatures == null) && advertised.programmaticLanguages?.length === executor.programmaticLanguages?.length && (advertised.programmaticLanguages?.every( @@ -314,6 +319,7 @@ function registrationCompatibleCapabilities( editFileModes: _editFileModes, editFileFeatures: _editFileFeatures, listFileFeatures: _listFileFeatures, + commandResultFeatures: _commandResultFeatures, programmaticLanguages: _programmaticLanguages, ...compatibleWorkspaceTools } = workspaceTools; @@ -409,6 +415,9 @@ function supportedWorkspaceCapabilities( const listFileFeatures = desired.listFileFeatures?.filter((feature) => registration.supportedWorkspaceListFileFeatures?.includes(feature), ); + const commandResultFeatures = desired.commandResultFeatures?.filter((feature) => + registration.supportedWorkspaceCommandResultFeatures?.includes(feature), + ); const programmaticLanguages = desired.programmaticLanguages?.filter( (language) => registration.supportedWorkspaceProgrammaticLanguages?.includes(language), @@ -418,6 +427,7 @@ function supportedWorkspaceCapabilities( editFileModes: _editFileModes, editFileFeatures: _editFileFeatures, listFileFeatures: _listFileFeatures, + commandResultFeatures: _commandResultFeatures, programmaticLanguages: _programmaticLanguages, ...compatibleDesired } = desired; @@ -439,6 +449,9 @@ function supportedWorkspaceCapabilities( ...(operations.includes('list_files') && listFileFeatures?.length ? { listFileFeatures } : {}), + ...(operations.includes('execute_command') && commandResultFeatures?.length + ? { commandResultFeatures } + : {}), ...(operations.includes('execute_command') && programmaticLanguages?.length ? { programmaticLanguages } @@ -1864,6 +1877,14 @@ export class BridgeWorker { payload; payload = compatiblePayload; } + if ( + workspaceRequest.operation === 'execute_command' && + !advertised.commandResultFeatures?.includes('lane_git') && + 'laneGit' in payload + ) { + const { laneGit: _laneGit, ...compatiblePayload } = payload; + payload = compatiblePayload; + } workspaceMutationApplied = isMutation; if (isMutation && !isWorkspaceToolResult(workspaceRequest, payload)) { throw new BridgeProtocolError( diff --git a/packages/code/src/worktree-retirement.ts b/packages/code/src/worktree-retirement.ts index 39a7b60c..5254f4de 100644 --- a/packages/code/src/worktree-retirement.ts +++ b/packages/code/src/worktree-retirement.ts @@ -158,7 +158,7 @@ function gitEnvironment(): NodeJS.ProcessEnv { }; } -async function git( +export async function git( cwd: string, args: string[], signal?: AbortSignal, diff --git a/service/src/bridge/index.ts b/service/src/bridge/index.ts index b04de55f..f7ba03b4 100644 --- a/service/src/bridge/index.ts +++ b/service/src/bridge/index.ts @@ -10,6 +10,7 @@ export const bridgeStore = new RedisBridgeStore( undefined, undefined, env.BRIDGE_MAX_WORKSPACE_LEASE_SLOTS, + env.BRIDGE_LANE_GIT, ); export const bridgePairings = new RedisBridgePairingStore( connection, diff --git a/service/src/bridge/lane-git.test.ts b/service/src/bridge/lane-git.test.ts new file mode 100644 index 00000000..ebc41203 --- /dev/null +++ b/service/src/bridge/lane-git.test.ts @@ -0,0 +1,167 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import RedisMock from 'ioredis-mock'; + +import type Redis from 'ioredis'; + +import { BRIDGE_PROTOCOL_VERSION } from '../../../packages/code/src/protocol'; +import { laneGitPolicy } from './lane-git'; +import { RedisBridgeStore } from './store'; + +const redis = new RedisMock() as unknown as Redis; +const incarnationId = 'incarnation-00000001'; +const head = 'a'.repeat(40); + +afterEach(async () => { + await redis.flushall(); +}); + +function capabilities(advertise: boolean) { + return { + statefulWorkspace: true, + sandboxProfile: 'nsjail', + runtimes: ['bash'], + workspaceTools: { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operations: ['read_file' as const, 'execute_command' as const], + workspaces: [{ id: 'primary' }], + ...(advertise ? { commandResultFeatures: ['lane_git' as const] } : {}), + }, + }; +} + +const request = { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operation: 'execute_command' as const, + workspaceId: 'primary', + command: 'git checkout -b agent', +}; + +function commandResult(extra: Record = {}) { + return { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operation: 'execute_command' as const, + workspaceId: 'primary', + exitCode: 0, + stdout: '', + stderr: '', + truncated: false, + timedOut: false, + ...extra, + }; +} + +/** Registers a worker, runs one command through the store, and returns what the caller receives. */ +async function run(opts: { enabled: boolean; advertise: boolean; workerResult: Record }) { + const store = new RedisBridgeStore(redis, undefined, undefined, 1, opts.enabled); + await store.register({ + protocolVersion: BRIDGE_PROTOCOL_VERSION, + workerId: 'lane-worker', + incarnationId, + capabilities: capabilities(opts.advertise), + }); + const completion = store.dispatchWorkspaceTool({ + workerId: 'lane-worker', + tenantId: 'tenant-1', + request, + deadlineAtMs: Date.now() + 5_000, + signal: new AbortController().signal, + }); + const assignment = await store.lease('lane-worker', incarnationId, 1_000); + await store.settle('lane-worker', assignment?.assignmentId ?? '', { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + generation: assignment?.generation ?? 0, + leaseToken: assignment?.leaseToken ?? '', + incarnationId, + status: 'fulfilled', + result: opts.workerResult as never, + }); + // Read the durable copy before the dispatcher cleans it up; it must exist, or the + // "not stored" assertions below would pass vacuously. + const stored = await redis.get(`codeapi:bridge:v1:assignment:${assignment?.assignmentId}:settlement`); + expect(stored).not.toBeNull(); + const settlement = await completion; + return { settlement, stored: stored as string }; +} + +describe('laneGit on command settlements', () => { + test('a valid laneGit from an advertising worker reaches the caller unchanged', async () => { + const laneGit = { branch: 'librechat/conversation-abcd1234-0123', head }; + const { settlement } = await run({ enabled: true, advertise: true, workerResult: commandResult({ laneGit }) }); + expect(settlement.status).toBe('fulfilled'); + expect((settlement as { result: { laneGit?: unknown } }).result.laneGit).toEqual(laneGit); + }); + + test('null branch and head (detached, no commit) are valid and preserved', async () => { + const laneGit = { branch: null, head: null }; + const { settlement } = await run({ enabled: true, advertise: true, workerResult: commandResult({ laneGit }) }); + expect((settlement as { result: { laneGit?: unknown } }).result.laneGit).toEqual(laneGit); + }); + + test('a 64 character head is accepted', async () => { + const laneGit = { branch: 'main', head: 'b'.repeat(64) }; + const { settlement } = await run({ enabled: true, advertise: true, workerResult: commandResult({ laneGit }) }); + expect((settlement as { result: { laneGit?: unknown } }).result.laneGit).toEqual(laneGit); + }); + + const invalid: Array<[string, unknown]> = [ + ['extra keys', { branch: 'main', head, path: '/srv/repo' }], + ['an over-long branch', { branch: 'x'.repeat(257), head }], + ['an empty branch', { branch: '', head }], + ['a control character in the branch', { branch: 'bad\nname', head }], + ['uppercase hex', { branch: 'main', head: 'A'.repeat(40) }], + ['a short head', { branch: 'main', head: 'a'.repeat(39) }], + ['non-hex head', { branch: 'main', head: 'g'.repeat(40) }], + ['a missing head', { branch: 'main' }], + ['a non-object', 'main'], + ['an array', [null, null]], + ]; + for (const [name, laneGit] of invalid) { + test(`laneGit with ${name} is dropped and the command still completes`, async () => { + const { settlement, stored } = await run({ enabled: true, advertise: true, workerResult: commandResult({ laneGit }) }); + expect(settlement.status).toBe('fulfilled'); + const result = (settlement as unknown as { result: Record }).result; + expect('laneGit' in result).toBe(false); + expect(result.exitCode).toBe(0); + expect(stored).not.toContain('laneGit'); + expect(stored).not.toContain('/srv/repo'); + }); + } + + test('laneGit from a worker that did not advertise lane_git is stripped', async () => { + const { settlement, stored } = await run({ + enabled: true, + advertise: false, + workerResult: commandResult({ laneGit: { branch: 'main', head } }), + }); + expect(settlement.status).toBe('fulfilled'); + expect('laneGit' in (settlement as { result: object }).result).toBe(false); + expect(stored).not.toContain('laneGit'); + }); + + test('laneGit is stripped when the deployment setting is off, even from an advertising worker', async () => { + const { settlement, stored } = await run({ + enabled: false, + advertise: true, + workerResult: commandResult({ laneGit: { branch: 'main', head } }), + }); + expect(settlement.status).toBe('fulfilled'); + expect('laneGit' in (settlement as { result: object }).result).toBe(false); + expect(stored).not.toContain('laneGit'); + }); + + test('an old worker without the field still registers and runs commands, with the setting on or off', async () => { + for (const enabled of [false, true]) { + const { settlement } = await run({ enabled, advertise: false, workerResult: commandResult() }); + expect(settlement.status).toBe('fulfilled'); + expect((settlement as { result: { exitCode: number } }).result.exitCode).toBe(0); + await redis.flushall(); + } + }); + + test('the policy never reports the value, only a fixed reason', () => { + const result = commandResult({ laneGit: { branch: 'secret-branch', head, path: '/srv/repo' } }); + const outcome = laneGitPolicy({ result, enabled: true, advertised: true }); + expect(outcome.dropped).toBe('invalid'); + expect(JSON.stringify(outcome.dropped)).not.toContain('secret-branch'); + }); +}); diff --git a/service/src/bridge/lane-git.ts b/service/src/bridge/lane-git.ts new file mode 100644 index 00000000..69ddf7b9 --- /dev/null +++ b/service/src/bridge/lane-git.ts @@ -0,0 +1,36 @@ +import { isWorkspaceLaneGit } from '../../../packages/code/src/protocol'; + +export type LaneGitDropReason = 'disabled' | 'not_advertised' | 'invalid'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Whether a fulfilled command result carries a `laneGit` key at all. */ +export function hasLaneGit(result: unknown): boolean { + return isRecord(result) && 'laneGit' in result; +} + +/** + * Decide what happens to the `laneGit` on an execute_command result. It is kept, + * unchanged, only when the deployment enables the feature, the worker advertised + * `lane_git`, and the value is exactly `{ branch, head }` within bounds. Anything + * else is dropped; the command result itself is never failed over it. The reason + * is a fixed label, never the value. + */ +export function laneGitPolicy(args: { + result: Record; + enabled: boolean; + advertised: boolean; +}): { result: Record; dropped?: LaneGitDropReason } { + const reason: LaneGitDropReason | undefined = !args.enabled + ? 'disabled' + : !args.advertised + ? 'not_advertised' + : isWorkspaceLaneGit(args.result.laneGit) + ? undefined + : 'invalid'; + if (reason === undefined) return { result: args.result }; + const { laneGit: _laneGit, ...rest } = args.result; + return { result: rest, dropped: reason }; +} diff --git a/service/src/bridge/router.test.ts b/service/src/bridge/router.test.ts index 360414a0..5e76876e 100644 --- a/service/src/bridge/router.test.ts +++ b/service/src/bridge/router.test.ts @@ -167,6 +167,55 @@ describe('paired bridge HTTP API', () => { }); }); + for (const laneGit of [false, true]) { + test(`registration ${laneGit ? 'offers' : 'omits'} lane_git when the setting is ${laneGit ? 'on' : 'off'}`, async () => { + const app = express(); + app.use(json()); + app.use( + '/v1/bridge', + createBridgeRouter({ + store: new RedisBridgeStore(redis, undefined, undefined, 1, laneGit), + pairings: new RedisBridgePairingStore(redis), + authMode: 'static', + adminToken: 'strong-administrator-bootstrap-token', + configuredWorkerId: 'vm-1', + }), + ); + server = createServer(app); + await new Promise((resolve) => server?.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (address == null || typeof address === 'string') throw new Error('Expected TCP listener'); + const response = await fetch(`http://127.0.0.1:${address.port}/v1/bridge/workers/register`, { + method: 'POST', + headers: { + Authorization: 'Bearer strong-administrator-bootstrap-token', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + protocolVersion: BRIDGE_PROTOCOL_VERSION, + workerId: 'vm-1', + incarnationId: 'incarnation-00000001', + capabilities: { + statefulWorkspace: false, + sandboxProfile: 'nsjail', + runtimes: [], + workspaceTools: { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operations: ['read_file', 'execute_command'], + workspaces: [{ id: 'primary' }], + ...(laneGit ? { commandResultFeatures: ['lane_git'] } : {}), + }, + }, + }), + }); + expect(response.status).toBe(200); + const body = (await response.json()) as Record; + expect(body.supportedWorkspaceListFileFeatures).toEqual(['after_path']); + if (laneGit) expect(body.supportedWorkspaceCommandResultFeatures).toEqual(['lane_git']); + else expect('supportedWorkspaceCommandResultFeatures' in body).toBe(false); + }); + } + test('rejects a malformed optional binding for a configured worker', async () => { const app = express(); app.use(json()); diff --git a/service/src/bridge/router.ts b/service/src/bridge/router.ts index 0bc20a1e..123f8c75 100644 --- a/service/src/bridge/router.ts +++ b/service/src/bridge/router.ts @@ -538,6 +538,9 @@ router.post( supportedWorkspaceEditFileModes: ['single', 'batch'], supportedWorkspaceEditFileFeatures: [...WORKSPACE_EDIT_FILE_FEATURES], supportedWorkspaceListFileFeatures: ['after_path'], + ...(options.store.laneGitEnabled + ? { supportedWorkspaceCommandResultFeatures: ['lane_git'] } + : {}), supportedWorkspaceProgrammaticLanguages: ['bash'], supportedWorkspaceInstanceTypes: ['git_worktree'], supportedWorkspaceScopes: ['git_linked_worktree'], diff --git a/service/src/bridge/store.ts b/service/src/bridge/store.ts index 542c5903..57e2f9cd 100644 --- a/service/src/bridge/store.ts +++ b/service/src/bridge/store.ts @@ -21,6 +21,8 @@ import { workspaceIsolationParent, } from '../../../packages/code/src/protocol'; import type { BridgeWorkerBinding } from './pairing'; +import logger from '../logger'; +import { hasLaneGit, laneGitPolicy } from './lane-git'; import { BridgeAdmissionQueue, durableAdmissionFence } from './admission'; import { BridgeWorkspaceSlots } from './slots'; import type { StoredWorkspaceRequest } from '../workspace-tools/requests'; @@ -472,6 +474,8 @@ export class RedisBridgeStore { private readonly workerTtlSeconds = DEFAULT_WORKER_TTL_SECONDS, private readonly redisCommandTimeoutMs = DEFAULT_REDIS_COMMAND_TIMEOUT_MS, private readonly maxWorkspaceLeaseSlots = 1, + /** Accept `laneGit` on command results and offer `lane_git` at registration. Off by default. */ + public readonly laneGitEnabled = false, ) { if ( !Number.isSafeInteger(maxWorkspaceLeaseSlots) || @@ -1781,6 +1785,9 @@ export class RedisBridgeStore { identityId?: string, quarantineWorkspace = false, ): Promise { + if (!quarantineWorkspace) { + settlement = await this.sanitizeLaneGit(workerId, assignmentId, settlement, signal); + } if (quarantineWorkspace) { await this.quarantineSettledWorkspace( workerId, @@ -1964,6 +1971,49 @@ export class RedisBridgeStore { } } + /** + * Validate the optional `laneGit` of a fulfilled execute_command settlement before it + * is stored or forwarded. An invalid, unexpected or unadvertised value is dropped, never + * an error: the command's own result must still reach the caller. Settlements without + * the key are returned untouched, without extra reads. + */ + private async sanitizeLaneGit( + workerId: string, + assignmentId: string, + settlement: AnyCodeBridgeSettlement, + signal?: AbortSignal, + ): Promise { + if (settlement.status !== 'fulfilled' || !hasLaneGit(settlement.result)) return settlement; + const assignment = await this.leaseCommand( + this.readAssignment(assignmentId), + signal, + 'Bridge settlement assignment read', + ); + if ( + assignment == null || + assignment.workerId !== workerId || + assignment.executionKind !== 'workspace_tool' || + (assignment.request as WorkspaceToolRequest).operation !== 'execute_command' + ) { + return settlement; + } + const registration = await this.leaseCommand( + this.registration(workerId), + signal, + 'Bridge settlement registration read', + ); + const { result, dropped } = laneGitPolicy({ + result: settlement.result as unknown as Record, + enabled: this.laneGitEnabled, + advertised: + registration?.capabilities.workspaceTools?.commandResultFeatures?.includes('lane_git') === true, + }); + if (dropped === undefined) return settlement; + // Reason only: never the branch, the head or anything else from the result. + logger.debug('Dropped lane Git from a command settlement', { reason: dropped, assignmentId }); + return { ...settlement, result } as AnyCodeBridgeSettlement; + } + async cancelled( workerId: string, incarnationId: string, diff --git a/service/src/config.ts b/service/src/config.ts index 29ae854d..11d6ffae 100644 --- a/service/src/config.ts +++ b/service/src/config.ts @@ -420,6 +420,11 @@ export const env = { BRIDGE_MAX_WORKSPACE_LEASE_SLOTS: Number( process.env.CODEAPI_BRIDGE_MAX_WORKSPACE_LEASE_SLOTS ?? 1, ), + /** + * Offer `lane_git` to workers and accept `laneGit` (`{ branch, head }`) on command results. + * Off by default: enable only after the LibreChat talking to this service accepts the field. + */ + BRIDGE_LANE_GIT: process.env.CODEAPI_BRIDGE_LANE_GIT === 'true', /** Outbound worker selected by the remote-bridge backend. */ BRIDGE_WORKER_ID: process.env.CODEAPI_BRIDGE_WORKER_ID ?? '', /** Static compatibility auth or short-lived proof-of-possession credentials. */