diff --git a/src/server/lib/agentSession/__tests__/configSeeder.test.ts b/src/server/lib/agentSession/__tests__/configSeeder.test.ts index 9dd858a..f95f1a2 100644 --- a/src/server/lib/agentSession/__tests__/configSeeder.test.ts +++ b/src/server/lib/agentSession/__tests__/configSeeder.test.ts @@ -99,6 +99,32 @@ describe('configSeeder', () => { expect(script).toContain('git config --global --add safe.directory "/workspace"'); }); + it('marks every mounted repo as a safe git directory in multi-repo sessions', () => { + const script = generateRuntimeSeedScript({ + workspaceRepos: [ + { + repo: 'org/ui', + repoUrl: 'https://github.com/org/ui.git', + branch: 'feature/ui', + revision: null, + mountPath: '/workspace/repos/org/ui', + primary: true, + }, + { + repo: 'org/api', + repoUrl: 'https://github.com/org/api.git', + branch: 'feature/api', + revision: null, + mountPath: '/workspace/repos/org/api', + primary: false, + }, + ], + }); + + expect(script).toContain('git config --global --add safe.directory "/workspace/repos/org/ui"'); + expect(script).toContain('git config --global --add safe.directory "/workspace/repos/org/api"'); + }); + it('sets up pre-push branch protection hook', () => { const script = generateRuntimeSeedScript(baseOpts); expect(script).toContain('pre-push'); @@ -116,7 +142,7 @@ describe('configSeeder', () => { repoUrl: 'https://github.com/org/ui.git', branch: 'feature/ui', revision: 'abc123', - mountPath: '/workspace', + mountPath: '/workspace/repos/org/ui', primary: true, }, { @@ -133,6 +159,7 @@ describe('configSeeder', () => { expect(script).toContain('git clone --progress --depth 50 --branch "feature/ui" --single-branch'); expect(script).toContain('git clone --progress --depth 50 --branch "feature/api" --single-branch'); expect(script).toContain('"/workspace/repos/org"'); + expect(script).toContain('"/workspace/repos/org/ui"'); expect(script).toContain('"/workspace/repos/org/api"'); }); diff --git a/src/server/lib/agentSession/__tests__/podFactory.test.ts b/src/server/lib/agentSession/__tests__/podFactory.test.ts index 2ae6e0b..162e3b6 100644 --- a/src/server/lib/agentSession/__tests__/podFactory.test.ts +++ b/src/server/lib/agentSession/__tests__/podFactory.test.ts @@ -688,6 +688,7 @@ describe('podFactory', () => { { name: 'GIT_AUTHOR_EMAIL', value: 'sample-user@example.com' }, { name: 'GIT_COMMITTER_NAME', value: 'Sample User' }, { name: 'GIT_COMMITTER_EMAIL', value: 'sample-user@example.com' }, + { name: 'LIFECYCLE_SESSION_PRIMARY_REPO_PATH', value: '/workspace' }, ]) ); @@ -701,6 +702,51 @@ describe('podFactory', () => { ); }); + it('mounts shared git config into the editor without changing its home directory', () => { + const pod = buildSessionWorkspacePodSpec(baseOpts); + const editor = getContainer(pod, 'editor'); + + expect(editor.env).toEqual( + expect.arrayContaining([ + { name: 'HOME', value: '/home/coder' }, + { name: 'GIT_CONFIG_GLOBAL', value: '/home/coder/.lifecycle-session/.gitconfig' }, + { + name: 'GITHUB_TOKEN', + valueFrom: { + secretKeyRef: { + key: 'GITHUB_TOKEN', + name: 'agent-secret-abc123', + }, + }, + }, + { + name: 'GH_TOKEN', + valueFrom: { + secretKeyRef: { + key: 'GITHUB_TOKEN', + name: 'agent-secret-abc123', + }, + }, + }, + { name: 'GIT_AUTHOR_NAME', value: 'Sample User' }, + { name: 'GIT_AUTHOR_EMAIL', value: 'sample-user@example.com' }, + ]) + ); + + expect(editor.volumeMounts).toEqual( + expect.arrayContaining([ + { + name: 'editor-home', + mountPath: '/home/coder', + }, + { + name: SESSION_WORKSPACE_HOME_VOLUME_NAME, + mountPath: '/home/coder/.lifecycle-session', + }, + ]) + ); + }); + it('passes the session-pod MCP config secret into the workspace gateway sidecar', () => { const pod = buildSessionWorkspacePodSpec(baseOpts); const workspaceGateway = getContainer(pod, 'workspace-gateway'); @@ -759,7 +805,7 @@ describe('podFactory', () => { repoUrl: 'https://github.com/org/repo.git', branch: 'feature/test', revision: null, - mountPath: '/workspace', + mountPath: '/workspace/repos/org/repo', primary: true, }, { @@ -784,9 +830,15 @@ describe('podFactory', () => { }) ); expect(getInitContainer(pod, 'prepare-editor-workspace').command?.[2]).toContain('"name": "org/repo"'); + expect(getInitContainer(pod, 'prepare-editor-workspace').command?.[2]).toContain( + '"path": "/workspace/repos/org/repo"' + ); expect(getInitContainer(pod, 'prepare-editor-workspace').command?.[2]).toContain( '"path": "/workspace/repos/org/api"' ); + expect(getContainer(pod, 'workspace-gateway').env).toEqual( + expect.arrayContaining([{ name: 'LIFECYCLE_SESSION_PRIMARY_REPO_PATH', value: '/workspace/repos/org/repo' }]) + ); }); it('still prepares the editor workspace when workspace bootstrap is skipped', () => { diff --git a/src/server/lib/agentSession/__tests__/servicePlan.test.ts b/src/server/lib/agentSession/__tests__/servicePlan.test.ts index 95fb757..3c7e41a 100644 --- a/src/server/lib/agentSession/__tests__/servicePlan.test.ts +++ b/src/server/lib/agentSession/__tests__/servicePlan.test.ts @@ -15,10 +15,41 @@ */ import { buildCombinedInstallCommand, resolveAgentSessionServicePlan } from '../servicePlan'; -import { SESSION_WORKSPACE_ROOT } from '../workspace'; +import { SESSION_WORKSPACE_REPOS_ROOT, SESSION_WORKSPACE_ROOT } from '../workspace'; describe('servicePlan', () => { - it('rewrites secondary repo service config against the mounted workspace path', () => { + it('keeps the primary repo at the workspace root for single-repo sessions', () => { + const plan = resolveAgentSessionServicePlan( + { + repoUrl: 'https://github.com/example-org/api.git', + branch: 'feature/api', + }, + [ + { + name: 'api', + deployId: 1, + repo: 'example-org/api', + branch: 'feature/api', + devConfig: { + image: 'node:20', + command: 'pnpm dev', + installCommand: 'pnpm install', + }, + }, + ] + ); + + expect(plan.workspaceRepos).toEqual([ + expect.objectContaining({ + repo: 'example-org/api', + mountPath: SESSION_WORKSPACE_ROOT, + primary: true, + }), + ]); + expect(buildCombinedInstallCommand(plan.services)).toBe('pnpm install'); + }); + + it('rewrites multi-repo service config against sibling mounted workspace paths', () => { const plan = resolveAgentSessionServicePlan({}, [ { name: 'api', @@ -51,12 +82,12 @@ describe('servicePlan', () => { expect(plan.workspaceRepos).toEqual([ expect.objectContaining({ repo: 'example-org/api', - mountPath: SESSION_WORKSPACE_ROOT, + mountPath: `${SESSION_WORKSPACE_REPOS_ROOT}/example-org/api`, primary: true, }), expect.objectContaining({ repo: 'example-org/web', - mountPath: '/workspace/repos/example-org/web', + mountPath: `${SESSION_WORKSPACE_REPOS_ROOT}/example-org/web`, primary: false, }), ]); @@ -65,14 +96,14 @@ describe('servicePlan', () => { expect.arrayContaining([ expect.objectContaining({ name: 'web', - workspacePath: '/workspace/repos/example-org/web', - workDir: '/workspace/repos/example-org/web/apps/web', + workspacePath: `${SESSION_WORKSPACE_REPOS_ROOT}/example-org/web`, + workDir: `${SESSION_WORKSPACE_REPOS_ROOT}/example-org/web/apps/web`, devConfig: expect.objectContaining({ - workDir: '/workspace/repos/example-org/web/apps/web', - command: 'pnpm --dir /workspace/repos/example-org/web/apps/web dev', + workDir: `${SESSION_WORKSPACE_REPOS_ROOT}/example-org/web/apps/web`, + command: `pnpm --dir ${SESSION_WORKSPACE_REPOS_ROOT}/example-org/web/apps/web dev`, installCommand: 'pnpm install', env: { - CONFIG_PATH: '/workspace/repos/example-org/web/config', + CONFIG_PATH: `${SESSION_WORKSPACE_REPOS_ROOT}/example-org/web/config`, }, }), }), @@ -85,14 +116,14 @@ describe('servicePlan', () => { name: 'web', repo: 'example-org/web', branch: 'feature/web', - workspacePath: '/workspace/repos/example-org/web', - workDir: '/workspace/repos/example-org/web/apps/web', + workspacePath: `${SESSION_WORKSPACE_REPOS_ROOT}/example-org/web`, + workDir: `${SESSION_WORKSPACE_REPOS_ROOT}/example-org/web/apps/web`, }), ]) ); }); - it('builds repo-aware install commands without duplicating primary repo cd steps', () => { + it('builds repo-aware install commands for every repo in multi-repo sessions', () => { const plan = resolveAgentSessionServicePlan({}, [ { name: 'api', @@ -119,7 +150,7 @@ describe('servicePlan', () => { ]); expect(buildCombinedInstallCommand(plan.services)).toBe( - 'pnpm install\n\ncd "/workspace/repos/example-org/web"\npnpm install' + `cd "${SESSION_WORKSPACE_REPOS_ROOT}/example-org/api"\npnpm install\n\ncd "${SESSION_WORKSPACE_REPOS_ROOT}/example-org/web"\npnpm install` ); }); diff --git a/src/server/lib/agentSession/__tests__/workspace.test.ts b/src/server/lib/agentSession/__tests__/workspace.test.ts new file mode 100644 index 0000000..04c11e8 --- /dev/null +++ b/src/server/lib/agentSession/__tests__/workspace.test.ts @@ -0,0 +1,82 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + buildSessionWorkspaceEditorContents, + buildSessionWorkspaceRepoMountPath, + normalizeSessionWorkspaceRepo, + SESSION_WORKSPACE_REPOS_ROOT, + SESSION_WORKSPACE_ROOT, +} from '../workspace'; + +describe('workspace', () => { + it('uses /workspace for the primary repo in single-repo sessions', () => { + expect(buildSessionWorkspaceRepoMountPath('example-org/api', true)).toBe(SESSION_WORKSPACE_ROOT); + }); + + it('uses sibling repo mounts for the primary repo in multi-repo sessions', () => { + expect( + buildSessionWorkspaceRepoMountPath('example-org/api', true, { + useWorkspaceRootForPrimary: false, + }) + ).toBe(`${SESSION_WORKSPACE_REPOS_ROOT}/example-org/api`); + }); + + it('normalizes multi-repo primary paths consistently', () => { + expect( + normalizeSessionWorkspaceRepo( + { + repo: 'example-org/api', + repoUrl: 'https://github.com/example-org/api.git', + branch: 'feature/api', + revision: null, + }, + true, + { useWorkspaceRootForPrimary: false } + ) + ).toEqual({ + repo: 'example-org/api', + repoUrl: 'https://github.com/example-org/api.git', + branch: 'feature/api', + revision: null, + mountPath: `${SESSION_WORKSPACE_REPOS_ROOT}/example-org/api`, + primary: true, + }); + }); + + it('writes editor workspace folders for sibling repo mounts', () => { + expect( + buildSessionWorkspaceEditorContents([ + { + repo: 'example-org/api', + repoUrl: 'https://github.com/example-org/api.git', + branch: 'feature/api', + revision: null, + mountPath: `${SESSION_WORKSPACE_REPOS_ROOT}/example-org/api`, + primary: true, + }, + { + repo: 'example-org/web', + repoUrl: 'https://github.com/example-org/web.git', + branch: 'feature/web', + revision: null, + mountPath: `${SESSION_WORKSPACE_REPOS_ROOT}/example-org/web`, + primary: false, + }, + ]) + ).toContain(`"path": "${SESSION_WORKSPACE_REPOS_ROOT}/example-org/api"`); + }); +}); diff --git a/src/server/lib/agentSession/__tests__/workspaceEditorProxy.test.ts b/src/server/lib/agentSession/__tests__/workspaceEditorProxy.test.ts new file mode 100644 index 0000000..8393af3 --- /dev/null +++ b/src/server/lib/agentSession/__tests__/workspaceEditorProxy.test.ts @@ -0,0 +1,99 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { buildWorkspaceEditorProxyHeaders, serializeSocketHttpResponse } from '../workspaceEditorProxy'; + +describe('workspaceEditorProxy', () => { + it('drops hop-by-hop headers for plain HTTP proxy requests', () => { + expect( + buildWorkspaceEditorProxyHeaders({ + requestHeaders: { + host: 'localhost:5001', + connection: 'Upgrade', + upgrade: 'websocket', + origin: 'http://localhost:5001', + cookie: 'sample=1', + }, + targetHost: 'agent.sample.svc.cluster.local:13337', + forwardedHost: 'localhost:5001', + forwardedProto: 'http', + forwardedPrefix: '/api/agent-session/workspace-editor/sample', + remoteAddress: '127.0.0.1', + }) + ).toEqual({ + cookie: 'sample=1', + origin: 'http://localhost:5001', + host: 'agent.sample.svc.cluster.local:13337', + 'x-forwarded-host': 'localhost:5001', + 'x-forwarded-proto': 'http', + 'x-forwarded-prefix': '/api/agent-session/workspace-editor/sample', + 'x-forwarded-for': '127.0.0.1', + }); + }); + + it('preserves websocket upgrade headers for raw upgrade proxying', () => { + expect( + buildWorkspaceEditorProxyHeaders({ + requestHeaders: { + host: 'localhost:5001', + connection: 'Upgrade', + upgrade: 'websocket', + 'sec-websocket-key': 'sample-key', + 'sec-websocket-version': '13', + }, + targetHost: 'agent.sample.svc.cluster.local:13337', + forwardedHost: 'localhost:5001', + forwardedProto: 'http', + forwardedPrefix: '/api/agent-session/workspace-editor/sample', + includeUpgradeHeaders: true, + }) + ).toEqual( + expect.objectContaining({ + connection: 'Upgrade', + upgrade: 'websocket', + 'sec-websocket-key': 'sample-key', + 'sec-websocket-version': '13', + host: 'agent.sample.svc.cluster.local:13337', + }) + ); + }); + + it('serializes upgrade responses with repeated headers', () => { + expect( + serializeSocketHttpResponse({ + statusCode: 101, + statusMessage: 'Switching Protocols', + headers: { + Upgrade: 'websocket', + Connection: 'Upgrade', + 'Set-Cookie': ['a=1', 'b=2'], + }, + }).toString('utf8') + ).toBe( + 'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSet-Cookie: a=1\r\nSet-Cookie: b=2\r\n\r\n' + ); + }); + + it('adds content-length for non-empty error responses', () => { + expect( + serializeSocketHttpResponse({ + statusCode: 502, + statusMessage: 'Bad Gateway', + body: 'editor unavailable', + }).toString('utf8') + ).toBe('HTTP/1.1 502 Bad Gateway\r\nContent-Length: 18\r\n\r\neditor unavailable'); + }); +}); diff --git a/src/server/lib/agentSession/podFactory.ts b/src/server/lib/agentSession/podFactory.ts index a9abcdd..edfdbf9 100644 --- a/src/server/lib/agentSession/podFactory.ts +++ b/src/server/lib/agentSession/podFactory.ts @@ -45,6 +45,8 @@ export const SESSION_WORKSPACE_GATEWAY_PORT_NAME = 'ws-gateway'; export const SESSION_WORKSPACE_EDITOR_PORT = parseInt(process.env.AGENT_SESSION_WORKSPACE_EDITOR_PORT || '13337', 10); export const SESSION_WORKSPACE_GATEWAY_PORT = parseInt(process.env.AGENT_SESSION_WORKSPACE_GATEWAY_PORT || '13338', 10); const SESSION_WORKSPACE_VOLUME_ROOT = '/workspace-volume'; +const SESSION_WORKSPACE_EDITOR_SHARED_SESSION_HOME_DIR = '/home/coder/.lifecycle-session'; +const SESSION_WORKSPACE_EDITOR_GIT_CONFIG_PATH = `${SESSION_WORKSPACE_EDITOR_SHARED_SESSION_HOME_DIR}/.gitconfig`; function sleep(ms: number): Promise { return new Promise((resolve) => { @@ -406,6 +408,7 @@ export function buildSessionWorkspacePodSpec(opts: SessionWorkspacePodOptions): opts.forwardedAgentSecretServiceName || podName, apiKeySecretName ); + const primaryWorkspaceRepo = editorWorkspaceRepos.find((repo) => repo.primary) || editorWorkspaceRepos[0]; const sessionPodMcpConfigEnv: k8s.V1EnvVar = { name: SESSION_POD_MCP_CONFIG_ENV, valueFrom: { @@ -617,9 +620,12 @@ export function buildSessionWorkspacePodSpec(opts: SessionWorkspacePodOptions): securityContext, env: [ { name: 'HOME', value: '/home/coder' }, + { name: 'GIT_CONFIG_GLOBAL', value: SESSION_WORKSPACE_EDITOR_GIT_CONFIG_PATH }, { name: 'TMPDIR', value: '/tmp' }, { name: 'TMP', value: '/tmp' }, { name: 'TEMP', value: '/tmp' }, + ...githubTokenEnv, + ...userEnv, ], readinessProbe: { httpGet: { @@ -635,6 +641,10 @@ export function buildSessionWorkspacePodSpec(opts: SessionWorkspacePodOptions): name: 'editor-home', mountPath: '/home/coder', }, + { + name: SESSION_WORKSPACE_HOME_VOLUME_NAME, + mountPath: SESSION_WORKSPACE_EDITOR_SHARED_SESSION_HOME_DIR, + }, { name: 'tmp', mountPath: '/tmp', @@ -652,6 +662,7 @@ export function buildSessionWorkspacePodSpec(opts: SessionWorkspacePodOptions): env: [ { name: 'LIFECYCLE_SESSION_WORKSPACE', value: workspacePath }, { name: 'LIFECYCLE_SESSION_HOME', value: SESSION_WORKSPACE_SHARED_HOME_DIR }, + { name: 'LIFECYCLE_SESSION_PRIMARY_REPO_PATH', value: primaryWorkspaceRepo?.mountPath || workspacePath }, { name: 'MCP_PORT', value: String(SESSION_WORKSPACE_GATEWAY_PORT) }, { name: 'HOME', value: SESSION_WORKSPACE_SHARED_HOME_DIR }, { name: 'TMPDIR', value: '/tmp' }, diff --git a/src/server/lib/agentSession/servicePlan.ts b/src/server/lib/agentSession/servicePlan.ts index 0b640e1..fb6a334 100644 --- a/src/server/lib/agentSession/servicePlan.ts +++ b/src/server/lib/agentSession/servicePlan.ts @@ -178,10 +178,13 @@ export function resolveAgentSessionWorkspaceRepos( } const resolvedPrimaryKey = primaryKey || orderedKeys[0]; + const useWorkspaceRootForPrimary = orderedKeys.length === 1; return orderedKeys.map((key) => { const repo = reposByKey.get(key)!; - return normalizeSessionWorkspaceRepo(repo, key === resolvedPrimaryKey); + return normalizeSessionWorkspaceRepo(repo, key === resolvedPrimaryKey, { + useWorkspaceRootForPrimary, + }); }); } diff --git a/src/server/lib/agentSession/workspace.ts b/src/server/lib/agentSession/workspace.ts index 790eb19..30f7cb6 100644 --- a/src/server/lib/agentSession/workspace.ts +++ b/src/server/lib/agentSession/workspace.ts @@ -19,7 +19,7 @@ import { posix as pathPosix } from 'path'; export const SESSION_WORKSPACE_ROOT = '/workspace'; export const SESSION_WORKSPACE_SUBPATH = 'repo'; export const SESSION_WORKSPACE_EDITOR_PROJECT_FILE = '/tmp/agent-session.code-workspace'; -const SESSION_WORKSPACE_ADDITIONAL_REPOS_ROOT = `${SESSION_WORKSPACE_ROOT}/repos`; +export const SESSION_WORKSPACE_REPOS_ROOT = `${SESSION_WORKSPACE_ROOT}/repos`; export interface AgentSessionWorkspaceRepo { repo: string; @@ -59,25 +59,30 @@ export function repoNameFromRepoUrl(repoUrl?: string | null): string | null { return normalized || null; } -export function buildSessionWorkspaceRepoMountPath(repo: string, primary = false): string { - if (primary) { +export function buildSessionWorkspaceRepoMountPath( + repo: string, + primary = false, + opts?: { useWorkspaceRootForPrimary?: boolean } +): string { + if (primary && opts?.useWorkspaceRootForPrimary !== false) { return SESSION_WORKSPACE_ROOT; } const { owner, name } = splitRepoFullName(repo); - return pathPosix.join(SESSION_WORKSPACE_ADDITIONAL_REPOS_ROOT, owner, name); + return pathPosix.join(SESSION_WORKSPACE_REPOS_ROOT, owner, name); } export function normalizeSessionWorkspaceRepo( repo: Pick, - primary = false + primary = false, + opts?: { useWorkspaceRootForPrimary?: boolean } ): AgentSessionWorkspaceRepo { return { repo: repo.repo, repoUrl: repo.repoUrl, branch: repo.branch, revision: repo.revision || null, - mountPath: buildSessionWorkspaceRepoMountPath(repo.repo, primary), + mountPath: buildSessionWorkspaceRepoMountPath(repo.repo, primary, opts), primary, }; } diff --git a/src/server/lib/agentSession/workspaceEditorProxy.ts b/src/server/lib/agentSession/workspaceEditorProxy.ts new file mode 100644 index 0000000..bdb9cc2 --- /dev/null +++ b/src/server/lib/agentSession/workspaceEditorProxy.ts @@ -0,0 +1,141 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { IncomingHttpHeaders, STATUS_CODES } from 'http'; + +const HOP_BY_HOP_HEADERS = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]); + +export interface BuildWorkspaceEditorProxyHeadersOpts { + requestHeaders: IncomingHttpHeaders; + targetHost: string; + forwardedHost: string; + forwardedProto: string; + forwardedPrefix: string; + remoteAddress?: string | null; + includeUpgradeHeaders?: boolean; +} + +function shouldForwardHeader(headerName: string, includeUpgradeHeaders: boolean): boolean { + if (headerName === 'host' || headerName === 'content-length') { + return false; + } + + if (!HOP_BY_HOP_HEADERS.has(headerName)) { + return true; + } + + if (!includeUpgradeHeaders) { + return false; + } + + return headerName === 'connection' || headerName === 'upgrade'; +} + +export function buildWorkspaceEditorProxyHeaders(opts: BuildWorkspaceEditorProxyHeadersOpts): Record { + const { + requestHeaders, + targetHost, + forwardedHost, + forwardedProto, + forwardedPrefix, + remoteAddress, + includeUpgradeHeaders = false, + } = opts; + + const headers: Record = {}; + + for (const [key, value] of Object.entries(requestHeaders)) { + if (value == null) { + continue; + } + + const normalizedKey = key.toLowerCase(); + if (!shouldForwardHeader(normalizedKey, includeUpgradeHeaders)) { + continue; + } + + headers[key] = Array.isArray(value) ? value.join(', ') : value; + } + + headers.host = targetHost; + headers['x-forwarded-host'] = forwardedHost || targetHost; + headers['x-forwarded-proto'] = forwardedProto; + headers['x-forwarded-prefix'] = forwardedPrefix; + + if (remoteAddress) { + headers['x-forwarded-for'] = requestHeaders['x-forwarded-for'] + ? `${requestHeaders['x-forwarded-for']}, ${remoteAddress}` + : remoteAddress; + } + + if (includeUpgradeHeaders) { + headers.connection = headers.connection || 'Upgrade'; + headers.upgrade = headers.upgrade || 'websocket'; + } + + return headers; +} + +export interface SerializeSocketHttpResponseOpts { + statusCode: number; + statusMessage?: string | null; + headers?: IncomingHttpHeaders; + body?: Buffer | string | null; +} + +export function serializeSocketHttpResponse(opts: SerializeSocketHttpResponseOpts): Buffer { + const { statusCode, statusMessage, headers = {}, body } = opts; + const bodyBuffer = + typeof body === 'string' ? Buffer.from(body, 'utf8') : Buffer.isBuffer(body) ? body : Buffer.alloc(0); + + const lines = [`HTTP/1.1 ${statusCode} ${statusMessage || STATUS_CODES[statusCode] || 'Unknown'}`]; + let hasContentLength = false; + + for (const [key, value] of Object.entries(headers)) { + if (value == null) { + continue; + } + + if (key.toLowerCase() === 'content-length') { + hasContentLength = true; + } + + if (Array.isArray(value)) { + for (const entry of value) { + lines.push(`${key}: ${entry}`); + } + continue; + } + + lines.push(`${key}: ${value}`); + } + + if (bodyBuffer.length > 0 && !hasContentLength) { + lines.push(`Content-Length: ${bodyBuffer.length}`); + } + + const headBuffer = Buffer.from(`${lines.join('\r\n')}\r\n\r\n`, 'utf8'); + return bodyBuffer.length > 0 ? Buffer.concat([headBuffer, bodyBuffer]) : headBuffer; +} diff --git a/src/server/services/__tests__/agentSession.test.ts b/src/server/services/__tests__/agentSession.test.ts index 54a563d..2f20ca0 100644 --- a/src/server/services/__tests__/agentSession.test.ts +++ b/src/server/services/__tests__/agentSession.test.ts @@ -1001,7 +1001,7 @@ describe('AgentSessionService', () => { expect.objectContaining({ repo: 'example-org/ui-service', branch: 'feature/ui-service', - mountPath: '/workspace', + mountPath: '/workspace/repos/example-org/ui-service', primary: true, }), expect.objectContaining({ @@ -1011,14 +1011,15 @@ describe('AgentSessionService', () => { primary: false, }), ], - installCommand: 'pnpm install\n\ncd "/workspace/repos/example-org/api-service"\npnpm install', + installCommand: + 'cd "/workspace/repos/example-org/ui-service"\npnpm install\n\ncd "/workspace/repos/example-org/api-service"\npnpm install', }) ); expect(mockEnableDevMode).toHaveBeenNthCalledWith( 1, expect.objectContaining({ devConfig: expect.objectContaining({ - workDir: '/workspace/apps/web', + workDir: '/workspace/repos/example-org/ui-service/apps/web', }), }) ); @@ -1041,8 +1042,8 @@ describe('AgentSessionService', () => { name: 'web', repo: 'example-org/ui-service', branch: 'feature/ui-service', - workspacePath: '/workspace', - workDir: '/workspace/apps/web', + workspacePath: '/workspace/repos/example-org/ui-service', + workDir: '/workspace/repos/example-org/ui-service/apps/web', }), expect.objectContaining({ name: 'api', diff --git a/sysops/workspace-gateway/index.mjs b/sysops/workspace-gateway/index.mjs index 4190abd..e172362 100644 --- a/sysops/workspace-gateway/index.mjs +++ b/sysops/workspace-gateway/index.mjs @@ -17,6 +17,9 @@ const execFile = promisify(execFileCallback); const WORKSPACE_ROOT = resolve( process.env.LIFECYCLE_SESSION_WORKSPACE || '/workspace' ); +const PRIMARY_GIT_ROOT = resolve( + process.env.LIFECYCLE_SESSION_PRIMARY_REPO_PATH || WORKSPACE_ROOT +); const HOST = process.env.MCP_HOST || '0.0.0.0'; const PORT = Number.parseInt(process.env.MCP_PORT || process.env.PORT || '3000', 10); const MAX_READ_CHARS = parsePositiveInt(process.env.LIFECYCLE_SANDBOX_MAX_READ_CHARS, 24_000); @@ -318,6 +321,11 @@ function isWithinWorkspace(candidate) { return normalized === WORKSPACE_ROOT || normalized.startsWith(`${WORKSPACE_ROOT}${sep}`); } +function isWithinPrimaryGitRoot(candidate) { + const normalized = resolve(candidate); + return normalized === PRIMARY_GIT_ROOT || normalized.startsWith(`${PRIMARY_GIT_ROOT}${sep}`); +} + function resolveWorkspacePath(inputPath) { const resolved = inputPath.startsWith('/') ? resolve(inputPath) : resolve(WORKSPACE_ROOT, inputPath); if (!isWithinWorkspace(resolved)) { @@ -335,6 +343,20 @@ function toPosixPath(inputPath) { return inputPath.split(sep).join('/'); } +function normalizeGitPathArg(inputPath) { + if (!inputPath || !inputPath.trim()) { + return ''; + } + + const resolved = resolveWorkspacePath(inputPath); + if (!isWithinPrimaryGitRoot(resolved)) { + throw new Error(`Path must stay within ${toWorkspaceRelativePath(PRIMARY_GIT_ROOT)}`); + } + + const rel = relative(PRIMARY_GIT_ROOT, resolved); + return rel ? toPosixPath(rel) : '.'; +} + function isReservedWorkspacePath(filePath) { const normalized = toWorkspaceRelativePath(resolveWorkspacePath(filePath)); return RESERVED_WORKSPACE_PREFIXES.some( @@ -638,7 +660,7 @@ async function grepWorkspace({ pattern, path = '.', caseSensitive = true, maxRes } async function summarizeGitState() { - const gitDir = resolve(WORKSPACE_ROOT, '.git'); + const gitDir = resolve(PRIMARY_GIT_ROOT, '.git'); if (!(await fileExists(gitDir))) { return { present: false }; } @@ -669,6 +691,14 @@ async function summarizeGitState() { } } +async function runPrimaryGitCommand({ command, timeoutMs = 30000 }) { + return runWorkspaceCommand({ + command, + cwd: PRIMARY_GIT_ROOT, + timeoutMs, + }); +} + async function readOptionalText(filePath) { try { return await readFile(filePath, 'utf8'); @@ -1151,7 +1181,7 @@ function buildServer() { try { return textResult({ ok: true, - ...(await runWorkspaceCommand({ command: 'git status --short --branch' })), + ...(await runPrimaryGitCommand({ command: 'git status --short --branch' })), }); } catch (error) { return errorText('Unable to read git status', error instanceof Error ? error.message : String(error)); @@ -1172,12 +1202,13 @@ function buildServer() { }, async ({ staged, path }) => { try { + const normalizedPath = normalizeGitPathArg(path || ''); const command = staged - ? `git diff --cached -- ${path || ''}`.trim() - : `git diff -- ${path || ''}`.trim(); + ? `git diff --cached -- ${normalizedPath}`.trim() + : `git diff -- ${normalizedPath}`.trim(); return textResult({ ok: true, - ...(await runWorkspaceCommand({ command })), + ...(await runPrimaryGitCommand({ command })), }); } catch (error) { return errorText('Unable to read git diff', error instanceof Error ? error.message : String(error)); @@ -1197,10 +1228,10 @@ function buildServer() { }, async ({ paths }) => { try { - const command = `git add -- ${paths.map(quoteShellSingle).join(' ')}`; + const command = `git add -- ${paths.map((path) => quoteShellSingle(normalizeGitPathArg(path))).join(' ')}`; return textResult({ ok: true, - ...(await runWorkspaceCommand({ command })), + ...(await runPrimaryGitCommand({ command })), }); } catch (error) { return errorText('Unable to stage paths', error instanceof Error ? error.message : String(error)); @@ -1222,7 +1253,7 @@ function buildServer() { try { return textResult({ ok: true, - ...(await runWorkspaceCommand({ command: `git commit -m ${quoteShellSingle(message)}` })), + ...(await runPrimaryGitCommand({ command: `git commit -m ${quoteShellSingle(message)}` })), }); } catch (error) { return errorText('Unable to create commit', error instanceof Error ? error.message : String(error)); @@ -1247,7 +1278,7 @@ function buildServer() { if (!name) { return textResult({ ok: true, - ...(await runWorkspaceCommand({ command: 'git branch --list' })), + ...(await runPrimaryGitCommand({ command: 'git branch --list' })), }); } @@ -1261,7 +1292,7 @@ function buildServer() { return textResult({ ok: true, - ...(await runWorkspaceCommand({ command })), + ...(await runPrimaryGitCommand({ command })), }); } catch (error) { return errorText('Unable to manage git branch', error instanceof Error ? error.message : String(error)); diff --git a/ws-server.ts b/ws-server.ts index 457aac0..8265a18 100644 --- a/ws-server.ts +++ b/ws-server.ts @@ -28,11 +28,16 @@ moduleAlias.addAliases({ }); import { createServer, IncomingMessage, ServerResponse, request as httpRequest } from 'http'; +import type { Socket } from 'net'; import { parse, URL } from 'url'; import next from 'next'; import { WebSocketServer, WebSocket } from 'ws'; import { rootLogger } from './src/server/lib/logger'; import { streamK8sLogs, AbortHandle } from './src/server/lib/k8sStreamer'; +import { + buildWorkspaceEditorProxyHeaders, + serializeSocketHttpResponse, +} from './src/server/lib/agentSession/workspaceEditorProxy'; const dev = process.env.NODE_ENV !== 'production'; const hostname = process.env.HOSTNAME || 'localhost'; @@ -46,10 +51,6 @@ const LOG_STREAM_PATH = '/api/logs/stream'; // Path for WebSocket connections const SESSION_WORKSPACE_EDITOR_PATH_PREFIX = '/api/agent-session/workspace-editor/'; const SESSION_WORKSPACE_EDITOR_COOKIE_NAME = 'lfc_session_workspace_editor_auth'; const SESSION_WORKSPACE_EDITOR_PORT = parseInt(process.env.AGENT_SESSION_WORKSPACE_EDITOR_PORT || '13337', 10); -const SESSION_WORKSPACE_EDITOR_HEARTBEAT_INTERVAL_MS = parseInt( - process.env.AGENT_SESSION_WORKSPACE_EDITOR_HEARTBEAT_INTERVAL_MS || '15000', - 10 -); const logger = rootLogger.child({ filename: __filename }); const HOP_BY_HOP_HEADERS = new Set([ 'connection', @@ -61,7 +62,6 @@ const HOP_BY_HOP_HEADERS = new Set([ 'transfer-encoding', 'upgrade', ]); - function parseCookieHeader(cookieHeader: string | string[] | undefined): Record { if (!cookieHeader) { return {}; @@ -182,36 +182,189 @@ function buildSessionWorkspaceEditorServiceUrl( } function buildProxyHeaders(request: IncomingMessage, target: URL, forwardedPrefix: string): Record { - const headers: Record = {}; + return buildWorkspaceEditorProxyHeaders({ + requestHeaders: request.headers, + targetHost: target.host, + forwardedHost: request.headers.host || target.host, + forwardedProto: + (typeof request.headers['x-forwarded-proto'] === 'string' && request.headers['x-forwarded-proto']) || + ((request.socket as { encrypted?: boolean }).encrypted ? 'https' : 'http'), + forwardedPrefix, + remoteAddress: request.socket.remoteAddress, + }); +} - for (const [key, value] of Object.entries(request.headers)) { - if (value == null) { - continue; - } +function resolveSessionWorkspaceEditorErrorStatus(error: unknown): number { + const message = error instanceof Error ? error.message : String(error); - const normalizedKey = key.toLowerCase(); - if (HOP_BY_HOP_HEADERS.has(normalizedKey) || normalizedKey === 'host' || normalizedKey === 'content-length') { - continue; - } + if (message === 'Authentication token is required') { + return 401; + } - headers[key] = Array.isArray(value) ? value.join(', ') : value; + if (message === 'Forbidden: you do not own this session') { + return 403; } - headers.host = target.host; - headers['x-forwarded-host'] = request.headers.host || target.host; - headers['x-forwarded-proto'] = - (typeof request.headers['x-forwarded-proto'] === 'string' && request.headers['x-forwarded-proto']) || - ((request.socket as { encrypted?: boolean }).encrypted ? 'https' : 'http'); - headers['x-forwarded-prefix'] = forwardedPrefix; - - const remoteAddress = request.socket.remoteAddress; - if (remoteAddress) { - headers['x-forwarded-for'] = request.headers['x-forwarded-for'] - ? `${request.headers['x-forwarded-for']}, ${remoteAddress}` - : remoteAddress; + if (message === 'Session not found or not active') { + return 404; } - return headers; + return 502; +} + +async function handleSessionWorkspaceEditorUpgrade(request: IncomingMessage, socket: Socket, head: Buffer) { + const parsedUrl = parse(request.url || '', true); + const match = parseSessionWorkspaceEditorPath(parsedUrl.pathname); + const editorLogCtx: Record = { + remoteAddress: request.socket.remoteAddress, + path: parsedUrl.pathname, + }; + + if (!match) { + socket.end( + serializeSocketHttpResponse({ statusCode: 400, statusMessage: 'Bad Request', body: 'Invalid editor path' }) + ); + return; + } + + let upstreamSocket: Socket | null = null; + let proxyReq: ReturnType | null = null; + + try { + const queryToken = typeof parsedUrl.query.token === 'string' ? parsedUrl.query.token : null; + const session = await resolveOwnedAgentSession(request, match.sessionId, queryToken); + const forwardedPrefix = getSessionWorkspaceEditorCookiePath(match.sessionId); + const targetUrl = buildSessionWorkspaceEditorServiceUrl( + session, + match.forwardPath, + parsedUrl.query as Record + ); + const proxyHeaders = buildWorkspaceEditorProxyHeaders({ + requestHeaders: request.headers, + targetHost: targetUrl.host, + forwardedHost: request.headers.host || targetUrl.host, + forwardedProto: + (typeof request.headers['x-forwarded-proto'] === 'string' && request.headers['x-forwarded-proto']) || + ((request.socket as { encrypted?: boolean }).encrypted ? 'https' : 'http'), + forwardedPrefix, + remoteAddress: request.socket.remoteAddress, + includeUpgradeHeaders: true, + }); + + await new Promise((resolve, reject) => { + proxyReq = httpRequest(targetUrl, { + method: request.method || 'GET', + headers: proxyHeaders, + }); + + proxyReq.on('upgrade', (upstreamRes, proxiedSocket, upstreamHead) => { + upstreamSocket = proxiedSocket as Socket; + + socket.write( + serializeSocketHttpResponse({ + statusCode: upstreamRes.statusCode || 101, + statusMessage: upstreamRes.statusMessage, + headers: upstreamRes.headers, + }) + ); + + if (upstreamHead.length > 0) { + socket.write(upstreamHead); + } + + if (head.length > 0) { + upstreamSocket.write(head); + } + + socket.on('error', (error) => { + logger.warn( + { ...editorLogCtx, error }, + `SessionEditor: socket error source=client sessionId=${match.sessionId}` + ); + if (upstreamSocket && !upstreamSocket.destroyed) { + upstreamSocket.destroy(error as Error); + } + }); + + upstreamSocket.on('error', (error) => { + logger.warn( + { ...editorLogCtx, error }, + `SessionEditor: socket error source=upstream sessionId=${match.sessionId}` + ); + if (!socket.destroyed) { + socket.destroy(error as Error); + } + }); + + socket.on('close', () => { + if (upstreamSocket && !upstreamSocket.destroyed) { + upstreamSocket.end(); + } + }); + + upstreamSocket.on('close', () => { + if (!socket.destroyed) { + socket.end(); + } + }); + + socket.pipe(upstreamSocket); + upstreamSocket.pipe(socket); + socket.resume(); + upstreamSocket.resume(); + resolve(); + }); + + proxyReq.on('response', (upstreamRes) => { + const chunks: Buffer[] = []; + + upstreamRes.on('data', (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + + upstreamRes.on('end', () => { + if (!socket.destroyed) { + socket.end( + serializeSocketHttpResponse({ + statusCode: upstreamRes.statusCode || 502, + statusMessage: upstreamRes.statusMessage, + headers: upstreamRes.headers, + body: Buffer.concat(chunks), + }) + ); + } + + reject(new Error(`Editor upgrade rejected with status ${upstreamRes.statusCode || 502}`)); + }); + }); + + proxyReq.on('error', reject); + proxyReq.end(); + }); + } catch (error: any) { + logger.error( + { ...editorLogCtx, error, sessionId: match.sessionId }, + `SessionEditor: websocket setup failed sessionId=${match.sessionId}` + ); + + if (proxyReq) { + proxyReq.destroy(); + } + + if (upstreamSocket && !upstreamSocket.destroyed) { + upstreamSocket.destroy(); + } + + if (!socket.destroyed) { + socket.end( + serializeSocketHttpResponse({ + statusCode: resolveSessionWorkspaceEditorErrorStatus(error), + statusMessage: 'Bad Gateway', + body: error instanceof Error ? error.message : String(error), + }) + ); + } + } } async function resolveOwnedAgentSession( @@ -258,16 +411,6 @@ function closeSocket(ws: WebSocket, code: number, reason: string) { ws.close(1000, safeReason); } -function normalizeWebSocketCloseReason(reason?: Buffer | string): string | undefined { - if (!reason) { - return undefined; - } - - const value = typeof reason === 'string' ? reason : reason.toString(); - const trimmed = value.trim(); - return trimmed || undefined; -} - async function handleSessionWorkspaceEditorHttp( req: IncomingMessage, res: ServerResponse, @@ -383,9 +526,7 @@ app.prepare().then(() => { }); } else if (parseSessionWorkspaceEditorPath(pathname)) { logger.debug(connectionLogCtx, 'WebSocket: upgrade path=session_workspace_editor'); - wss.handleUpgrade(request, socket, head, (ws: WebSocket) => { - wss.emit('agent-editor', ws, request); - }); + void handleSessionWorkspaceEditorUpgrade(request, socket as Socket, head); } else { socket.destroy(); } @@ -483,157 +624,6 @@ app.prepare().then(() => { }); }); - wss.on('agent-editor', async (ws: WebSocket, request: IncomingMessage) => { - const parsedUrl = parse(request.url || '', true); - const match = parseSessionWorkspaceEditorPath(parsedUrl.pathname); - const editorLogCtx: Record = { - remoteAddress: request.socket.remoteAddress, - path: parsedUrl.pathname, - }; - - if (!match) { - closeSocket(ws, 1008, 'Invalid editor path'); - return; - } - - let upstream: WebSocket | null = null; - let heartbeatInterval: ReturnType | null = null; - - try { - const queryToken = typeof parsedUrl.query.token === 'string' ? parsedUrl.query.token : null; - const session = await resolveOwnedAgentSession(request, match.sessionId, queryToken); - const forwardedPrefix = getSessionWorkspaceEditorCookiePath(match.sessionId); - const targetUrl = buildSessionWorkspaceEditorServiceUrl( - session, - match.forwardPath, - parsedUrl.query as Record, - true - ); - const protocols = - typeof request.headers['sec-websocket-protocol'] === 'string' - ? request.headers['sec-websocket-protocol'] - .split(',') - .map((protocol) => protocol.trim()) - .filter(Boolean) - : undefined; - - upstream = new WebSocket(targetUrl, protocols, { - headers: buildProxyHeaders(request, targetUrl, forwardedPrefix), - }); - - const closeUpstream = (code?: number, reason?: Buffer) => { - if (!upstream || upstream.readyState === WebSocket.CLOSING || upstream.readyState === WebSocket.CLOSED) { - return; - } - - const closeReason = normalizeWebSocketCloseReason(reason); - if (isSendableCloseCode(code)) { - upstream.close(code, closeReason); - } else { - upstream.close(); - } - }; - - const clearHeartbeat = () => { - if (heartbeatInterval) { - clearInterval(heartbeatInterval); - heartbeatInterval = null; - } - }; - - const logEditorClose = (source: 'client' | 'upstream', code: number, reason?: Buffer | string) => { - logger.info( - { - ...editorLogCtx, - source, - sessionId: match.sessionId, - code, - reason: normalizeWebSocketCloseReason(reason), - }, - `SessionEditor: websocket closed source=${source} sessionId=${match.sessionId} code=${code}` - ); - }; - - if (SESSION_WORKSPACE_EDITOR_HEARTBEAT_INTERVAL_MS > 0) { - heartbeatInterval = setInterval(() => { - if (ws.readyState === WebSocket.OPEN) { - ws.ping(); - } - if (upstream?.readyState === WebSocket.OPEN) { - upstream.ping(); - } - }, SESSION_WORKSPACE_EDITOR_HEARTBEAT_INTERVAL_MS); - } - - ws.on('message', (data, isBinary) => { - if (upstream?.readyState === WebSocket.OPEN) { - upstream.send(data, { binary: isBinary }); - } - }); - - ws.on('close', (code, reason) => { - clearHeartbeat(); - logEditorClose('client', code, reason); - closeUpstream(code, reason); - }); - - ws.on('error', (error) => { - clearHeartbeat(); - logger.warn( - { ...editorLogCtx, error }, - `SessionEditor: websocket error source=client sessionId=${match.sessionId}` - ); - closeUpstream(1011, Buffer.from('Client WebSocket error')); - }); - - upstream.on('message', (data, isBinary) => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(data, { binary: isBinary }); - } - }); - - upstream.on('close', (code, reason) => { - clearHeartbeat(); - logEditorClose('upstream', code, reason); - closeSocket( - ws, - code === 1005 ? 1000 : code, - normalizeWebSocketCloseReason(reason) || 'Editor connection closed' - ); - }); - - upstream.on('error', (error) => { - clearHeartbeat(); - logger.warn( - { ...editorLogCtx, error }, - `SessionEditor: websocket error source=upstream sessionId=${match.sessionId}` - ); - closeSocket(ws, 1011, 'Editor upstream error'); - }); - - upstream.on('unexpected-response', (_req, response) => { - clearHeartbeat(); - logger.warn( - { ...editorLogCtx, statusCode: response.statusCode }, - `SessionEditor: upgrade rejected sessionId=${match.sessionId} statusCode=${response.statusCode}` - ); - closeSocket(ws, 1011, 'Editor upgrade rejected'); - }); - } catch (error: any) { - logger.error( - { ...editorLogCtx, error, sessionId: match.sessionId }, - `SessionEditor: websocket setup failed sessionId=${match.sessionId}` - ); - if (heartbeatInterval) { - clearInterval(heartbeatInterval); - } - closeSocket(ws, 1008, `Connection error: ${error.message}`); - if (upstream && upstream.readyState === WebSocket.CONNECTING) { - upstream.terminate(); - } - } - }); - httpServer.listen(port); httpServer.on('error', (error) => {