diff --git a/.changeset/external-repository-credential-resolver.md b/.changeset/external-repository-credential-resolver.md new file mode 100644 index 000000000..d1f8c3adf --- /dev/null +++ b/.changeset/external-repository-credential-resolver.md @@ -0,0 +1,5 @@ +--- +'@truefoundry/trueforge': minor +--- + +Add an optional external HTTP resolver for short-lived repository credentials. diff --git a/.changeset/session-repository-checkouts.md b/.changeset/session-repository-checkouts.md new file mode 100644 index 000000000..817289767 --- /dev/null +++ b/.changeset/session-repository-checkouts.md @@ -0,0 +1,6 @@ +--- +'@truefoundry/trueforge-core': minor +'@truefoundry/trueforge': minor +--- + +Add persistent session repository checkouts with per-turn credential resolution and read-only or read-write access controls. diff --git a/packages/trueforge-core/src/agent-session/Sessions.ts b/packages/trueforge-core/src/agent-session/Sessions.ts index 8eed66638..1e7382e02 100644 --- a/packages/trueforge-core/src/agent-session/Sessions.ts +++ b/packages/trueforge-core/src/agent-session/Sessions.ts @@ -14,10 +14,11 @@ import { SessionExternalIdConflictError } from './store/SessionStoreErrors'; export type SessionsCreateInput = Omit< CreateSessionInput, - 'custom' | 'metadata' + 'custom' | 'metadata' | 'repository' > & { custom?: TSessionCustom | undefined; metadata?: CreateSessionInput['metadata'] | undefined; + repository?: CreateSessionInput['repository'] | undefined; }; export class Sessions< @@ -38,6 +39,7 @@ export class Sessions< ...input, custom: input.custom ?? null, metadata: input.metadata ?? {}, + repository: input.repository ?? null, }); const record = await this.store.getSession({ tenant_id: input.tenant_id, diff --git a/packages/trueforge-core/src/agent-session/TurnResourceResolver.ts b/packages/trueforge-core/src/agent-session/TurnResourceResolver.ts index 175fc3f33..f157960a4 100644 --- a/packages/trueforge-core/src/agent-session/TurnResourceResolver.ts +++ b/packages/trueforge-core/src/agent-session/TurnResourceResolver.ts @@ -74,6 +74,8 @@ export class TurnResourceResolver< mcpConnectTimeoutMs: number; /** One sandbox type per runtime. Omit = no sandbox support. */ sandboxProvider?: TurnSandboxFactory | undefined; + /** Force sandbox provisioning for session-owned resources such as a repository checkout. */ + sandboxRequired?: boolean | undefined; /** * Named-agent lookup (registry id → live AgentSpec). Required when a * session is bound by reference; omit only if all sessions use inline agents. @@ -118,7 +120,7 @@ export class TurnResourceResolver< signal: AbortSignal; tracing: AgentTracing; }): Promise { - if (!this.deps.sandboxProvider || !specWantsSandbox(input.spec)) { + if (!this.deps.sandboxProvider || (!specWantsSandbox(input.spec) && this.deps.sandboxRequired !== true)) { return undefined; } this.#sandbox = await this.deps.sandboxProvider({ diff --git a/packages/trueforge-core/src/agent-session/index.ts b/packages/trueforge-core/src/agent-session/index.ts index f515621c5..4100368bb 100644 --- a/packages/trueforge-core/src/agent-session/index.ts +++ b/packages/trueforge-core/src/agent-session/index.ts @@ -21,8 +21,8 @@ export { } from './schemas/turn'; export type { TerminalTurnState, Turn, TurnInputItem, TurnMetrics, TurnState } from './schemas/turn'; -export { SessionMetadataSchema, SessionMetricsSchema, SessionSchema } from './schemas/session'; -export type { Session, SessionAgent, SessionMetadata, SessionMetrics } from './schemas/session'; +export { SessionMetadataSchema, SessionMetricsSchema, SessionRepositorySchema, SessionSchema } from './schemas/session'; +export type { Session, SessionAgent, SessionMetadata, SessionMetrics, SessionRepository } from './schemas/session'; export { EventType, diff --git a/packages/trueforge-core/src/agent-session/models/SessionRecord.ts b/packages/trueforge-core/src/agent-session/models/SessionRecord.ts index 0d48d08d5..b7684e298 100644 --- a/packages/trueforge-core/src/agent-session/models/SessionRecord.ts +++ b/packages/trueforge-core/src/agent-session/models/SessionRecord.ts @@ -1,4 +1,4 @@ -import type { SessionAgent, SessionMetadata, SessionMetrics } from '../schemas/session'; +import type { SessionAgent, SessionMetadata, SessionMetrics, SessionRepository } from '../schemas/session'; /** * Session persistence record. Agent binding is a single discriminated `agent` @@ -37,5 +37,7 @@ export interface SessionRecord> { last_activity_timestamp_ms: number; metrics: SessionMetrics; metadata: SessionMetadata; + /** Immutable sandbox checkout configuration; credentials are resolved per turn and never persisted. */ + repository: SessionRepository | null; custom: TCustom | null; } diff --git a/packages/trueforge-core/src/agent-session/schemas/session.ts b/packages/trueforge-core/src/agent-session/schemas/session.ts index 843736a58..f49e3a85f 100644 --- a/packages/trueforge-core/src/agent-session/schemas/session.ts +++ b/packages/trueforge-core/src/agent-session/schemas/session.ts @@ -3,6 +3,7 @@ * field (`reference` | `inline`). DB stores agent_id / agent_name / agent_spec columns. */ import { z } from '@hono/zod-openapi'; +import { SessionRepositorySchema, type SessionRepository } from '../../core/sandbox/RepositoryCheckout'; import { AgentSpecSchema } from './agentSpec'; /** Max key length for session metadata (aligned with LLM gateway HeaderMetadata). */ @@ -32,6 +33,9 @@ export const SessionMetadataSchema = z export type SessionMetadata = z.infer; +export { SessionRepositorySchema }; +export type { SessionRepository }; + export const SessionMetricsSchema = z .object({ total_cost_in_usd: z.number().nonnegative(), @@ -79,6 +83,7 @@ export const SessionSchema = z updated_at: z.string().describe('ISO 8601 last-update timestamp.'), metrics: SessionMetricsSchema, metadata: SessionMetadataSchema, + repository: SessionRepositorySchema.nullable(), }) .openapi('Session'); diff --git a/packages/trueforge-core/src/agent-session/store/ISessionStore.ts b/packages/trueforge-core/src/agent-session/store/ISessionStore.ts index a19ddbcce..88a474876 100644 --- a/packages/trueforge-core/src/agent-session/store/ISessionStore.ts +++ b/packages/trueforge-core/src/agent-session/store/ISessionStore.ts @@ -19,7 +19,7 @@ import type { CancellationReason, TerminalTurnState } from '../schemas/turn'; */ export type CreateSessionInput> = Pick< SessionRecord, - 'tenant_id' | 'session_id' | 'agent' | 'created_by' | 'external_id' | 'metadata' + 'tenant_id' | 'session_id' | 'agent' | 'created_by' | 'external_id' | 'metadata' | 'repository' > & { custom: TSessionCustom | null; }; diff --git a/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts b/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts index 85492b46e..6b3beffbb 100644 --- a/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts +++ b/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts @@ -201,6 +201,7 @@ export class InMemorySessionStore< total_turns: 0, }, metadata: deepCopy(input.metadata), + repository: input.repository !== null ? deepCopy(input.repository) : null, custom: input.custom !== null ? deepCopy(input.custom) : null, }; this.sessions.set(key, { record, turnIds: [] }); diff --git a/packages/trueforge-core/src/core/index.ts b/packages/trueforge-core/src/core/index.ts index 5ebbd311a..8773fb18a 100644 --- a/packages/trueforge-core/src/core/index.ts +++ b/packages/trueforge-core/src/core/index.ts @@ -22,6 +22,9 @@ export type { CreateDynamicSubAgentThread } from './runtime/CreateDynamicSubAgen export { isAgentInputUserMessage, isEmptyMessageContent, isFileContentPart } from './runtime/UserInputMessage'; export type { AgentInputUserMessage } from './runtime/UserInputMessage'; +export { SessionRepositorySchema } from './sandbox/RepositoryCheckout'; +export type { SessionRepository } from './sandbox/RepositoryCheckout'; + // Capability contracts export type { AgentCapability, CapabilityState, JsonValue } from './capabilities/AgentCapability'; export type { diff --git a/packages/trueforge-core/src/core/sandbox/RepositoryCheckout.ts b/packages/trueforge-core/src/core/sandbox/RepositoryCheckout.ts new file mode 100644 index 000000000..1f5877515 --- /dev/null +++ b/packages/trueforge-core/src/core/sandbox/RepositoryCheckout.ts @@ -0,0 +1,32 @@ +import { z } from '@hono/zod-openapi'; + +export const SessionRepositorySchema = z + .object({ + url: z.url().refine(value => { + const parsed = new URL(value); + return parsed.protocol === 'https:' && parsed.username === '' && parsed.password === ''; + }, 'Repository URL must use HTTPS and must not contain credentials.'), + ref: z + .string() + .min(1) + .max(255) + .regex(/^[A-Za-z0-9][A-Za-z0-9._/-]*$/, 'Ref contains unsupported characters.') + .refine(value => !value.includes('..') && !value.includes('@{') && !value.endsWith('.lock'), 'Invalid Git ref.') + .describe('Branch, tag, or commit to check out.'), + path: z + .string() + .min(1) + .max(255) + .regex( + /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))(?!.*\/\/)[A-Za-z0-9._/-]+$/, + 'Path must be relative, portable, and may not traverse.', + ) + .refine(value => value !== '.', 'Path must use a dedicated sandbox subdirectory.'), + access: z.enum(['read_only', 'read_write']), + credential_provider_ref: z.string().min(1).max(255).nullable().default(null), + }) + .strict() + .describe('A persistent Git checkout provisioned in the session sandbox.') + .openapi('SessionRepository'); + +export type SessionRepository = z.infer; diff --git a/packages/trueforge-core/src/core/sandbox/Sandbox.ts b/packages/trueforge-core/src/core/sandbox/Sandbox.ts index 2a8b0a774..3aabd168b 100644 --- a/packages/trueforge-core/src/core/sandbox/Sandbox.ts +++ b/packages/trueforge-core/src/core/sandbox/Sandbox.ts @@ -22,6 +22,7 @@ import { SandboxNotAvailableError, validateNoPathTraversal } from './SandboxErro import { formatSandboxId, rawSandboxId } from './sandboxRef'; // Import submodules, not the ./skills barrel, to avoid a cycle (the mounters import from Sandbox). import { dirname, join, relative } from 'node:path'; +import { SessionRepositorySchema, type SessionRepository } from './RepositoryCheckout'; import type { ISkillMounter } from './skills/ISkillMounter'; /** Layout derived from install remotePath (always `…/mcp_client.py`). */ @@ -79,6 +80,27 @@ function buildGitCredentialHelperEnv(credentialsPath: string): Record/dev/null 2>&1 && git -C ${path} remote set-url origin ${url} || ` + + `git -C ${path} remote add origin ${url}) && git -C ${path} fetch origin ${fetchRef}; ` + + `elif [ -e ${path} ] && [ ! -d ${path} ]; then echo "Repository path already exists and is not a directory" >&2; exit 1; ` + + `else mkdir -p ${path} && git init -- ${path} && git -C ${path} remote add origin ${url} && ` + + `git -C ${path} fetch --depth=1 origin ${fetchRef}; fi && ` + + `(git -C ${path} rev-parse --verify HEAD >/dev/null 2>&1 || git -C ${path} checkout -B trueforge-session FETCH_HEAD)` + + pushPolicy + ); +} + export interface SandboxStoredFile { filePath: string; sandboxCreated?: SandboxInfo | undefined; @@ -91,6 +113,8 @@ export interface SandboxOptions { fileDownloadEnabled?: boolean | undefined; /** Pre-resolved credential-store file content (null = clear / no git auth). */ resolvedGitCredentialsContent?: string | null | undefined; + /** Immutable repository checkout metadata. */ + repository?: SessionRepository | null | undefined; /** * Blocks destructive tools in code mode so they go through the approval flow * instead. Must be `true` — approvals are always enabled (the kill switch is gone). @@ -210,6 +234,7 @@ export class Sandbox extends LocalToolMCP { private readonly logger: Logger; // Pre-resolved credential-store file content (null = clear / no git auth). private readonly resolvedGitCredentialsContent: string | null; + private readonly repository: SessionRepository | null; private codeModeDispatcher: CodeModeDispatcher | undefined; private codeModeTransport: CodeModeTransport | undefined; /** Cached from transport.getClientInstall after sandbox init (when Code Mode is configured). */ @@ -234,6 +259,10 @@ export class Sandbox extends LocalToolMCP { this.requestTimeoutSeconds = Math.ceil(mcpBoundTimeoutMs / 1000) + NATS_REQUEST_TIMEOUT_BUFFER_SECONDS; this.logger = options.logger.child({ module: 'Sandbox' }); this.resolvedGitCredentialsContent = options.resolvedGitCredentialsContent ?? null; + this.repository = + options.repository === null || options.repository === undefined + ? null + : SessionRepositorySchema.parse(options.repository); if (this.existingSandboxId) { this.existingSandboxInfo = { sandbox_id: this.existingSandboxId }; @@ -295,6 +324,9 @@ export class Sandbox extends LocalToolMCP { const sandboxInstructions = builder.beginSection('sandbox'); sandboxInstructions.addContent('The Agent has access to a persistent sandbox environment for executing code.'); sandboxInstructions.addContent('The Agent must NOT read or modify any git credential files.'); + if (this.repository !== null) { + sandboxInstructions.addContent(`The session repository is checked out at ${this.repository.path}.`); + } this.buildSchemaSection(sandboxInstructions); this.buildSkillsSection(sandboxInstructions); @@ -684,13 +716,31 @@ export class Sandbox extends LocalToolMCP { ensureExecSuccess(result); } + private async prepareRepository(): Promise { + if (this.repository === null) { + return; + } + const sandboxId = this.providerSandboxId(this.requiredSandboxInfo.sandbox_id); + const credentialsPath = this.provider.getGitCredentialsPath(sandboxId); + const result = await this.provider.exec({ + sandboxId, + command: buildRepositoryCheckoutCommand(this.repository), + env: buildGitCredentialHelperEnv(credentialsPath), + timeoutSeconds: SKILL_DOWNLOAD_TIMEOUT_SECONDS, + }); + ensureExecSuccess(result); + } + private async initSandboxEnvironment(): Promise { const sandboxId = this.providerSandboxId(this.requiredSandboxInfo.sandbox_id); const fileUploadsDir = this.provider.getFileUploadsDir(sandboxId); const skillsDir = this.provider.getSkillsDir(sandboxId); const toolResultDumpDir = this.provider.getToolResultDumpDir(sandboxId); - this.logger.info('Uploading MCP client script and preparing skills directory in sandbox'); + this.logger.info('Uploading MCP client script and preparing sandbox resources'); + + await this.writeGitCredentials(); + await this.prepareRepository(); this.mcpClientInstall = this.codeModeTransport?.getClientInstall({ sandboxId }); const install = this.mcpClientInstall; @@ -750,8 +800,6 @@ export class Sandbox extends LocalToolMCP { ? `Sandbox initialized: skills dir ${skillsDir}` : `Sandbox initialized: MCP client at ${install.remotePath}; skills dir ${skillsDir}`, ); - - await this.writeGitCredentials(); } /** diff --git a/packages/trueforge-core/tests/agent-session/sessionRepository.test.ts b/packages/trueforge-core/tests/agent-session/sessionRepository.test.ts new file mode 100644 index 000000000..9de146358 --- /dev/null +++ b/packages/trueforge-core/tests/agent-session/sessionRepository.test.ts @@ -0,0 +1,35 @@ +import { SessionRepositorySchema } from '../../src/agent-session/schemas/session'; + +describe('SessionRepositorySchema', () => { + const repository = { + url: 'https://github.com/example/repository.git', + ref: 'feature/work', + path: 'workspace/repository', + access: 'read_write', + credential_provider_ref: 'github-app:installation-123', + }; + + it('accepts a scoped HTTPS checkout', () => { + expect(SessionRepositorySchema.parse(repository)).toEqual(repository); + }); + + it('defaults to anonymous credentials for public repositories', () => { + const { credential_provider_ref: _credentialProviderRef, ...publicRepository } = repository; + expect(SessionRepositorySchema.parse(publicRepository).credential_provider_ref).toBeNull(); + expect( + SessionRepositorySchema.parse({ ...repository, credential_provider_ref: null }).credential_provider_ref, + ).toBeNull(); + }); + + it.each([ + ['non-HTTPS URL', { ...repository, url: 'ssh://git@github.com/example/repository.git' }], + ['URL credentials', { ...repository, url: 'https://token@github.com/example/repository.git' }], + ['absolute path', { ...repository, path: '/workspace/repository' }], + ['sandbox root path', { ...repository, path: '.' }], + ['traversing path', { ...repository, path: '../repository' }], + ['empty credential provider reference', { ...repository, credential_provider_ref: '' }], + ['option-like ref', { ...repository, ref: '--upload-pack=malicious' }], + ])('rejects %s', (_label, candidate) => { + expect(SessionRepositorySchema.safeParse(candidate).success).toBe(false); + }); +}); diff --git a/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts b/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts index 09be27fd7..ddf8fcfda 100644 --- a/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts +++ b/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts @@ -2,8 +2,9 @@ import { z } from 'zod'; import { MAIN_THREAD_ID } from '../../../src/agent-session/models/TurnRecord'; import type { PersistedTurnEvent } from '../../../src/agent-session/schemas/events'; import { EventType } from '../../../src/agent-session/schemas/events'; +import type { SessionRepository } from '../../../src/agent-session/schemas/session'; import { CancellationReason } from '../../../src/agent-session/schemas/turn'; -import type { ISessionStore } from '../../../src/agent-session/store/ISessionStore'; +import type { CreateSessionInput, ISessionStore } from '../../../src/agent-session/store/ISessionStore'; import { decodeSessionEventPageToken } from '../../../src/agent-session/store/SessionEventPageToken'; import { PreviousTurnRunningError, @@ -69,6 +70,13 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { const missingSessionId = 'missing-session'; const missingTurnId = 'missing-turn'; + async function createSession( + store: ISessionStore, + input: Omit & { repository?: CreateSessionInput['repository'] | undefined }, + ): Promise { + await store.createSession({ ...input, repository: input.repository ?? null }); + } + async function finishTurn(store: ISessionStore, turnId: string) { const state = makeDoneTurnState(); await store.updateTurnState({ @@ -80,13 +88,14 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { } async function seedSession(store: ISessionStore, agentSpec = makeAgentSpec()) { - await store.createSession({ + await createSession(store, { tenant_id: tenant, session_id: sessionId, created_by: 'user-1', agent: { type: 'inline', spec: agentSpec }, custom: null, metadata: {}, + repository: null, external_id: null, }); } @@ -187,13 +196,14 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { it('createSession persists created_by', async () => { const store = createStore(); - await store.createSession({ + await createSession(store, { tenant_id: tenant, session_id: 'created-by-session', created_by: 'alice@example.com', agent: { type: 'inline', spec: makeAgentSpec() }, custom: null, metadata: {}, + repository: null, external_id: null, }); const session = mustGet(await store.getSession({ tenant_id: tenant, session_id: 'created-by-session' })); @@ -213,9 +223,34 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { expect(listed.data.find(s => s.session_id === 'created-by-session')?.created_by).toBe('alice@example.com'); }); + it('persists repository metadata without credential material', async () => { + const store = createStore(); + const repository: SessionRepository = { + url: 'https://github.com/example/repository.git', + ref: 'feature/work', + path: 'workspace/repository', + access: 'read_write', + credential_provider_ref: 'github-app:installation-123', + }; + await createSession(store, { + tenant_id: tenant, + session_id: 'repository-session', + created_by: 'user-1', + agent: { type: 'inline', spec: makeAgentSpec() }, + custom: null, + metadata: {}, + external_id: null, + repository, + }); + + const session = mustGet(await store.getSession({ tenant_id: tenant, session_id: 'repository-session' })); + expect(session.repository).toEqual(repository); + expect(JSON.stringify(session)).not.toContain('credential-store-content'); + }); + it('persists reference agents and listSessions filters by agent_id', async () => { const store = createStore(); - await store.createSession({ + await createSession(store, { tenant_id: tenant, session_id: 'named-1', created_by: 'user-1', @@ -244,7 +279,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { it('rejects agent updates on sessions bound by agent_id', async () => { const store = createStore(); - await store.createSession({ + await createSession(store, { tenant_id: tenant, session_id: 'named-1', created_by: 'user-1', @@ -299,7 +334,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { it('createSession persists metadata for getSession', async () => { const store = createStore(); const metadata = { env: 'prod', ticket: 'T-1' }; - await store.createSession({ + await createSession(store, { tenant_id: tenant, session_id: sessionId, created_by: 'user-1', @@ -314,7 +349,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { it('updateSession replaces metadata when set and leaves it when omitted', async () => { const store = createStore(); - await store.createSession({ + await createSession(store, { tenant_id: tenant, session_id: sessionId, created_by: 'user-1', @@ -366,13 +401,14 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { const store = createStore(); await seedSession(store); await expect( - store.createSession({ + createSession(store, { tenant_id: 'other', session_id: sessionId, created_by: 'user-1', agent: { type: 'inline', spec: makeAgentSpec() }, custom: null, metadata: {}, + repository: null, external_id: null, }), ).rejects.toBeInstanceOf(SessionStoreConflictError); @@ -387,7 +423,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { it('createSession persists external_id and getSessionByExternalId finds it', async () => { const store = createStore(); - await store.createSession({ + await createSession(store, { tenant_id: tenant, session_id: sessionId, created_by: 'user-1', @@ -406,7 +442,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { it('createSession unique external_id within a tenant; nulls and other tenants do not collide', async () => { const store = createStore(); - await store.createSession({ + await createSession(store, { tenant_id: tenant, session_id: 's-a', created_by: 'user-1', @@ -418,7 +454,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { // Specifically the external-id arm, not just any conflict: get-or-create // treats this rejection as its normal repeat-call path. await expect( - store.createSession({ + createSession(store, { tenant_id: tenant, session_id: 's-b', created_by: 'user-1', @@ -429,7 +465,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { }), ).rejects.toBeInstanceOf(SessionExternalIdConflictError); - await store.createSession({ + await createSession(store, { tenant_id: 'other', session_id: 's-c', created_by: 'user-1', @@ -438,7 +474,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { metadata: {}, external_id: 'shared-key', }); - await store.createSession({ + await createSession(store, { tenant_id: tenant, session_id: 's-d', created_by: 'user-1', @@ -447,7 +483,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { metadata: {}, external_id: null, }); - await store.createSession({ + await createSession(store, { tenant_id: tenant, session_id: 's-e', created_by: 'user-1', @@ -460,7 +496,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { it('getSessionByExternalId does not bump last_activity_timestamp_ms', async () => { const store = createStore(); - await store.createSession({ + await createSession(store, { tenant_id: tenant, session_id: sessionId, created_by: 'user-1', @@ -480,7 +516,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { it('removes all session data, is idempotent, and is a no-op when tenant_id does not match', async () => { const store = createStore(); await seedSession(store); - await store.createSession({ + await createSession(store, { tenant_id: 'other', session_id: 'other-session', created_by: 'user-1', @@ -665,7 +701,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { const store = createStore(); const nested = `${sessionId}:nested`; await seedSession(store); - await store.createSession({ + await createSession(store, { tenant_id: tenant, session_id: nested, created_by: 'user-1', @@ -796,7 +832,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { describe('listSessions', () => { async function seedThreeSessions(store: ISessionStore) { for (const id of ['sa', 'sb', 'sc']) { - await store.createSession({ + await createSession(store, { tenant_id: tenant, session_id: id, created_by: 'user-1', @@ -812,7 +848,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { it('lists newest-first by default, asc on request, scoped to tenant', async () => { const store = createStore(); await seedThreeSessions(store); - await store.createSession({ + await createSession(store, { tenant_id: 'other', session_id: 'sx', created_by: 'user-1', @@ -967,7 +1003,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { it('filters by created_by', async () => { const store = createStore(); - await store.createSession({ + await createSession(store, { tenant_id: tenant, session_id: 'alice-session', created_by: 'alice', @@ -976,7 +1012,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { metadata: {}, external_id: null, }); - await store.createSession({ + await createSession(store, { tenant_id: tenant, session_id: 'bob-session', created_by: 'bob', diff --git a/packages/trueforge-core/tests/agent-session/turnStream.test.ts b/packages/trueforge-core/tests/agent-session/turnStream.test.ts index 1ccd57e67..f23ab61eb 100644 --- a/packages/trueforge-core/tests/agent-session/turnStream.test.ts +++ b/packages/trueforge-core/tests/agent-session/turnStream.test.ts @@ -509,4 +509,28 @@ describe('TurnResourceResolver caches', () => { } expect(sandboxCreates).toBe(1); }); + + it('resolves a required session sandbox when the agent sandbox flag is disabled', async () => { + const sandbox = makeStubPublicSandbox(); + const sandboxProvider = jest.fn().mockResolvedValue(sandbox); + const resolver = new TurnResourceResolver({ + llm: () => Promise.resolve({ modelClient: makeMockILLM(), defaultModelParams: {} }), + mcp: () => Promise.resolve({ url: 'http://localhost' }), + mcpRequestTimeoutMs: 60_000, + mcpConnectTimeoutMs: 5_000, + sandboxProvider, + sandboxRequired: true, + logger: makeSilentLogger(), + }); + const spec = makeAgentSpec({ config: { sandbox: { enabled: false, file_downloads: false } } }); + + await expect( + resolver.resolveSandbox({ + spec, + signal: new AbortController().signal, + tracing: resolver.createTracing(), + }), + ).resolves.toBe(sandbox); + expect(sandboxProvider).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/trueforge-core/tests/core/sandbox/Sandbox.paths.test.ts b/packages/trueforge-core/tests/core/sandbox/Sandbox.paths.test.ts index 7184725af..0c9f31b23 100644 --- a/packages/trueforge-core/tests/core/sandbox/Sandbox.paths.test.ts +++ b/packages/trueforge-core/tests/core/sandbox/Sandbox.paths.test.ts @@ -1,7 +1,7 @@ import { InstructionBuilder } from '../../../src/core/InstructionBuilder'; import type { CodeModeTransport } from '../../../src/core/sandbox/codeMode/CodeModeTransport'; import type { ExecResult, SandboxExecParams, SandboxProvider } from '../../../src/core/sandbox/provider/Provider'; -import { SANDBOX_EXEC_TOOL_NAME, Sandbox } from '../../../src/core/sandbox/Sandbox'; +import { buildRepositoryCheckoutCommand, Sandbox, SANDBOX_EXEC_TOOL_NAME } from '../../../src/core/sandbox/Sandbox'; import { NOOP_AGENT_TRACING } from '../../../src/core/tracing/NoopAgentTracing'; import { makeMockIMCPServer, makeSilentLogger } from '../harnessMocks'; @@ -42,6 +42,67 @@ function makeSandbox(provider: SandboxProvider, options: { existingSandboxId?: s } describe('Sandbox provider-owned paths', () => { + it('builds an idempotent checkout that fetches without resetting resumed work', () => { + const command = buildRepositoryCheckoutCommand({ + url: 'https://github.com/example/repository.git', + ref: 'feature/work', + path: 'workspace/repository', + access: 'read_write', + credential_provider_ref: 'github-app:installation-123', + }); + + expect(command).toContain('remote get-url origin'); + expect(command).toContain("remote set-url origin 'https://github.com/example/repository.git'"); + expect(command).toContain("remote add origin 'https://github.com/example/repository.git'"); + expect(command).toContain("elif [ -e 'workspace/repository' ] && [ ! -d 'workspace/repository' ]"); + expect(command).toContain("else mkdir -p 'workspace/repository' && git init -- 'workspace/repository'"); + expect(command).toContain("fetch origin '+feature/work:refs/trueforge/session-source'"); + expect(command).not.toContain('fetch --prune'); + expect(command).toContain("config remote.origin.push 'HEAD:feature/work'"); + expect(command).not.toContain('reset'); + expect(command).not.toContain('credential_provider_ref'); + }); + + it('prepares credentials and a read-only repository before agent commands', async () => { + const execCalls: SandboxExecParams[] = []; + const provider = makeProvider({ + exec: params => { + execCalls.push(params); + return readyExec(); + }, + }); + const sandbox = new Sandbox({ + provider, + repository: { + url: 'https://github.com/example/repository.git', + ref: 'main', + path: 'workspace/repository', + access: 'read_only', + credential_provider_ref: 'github-app:installation-123', + }, + resolvedGitCredentialsContent: 'https://token@example.test', + blockDestructiveToolsInCodeMode: true, + mcpRequestTimeoutMs: 60_000, + mcpConnectTimeoutMs: 5_000, + logger: makeSilentLogger(), + tracing: NOOP_AGENT_TRACING, + }); + + await sandbox.callTool({ + name: SANDBOX_EXEC_TOOL_NAME, + arguments: { intent: 'Inspect repository', command: 'git status' }, + }); + + expect(execCalls[0]?.command).toContain('base64 -d'); + expect(execCalls[1]?.command).toContain('remote set-url --push origin disabled://read-only'); + expect(execCalls[1]?.env).toEqual({ + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'credential.helper', + GIT_CONFIG_VALUE_0: 'store --file /prov/.git-credentials', + }); + expect(execCalls.at(-1)?.command).toBe('git status'); + }); + it('puts the provider uploads dir in the system prompt, not /tmp/uploads', () => { const sandbox = makeSandbox(makeProvider()); const builder = new InstructionBuilder('root'); diff --git a/packages/trueforge/.env.example b/packages/trueforge/.env.example index 8aaa335d9..df5dbcd56 100644 --- a/packages/trueforge/.env.example +++ b/packages/trueforge/.env.example @@ -118,6 +118,23 @@ REDIS_URL=redis://localhost:6379 ## Max ms for an MCP transport connection. Default 30000. # MCP_CONNECT_TIMEOUT_MS=30000 +## --------------------------------------------------------------------------- +## Repository credential resolver. Optional; when unset, sessions that include +## credential_provider_ref retain the default rejection behavior. +## +## TrueForge sends a POST JSON body containing credential_provider_ref, +## tenant_id, session_id, user_ref, and repository { url, ref, access }. The +## endpoint responds with { "credentials": "" }. +## Request and response bodies are never logged or persisted by TrueForge. +## --------------------------------------------------------------------------- +# REPOSITORY_CREDENTIAL_RESOLVER_URL=https://resolver.example.com/v1/repository-credentials +## Optional complete Authorization header value, such as "Bearer ". +# REPOSITORY_CREDENTIAL_RESOLVER_AUTHORIZATION= +## Max request duration in milliseconds. Default 10000. +# REPOSITORY_CREDENTIAL_RESOLVER_TIMEOUT_MS=10000 +## Max response size in bytes. Default 65536. +# REPOSITORY_CREDENTIAL_RESOLVER_MAX_RESPONSE_BYTES=65536 + ## --------------------------------------------------------------------------- ## Sandbox process knobs. Provider identity/credentials are configured via ## PUT /api/v1/settings/sandbox-providers (copy from GET /api/v1/catalog/sandbox-providers). Without a @@ -176,4 +193,4 @@ POSTGRES_PORT=5432 ## Optional comma-separated email allowlist (exact addresses and/or * globs). ## When set, only ID tokens whose `email` claim matches an entry may sign in. ## Example: alice@acme.com,*@partner.com Empty / unset = unrestricted. -# OIDC_ALLOWED_EMAILS= \ No newline at end of file +# OIDC_ALLOWED_EMAILS= diff --git a/packages/trueforge/README.md b/packages/trueforge/README.md index 5820f1039..b98de877e 100644 --- a/packages/trueforge/README.md +++ b/packages/trueforge/README.md @@ -16,6 +16,18 @@ npx @truefoundry/trueforge Open [http://localhost:8790](http://localhost:8790). This is local mode (one process, SQLite) — for your machine only. +## Private repository credentials + +Set `REPOSITORY_CREDENTIAL_RESOLVER_URL` to enable repository credentials for the packaged server. For each turn whose repository has a `credential_provider_ref`, TrueForge sends the resolver a JSON `POST` with that opaque reference, tenant/session/user context, and the repository `url`, `ref`, and `access` fields. The resolver must respond with Git credential-store content: + +```json +{ "credentials": "https://username:password@git.example.com\n" } +``` + +`REPOSITORY_CREDENTIAL_RESOLVER_AUTHORIZATION` optionally sets the complete outbound `Authorization` header. Request timeout and maximum response size default to 10 seconds and 64 KiB and can be changed with `REPOSITORY_CREDENTIAL_RESOLVER_TIMEOUT_MS` and `REPOSITORY_CREDENTIAL_RESOLVER_MAX_RESPONSE_BYTES`. + +Resolver bodies and returned credentials are not logged or persisted. If the resolver is unset, unavailable, rejects the request, or returns an invalid response, repository provisioning fails closed. + ## Docs Guides, hosted deployment, API reference, and the UI SDK live at **[trueforge.dev](https://trueforge.dev)**. diff --git a/packages/trueforge/src/apis/sessions.ts b/packages/trueforge/src/apis/sessions.ts index daefc34c1..23719a7bc 100644 --- a/packages/trueforge/src/apis/sessions.ts +++ b/packages/trueforge/src/apis/sessions.ts @@ -40,7 +40,7 @@ import { } from '../routes/sessionRoutes'; import type { ActiveTurnRegistry } from '../runtime/activeTurns'; import { executorFromTurnId } from '../runtime/peeringIds'; -import { validateAgentSpec } from '../runtime/sessionResources'; +import { validateAgentSpec, validateRepositorySandbox } from '../runtime/sessionResources'; import { isSessionAgentNameRef, type Session } from '../schemas/session'; import { newId } from '../utils/id'; @@ -68,6 +68,7 @@ export function toWireSession(record: SessionRecord): Session { updated_at: record.updated_at.toISOString(), metrics: record.metrics, metadata: record.metadata, + repository: record.repository, }; } @@ -269,11 +270,18 @@ function createGetOrCreateSessionByExternalIdHandler( agent = { type: 'inline', spec: body.agent.spec }; } + await validateRepositorySandbox({ + repository: body.repository ?? null, + tenant_id: TENANT_ID, + sandboxProviderStore: deps.sandboxProviderStore, + }); + const { session, created } = await deps.sessions.getOrCreateByExternalId({ tenant_id: TENANT_ID, external_id: body.external_id, created_by: user.userRef, agent, + repository: body.repository ?? null, }); if (!created && !checkSessionAccess({ userRef: user.userRef, createdBy: session.record.created_by })) { return c.json({ error: { message: FORBIDDEN_SESSION_ACCESS } }, 403); @@ -300,6 +308,11 @@ export function createSessionsRouter(deps: SessionsRouterDeps) { if (agent === undefined) { return c.json({ error: { message: `Agent not found: ${body.agent.name}` } }, 404); } + await validateRepositorySandbox({ + repository: body.repository ?? null, + tenant_id: TENANT_ID, + sandboxProviderStore: deps.sandboxProviderStore, + }); const user = deps.resolveUserContext(c); const session = await deps.sessions.create({ tenant_id: TENANT_ID, @@ -307,6 +320,7 @@ export function createSessionsRouter(deps: SessionsRouterDeps) { created_by: user.userRef, agent: { type: 'reference', id: agent.id, name: agent.name }, metadata: body.metadata, + repository: body.repository ?? null, external_id: null, }); return c.json({ data: toWireSession(session.record) }, 201); @@ -320,6 +334,11 @@ export function createSessionsRouter(deps: SessionsRouterDeps) { skillStore: deps.skillStore, sandboxProviderStore: deps.sandboxProviderStore, }); + await validateRepositorySandbox({ + repository: body.repository ?? null, + tenant_id: TENANT_ID, + sandboxProviderStore: deps.sandboxProviderStore, + }); const user = deps.resolveUserContext(c); const session = await deps.sessions.create({ tenant_id: TENANT_ID, @@ -327,6 +346,7 @@ export function createSessionsRouter(deps: SessionsRouterDeps) { created_by: user.userRef, agent: { type: 'inline', spec: body.agent.spec }, metadata: body.metadata, + repository: body.repository ?? null, external_id: null, }); return c.json({ data: toWireSession(session.record) }, 201); diff --git a/packages/trueforge/src/apis/turns.ts b/packages/trueforge/src/apis/turns.ts index 420069471..eeff5a925 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -2,7 +2,13 @@ * DB-backed turns API (mounted at /api/v1/sessions). */ import { OpenAPIHono, type RouteHandler } from '@hono/zod-openapi'; -import type { ISessionStore, Sessions, Turn, TurnStreamingEvent } from '@truefoundry/trueforge-core/agent-session'; +import type { + ISessionStore, + SessionRepository, + Sessions, + Turn, + TurnStreamingEvent, +} from '@truefoundry/trueforge-core/agent-session'; import { CancellationReason, EventType, @@ -55,6 +61,7 @@ import { getModelDetails, resolveGitSkills, resolveSandboxProvider, + type ResolveRepositoryCredentials, } from '../runtime/sessionResources'; import { checkSnapshotStatus } from '../sandbox/providerUtils'; import { TENANT_ID } from './sessions'; @@ -115,6 +122,7 @@ export interface TurnsRouterDeps { sandboxProviderStore: ISandboxProviderStore; logger: Logger; resolveUserContext: ResolveUserContext; + resolveRepositoryCredentials: ResolveRepositoryCredentials; } /** @@ -124,7 +132,14 @@ export interface TurnsRouterDeps { */ export type BeginTurnExecutionDeps = Pick< TurnsRouterDeps, - 'activeTurns' | 'eventSubscriptions' | 'tokenStore' | 'skillStore' | 'agentStore' | 'sandboxProviderStore' | 'logger' + | 'activeTurns' + | 'eventSubscriptions' + | 'tokenStore' + | 'skillStore' + | 'agentStore' + | 'sandboxProviderStore' + | 'logger' + | 'resolveRepositoryCredentials' > & { modelProviderStore: IModelProviderStore; mcpServerStore: IMcpServerStore; @@ -145,6 +160,8 @@ function createTurnResolver(deps: { signal: AbortSignal; userRef: string; sessionId: string; + repository: SessionRepository | null; + resolveRepositoryCredentials: ResolveRepositoryCredentials; }): TurnResourceResolver { const { mcpServerStore, @@ -157,8 +174,11 @@ function createTurnResolver(deps: { signal, userRef, sessionId, + repository, + resolveRepositoryCredentials, } = deps; return new TurnResourceResolver({ + sandboxRequired: repository !== null, llm: async name => { const resolved = await getModelDetails({ tenant_id: TENANT_ID, @@ -228,6 +248,16 @@ function createTurnResolver(deps: { skills: spec.skills ?? [], store: skillStore, }); + let resolvedGitCredentialsContent: string | null = null; + if (repository !== null && repository.credential_provider_ref !== null) { + resolvedGitCredentialsContent = await resolveRepositoryCredentials({ + tenant_id: TENANT_ID, + session_id: sessionId, + user_ref: userRef, + repository, + signal, + }); + } return buildTurnSandbox({ provider, logger, @@ -235,6 +265,8 @@ function createTurnResolver(deps: { fileDownloadEnabled: spec.config.sandbox.file_downloads, existingSandboxId: carriedSandboxId, tracing, + repository, + resolvedGitCredentialsContent, }); }, agent: async agentId => { @@ -385,6 +417,8 @@ export async function beginTurnExecution(params: { signal: abortController.signal, userRef, sessionId, + repository: session.record.repository, + resolveRepositoryCredentials: deps.resolveRepositoryCredentials, }); // First turn only: derive the title from the first user message. The store diff --git a/packages/trueforge/src/app.ts b/packages/trueforge/src/app.ts index 930ae409b..61605378d 100644 --- a/packages/trueforge/src/app.ts +++ b/packages/trueforge/src/app.ts @@ -43,6 +43,7 @@ import { PACKAGE_VERSION } from './packageVersion'; import { OPENAPI_DOCUMENT_TAGS } from './routes/openapiTags'; import type { ActiveTurnRegistry } from './runtime/activeTurns'; import type { EventSubscriptionRegistry } from './runtime/event-subscription'; +import { rejectUnconfiguredRepositoryCredentials, type ResolveRepositoryCredentials } from './runtime/sessionResources'; import { InvalidCronError } from './schemas/schedule'; import { zodErrorResponse, zodValidationHook } from './zodErrorResponse'; @@ -194,6 +195,8 @@ export interface ServerDeps { logger: Logger; /** Discovered openid-client configuration; undefined when browser login is disabled. */ oidcClient: Configuration | undefined; + /** Resolves short-lived Git credentials per turn; credential material is never persisted. */ + resolveRepositoryCredentials?: ResolveRepositoryCredentials | undefined; } export function createServerApp(deps: ServerDeps) { @@ -299,6 +302,7 @@ export function createServerApp(deps: ServerDeps) { agentStore: deps.agentStore, sandboxProviderStore: deps.sandboxProviderStore, logger: deps.logger, + resolveRepositoryCredentials: deps.resolveRepositoryCredentials ?? rejectUnconfiguredRepositoryCredentials, }, withTransaction: deps.withTransaction, resolveUserContext, @@ -378,6 +382,7 @@ export function createServerApp(deps: ServerDeps) { sandboxProviderStore: deps.sandboxProviderStore, logger: deps.logger, resolveUserContext: resolveUserContext, + resolveRepositoryCredentials: deps.resolveRepositoryCredentials ?? rejectUnconfiguredRepositoryCredentials, }), ), ); diff --git a/packages/trueforge/src/config.ts b/packages/trueforge/src/config.ts index 619844167..5c781ad9e 100644 --- a/packages/trueforge/src/config.ts +++ b/packages/trueforge/src/config.ts @@ -38,6 +38,8 @@ const DEFAULT_POSTGRES_DB = 'trueforge'; const DEFAULT_POSTGRES_HOST = 'localhost'; const DEFAULT_POSTGRES_PORT = 5432; const DEFAULT_REDIS_URL = 'redis://localhost:6379'; +const DEFAULT_REPOSITORY_CREDENTIAL_RESOLVER_TIMEOUT_MS = 10_000; +const DEFAULT_REPOSITORY_CREDENTIAL_RESOLVER_MAX_RESPONSE_BYTES = 65_536; const DEFAULT_OIDC_USER_REFERENCE_CLAIM = 'sub'; const DEFAULT_OIDC_USER_ROLE_CLAIM = 'groups'; @@ -169,6 +171,41 @@ function resolveOptionalPathEnv(envKey: string): string | undefined { return path.resolve(override); } +function resolveOptionalHttpUrl(options: { envKey: string; raw: string | undefined }): string | undefined { + const { envKey, raw } = options; + if (raw === undefined || raw.trim() === '') { + return undefined; + } + let parsed: URL; + try { + parsed = new URL(raw); + } catch (error) { + throw new Error(`Environment variable ${envKey} must be a valid HTTP(S) URL.`, { cause: error }); + } + if ( + (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || + parsed.username !== '' || + parsed.password !== '' + ) { + throw new Error(`Environment variable ${envKey} must be an HTTP(S) URL without embedded credentials.`); + } + return parsed.href; +} + +function resolveOptionalAuthorization(raw: string | undefined): string | undefined { + if (raw === undefined || raw.trim() === '') { + return undefined; + } + try { + new Headers({ authorization: raw }); + } catch (error) { + throw new Error('Environment variable REPOSITORY_CREDENTIAL_RESOLVER_AUTHORIZATION is not a valid header value.', { + cause: error, + }); + } + return raw; +} + /** * Absolute SQLite file path for standalone mode. * Env: `SQLITE_PATH` (optional). Default: `{env-paths data}/db/db.sqlite`. @@ -459,6 +496,14 @@ export interface SharedServerConfiguration { * Env: `TRUEFOUNDRY_MTLS_CERTS_DIR`. Default `/etc/tls/truefoundry`. */ TRUEFOUNDRY_MTLS_CERTS_DIR: string; + /** Optional external resolver endpoint for repository Git credentials. */ + REPOSITORY_CREDENTIAL_RESOLVER_URL: string | undefined; + /** Optional complete Authorization header value sent only to the resolver. */ + REPOSITORY_CREDENTIAL_RESOLVER_AUTHORIZATION: string | undefined; + /** Max milliseconds for one external repository credential resolution. Default 10000. */ + REPOSITORY_CREDENTIAL_RESOLVER_TIMEOUT_MS: number; + /** Max bytes accepted from the external resolver. Default 65536. */ + REPOSITORY_CREDENTIAL_RESOLVER_MAX_RESPONSE_BYTES: number; } export type StandaloneServerConfiguration = SharedServerConfiguration & { @@ -627,6 +672,23 @@ const shared: SharedServerConfiguration = { }), TRUEFOUNDRY_MTLS_CERTS_DIR: getEnv('TRUEFOUNDRY_MTLS_CERTS_DIR', { defaultValue: '/etc/tls/truefoundry' }) ?? '/etc/tls/truefoundry', + REPOSITORY_CREDENTIAL_RESOLVER_URL: resolveOptionalHttpUrl({ + envKey: 'REPOSITORY_CREDENTIAL_RESOLVER_URL', + raw: getEnv('REPOSITORY_CREDENTIAL_RESOLVER_URL'), + }), + REPOSITORY_CREDENTIAL_RESOLVER_AUTHORIZATION: resolveOptionalAuthorization( + getEnv('REPOSITORY_CREDENTIAL_RESOLVER_AUTHORIZATION'), + ), + REPOSITORY_CREDENTIAL_RESOLVER_TIMEOUT_MS: parsePositiveInt({ + envKey: 'REPOSITORY_CREDENTIAL_RESOLVER_TIMEOUT_MS', + raw: getEnv('REPOSITORY_CREDENTIAL_RESOLVER_TIMEOUT_MS'), + defaultValue: DEFAULT_REPOSITORY_CREDENTIAL_RESOLVER_TIMEOUT_MS, + }), + REPOSITORY_CREDENTIAL_RESOLVER_MAX_RESPONSE_BYTES: parsePositiveInt({ + envKey: 'REPOSITORY_CREDENTIAL_RESOLVER_MAX_RESPONSE_BYTES', + raw: getEnv('REPOSITORY_CREDENTIAL_RESOLVER_MAX_RESPONSE_BYTES'), + defaultValue: DEFAULT_REPOSITORY_CREDENTIAL_RESOLVER_MAX_RESPONSE_BYTES, + }), }; const configuration: ServerConfiguration = standalone diff --git a/packages/trueforge/src/db/postgres/migrations/20260902_000002_session_repository.ts b/packages/trueforge/src/db/postgres/migrations/20260902_000002_session_repository.ts new file mode 100644 index 000000000..841c2fd80 --- /dev/null +++ b/packages/trueforge/src/db/postgres/migrations/20260902_000002_session_repository.ts @@ -0,0 +1,11 @@ +import { sql, type Kysely } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`SET LOCAL lock_timeout = '5s'`.execute(db); + await sql`ALTER TABLE session ADD COLUMN repository jsonb`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`SET LOCAL lock_timeout = '5s'`.execute(db); + await sql`ALTER TABLE session DROP COLUMN repository`.execute(db); +} diff --git a/packages/trueforge/src/db/postgres/session-store/queries/sessions.ts b/packages/trueforge/src/db/postgres/session-store/queries/sessions.ts index c08821e83..4ba3933f9 100644 --- a/packages/trueforge/src/db/postgres/session-store/queries/sessions.ts +++ b/packages/trueforge/src/db/postgres/session-store/queries/sessions.ts @@ -1,5 +1,10 @@ -import type { AgentSpec, SessionMetadata, SessionMetrics } from '@truefoundry/trueforge-core/agent-session'; -import { SessionMetadataSchema } from '@truefoundry/trueforge-core/agent-session'; +import type { + AgentSpec, + SessionMetadata, + SessionMetrics, + SessionRepository, +} from '@truefoundry/trueforge-core/agent-session'; +import { SessionMetadataSchema, SessionRepositorySchema } from '@truefoundry/trueforge-core/agent-session'; import type { SessionRecord } from '@truefoundry/trueforge-core/agent-session/models/SessionRecord'; import type { CreateSessionInput, @@ -47,6 +52,10 @@ function parseSessionMetadata(value: unknown): SessionMetadata { return SessionMetadataSchema.parse(value); } +function parseSessionRepository(value: unknown): SessionRepository | null { + return value === null ? null : SessionRepositorySchema.parse(value); +} + function mapRowToSessionRecord(row: { tenant_id: string; session_id: string; @@ -59,6 +68,7 @@ function mapRowToSessionRecord(row: { external_id: string | null; custom: Record | null; metadata: SessionMetadata; + repository: SessionRepository | null; metrics: SessionMetrics; created_at: Date; updated_at: Date; @@ -79,6 +89,7 @@ function mapRowToSessionRecord(row: { external_id: row.external_id, custom: parseSessionCustom(row.custom), metadata: parseSessionMetadata(row.metadata), + repository: parseSessionRepository(row.repository), metrics: row.metrics, created_at: row.created_at, updated_at: row.updated_at, @@ -103,6 +114,7 @@ export async function createSession(db: Kysely, input: CreateSessionIn title: null, custom: input.custom !== null ? json(input.custom) : null, metadata: json(input.metadata), + repository: input.repository !== null ? json(input.repository) : null, external_id: input.external_id, metrics: json({ total_cost_in_usd: 0, diff --git a/packages/trueforge/src/db/postgres/types.ts b/packages/trueforge/src/db/postgres/types.ts index 20d35853f..25b4978fd 100644 --- a/packages/trueforge/src/db/postgres/types.ts +++ b/packages/trueforge/src/db/postgres/types.ts @@ -7,6 +7,7 @@ import type { PersistedTurnEvent, SessionMetadata, SessionMetrics, + SessionRepository, TurnInputItem, TurnState, } from '@truefoundry/trueforge-core/agent-session'; @@ -90,6 +91,8 @@ export interface SessionTable { custom: JSONColumnType, Record, Record> | null; /** Caller-owned metadata; always present (DEFAULT '{}'). */ metadata: JSONColumnType; + /** Sandbox checkout metadata only; resolved credentials are never stored. */ + repository: JSONColumnType | null; metrics: JSONColumnType; /** top: list ordering (indexed below) */ created_at: Date; diff --git a/packages/trueforge/src/db/sqlite/client.ts b/packages/trueforge/src/db/sqlite/client.ts index b566ccfb4..d789e3b0a 100644 --- a/packages/trueforge/src/db/sqlite/client.ts +++ b/packages/trueforge/src/db/sqlite/client.ts @@ -123,6 +123,7 @@ function applyPragmas(database: Database.Database): void { */ const JSON_RESULT_COLUMNS = new Set([ 'agent_spec', + 'repository', 'custom', 'metadata', 'metrics', diff --git a/packages/trueforge/src/db/sqlite/migrations/20260902_000002_session_repository.ts b/packages/trueforge/src/db/sqlite/migrations/20260902_000002_session_repository.ts new file mode 100644 index 000000000..98721ca67 --- /dev/null +++ b/packages/trueforge/src/db/sqlite/migrations/20260902_000002_session_repository.ts @@ -0,0 +1,13 @@ +import { sql, type Kysely } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.transaction().execute(async trx => { + await sql`ALTER TABLE session ADD COLUMN repository BLOB`.execute(trx); + }); +} + +export async function down(db: Kysely): Promise { + await db.transaction().execute(async trx => { + await sql`ALTER TABLE session DROP COLUMN repository`.execute(trx); + }); +} diff --git a/packages/trueforge/src/db/sqlite/session-store/queries/sessions.ts b/packages/trueforge/src/db/sqlite/session-store/queries/sessions.ts index 4aac39f00..8eaadaf65 100644 --- a/packages/trueforge/src/db/sqlite/session-store/queries/sessions.ts +++ b/packages/trueforge/src/db/sqlite/session-store/queries/sessions.ts @@ -1,5 +1,10 @@ -import type { AgentSpec, SessionMetadata, SessionMetrics } from '@truefoundry/trueforge-core/agent-session'; -import { SessionMetadataSchema } from '@truefoundry/trueforge-core/agent-session'; +import type { + AgentSpec, + SessionMetadata, + SessionMetrics, + SessionRepository, +} from '@truefoundry/trueforge-core/agent-session'; +import { SessionMetadataSchema, SessionRepositorySchema } from '@truefoundry/trueforge-core/agent-session'; import type { SessionRecord } from '@truefoundry/trueforge-core/agent-session/models/SessionRecord'; import type { CreateSessionInput, @@ -46,6 +51,10 @@ function parseSessionMetadata(value: unknown): SessionMetadata { return SessionMetadataSchema.parse(value); } +function parseSessionRepository(value: unknown): SessionRepository | null { + return value === null ? null : SessionRepositorySchema.parse(value); +} + function mapRowToSessionRecord(row: { tenant_id: string; session_id: string; @@ -58,6 +67,7 @@ function mapRowToSessionRecord(row: { external_id: string | null; custom: Record | null; metadata: SessionMetadata; + repository: SessionRepository | null; metrics: SessionMetrics; created_at: string; updated_at: string; @@ -78,6 +88,7 @@ function mapRowToSessionRecord(row: { external_id: row.external_id, custom: parseSessionCustom(row.custom), metadata: parseSessionMetadata(row.metadata), + repository: parseSessionRepository(row.repository), metrics: row.metrics, created_at: new Date(row.created_at), updated_at: new Date(row.updated_at), @@ -98,6 +109,7 @@ function sessionSelectColumns() { 'external_id' as const, jsonText | null>(sql.ref('custom')).as('custom'), jsonText(sql.ref('metadata')).as('metadata'), + jsonText(sql.ref('repository')).as('repository'), jsonText(sql.ref('metrics')).as('metrics'), 'created_at' as const, 'updated_at' as const, @@ -122,6 +134,7 @@ export async function createSession(db: Kysely, input: CreateSessionIn title: null, custom: input.custom !== null ? jsonbBind(input.custom) : null, metadata: jsonbBind(input.metadata), + repository: input.repository !== null ? jsonbBind(input.repository) : null, external_id: input.external_id, metrics: jsonbBind({ total_cost_in_usd: 0, diff --git a/packages/trueforge/src/db/sqlite/types.ts b/packages/trueforge/src/db/sqlite/types.ts index 522f9b71c..d04e878a3 100644 --- a/packages/trueforge/src/db/sqlite/types.ts +++ b/packages/trueforge/src/db/sqlite/types.ts @@ -10,6 +10,7 @@ import type { PersistedTurnEvent, SessionMetadata, SessionMetrics, + SessionRepository, TurnInputItem, TurnState, } from '@truefoundry/trueforge-core/agent-session'; @@ -74,6 +75,8 @@ export interface SessionTable { external_id: string | null; custom: JsonbColumn> | null; metadata: JsonbColumn; + /** Sandbox checkout metadata only; resolved credentials are never stored. */ + repository: JsonbColumn | null; metrics: JsonbColumn; created_at: string; updated_at: string; diff --git a/packages/trueforge/src/main.ts b/packages/trueforge/src/main.ts index b3874a51c..66ba356d7 100644 --- a/packages/trueforge/src/main.ts +++ b/packages/trueforge/src/main.ts @@ -72,6 +72,7 @@ import type { IOAuthTokenStore } from './mcp/auth/types'; import { PACKAGE_VERSION } from './packageVersion'; import { ActiveTurnRegistry } from './runtime/activeTurns'; import { EventSubscriptionRegistry } from './runtime/event-subscription'; +import { createExternalRepositoryCredentialResolver } from './runtime/repositoryCredentialResolver'; import { printStandaloneStartupBanner } from './startupBanner'; import { TrueFoundryMcpServerStore } from './truefoundry/TrueFoundryMcpServerStore'; import { TrueFoundryModelProviderStore } from './truefoundry/TrueFoundryModelProviderStore'; @@ -315,6 +316,15 @@ async function createServerRuntime(persistence: ServerPersistence< logger.warn('Auth is disabled; browser login is off'); } const oidcClient = await initOidc(oidc); + const resolveRepositoryCredentials = + configuration.REPOSITORY_CREDENTIAL_RESOLVER_URL === undefined + ? undefined + : createExternalRepositoryCredentialResolver({ + endpoint: new URL(configuration.REPOSITORY_CREDENTIAL_RESOLVER_URL), + authorization: configuration.REPOSITORY_CREDENTIAL_RESOLVER_AUTHORIZATION, + timeoutMs: configuration.REPOSITORY_CREDENTIAL_RESOLVER_TIMEOUT_MS, + maxResponseBytes: configuration.REPOSITORY_CREDENTIAL_RESOLVER_MAX_RESPONSE_BYTES, + }); // Standalone is one process, so it owns the control loops too. const controller = configuration.STANDALONE @@ -348,6 +358,7 @@ async function createServerRuntime(persistence: ServerPersistence< eventSubscriptions, logger, oidcClient, + ...(resolveRepositoryCredentials === undefined ? {} : { resolveRepositoryCredentials }), }); return { activeTurns, app, controller, destroyDb, redis, requestReplyRouter }; diff --git a/packages/trueforge/src/runtime/repositoryCredentialResolver.ts b/packages/trueforge/src/runtime/repositoryCredentialResolver.ts new file mode 100644 index 000000000..88ac56369 --- /dev/null +++ b/packages/trueforge/src/runtime/repositoryCredentialResolver.ts @@ -0,0 +1,177 @@ +import { HTTPException } from 'hono/http-exception'; +import { z } from 'zod'; + +import type { ResolveRepositoryCredentials } from './sessionResources'; + +const ExternalRepositoryCredentialResponseSchema = z + .object({ + credentials: z.string().min(1), + }) + .strict(); + +type ExternalRepositoryCredentialResponse = z.infer; + +type ResponseBodyReadResult = { done: true } | { done: false; value: Uint8Array }; + +export interface ExternalRepositoryCredentialResolverOptions { + endpoint: URL; + authorization: string | undefined; + timeoutMs: number; + maxResponseBytes: number; + fetchImpl?: typeof fetch | undefined; +} + +function resolverError(options: { message: string; cause: unknown }): HTTPException { + return new HTTPException(424, options); +} + +function parseResponseBodyReadResult(value: unknown): ResponseBodyReadResult { + if (typeof value !== 'object' || value === null || !('done' in value) || typeof value.done !== 'boolean') { + throw new Error('Repository credential resolver returned an unreadable response'); + } + if (value.done) { + return { done: true }; + } + if (!('value' in value) || !(value.value instanceof Uint8Array)) { + throw new Error('Repository credential resolver returned an unreadable response'); + } + return { done: false, value: value.value }; +} + +async function readBoundedResponseBody(options: { response: Response; maxBytes: number }): Promise { + const { response, maxBytes } = options; + const contentLength = response.headers.get('content-length'); + if (contentLength !== null) { + const declaredBytes = Number(contentLength); + if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) { + await response.body?.cancel().catch(() => undefined); + throw new Error('Repository credential resolver response exceeded the configured size limit'); + } + } + + if (response.body === null) { + return ''; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const chunks: string[] = []; + let receivedBytes = 0; + try { + for (;;) { + const rawResult: unknown = await reader.read(); + const result = parseResponseBodyReadResult(rawResult); + if (result.done) { + break; + } + receivedBytes += result.value.byteLength; + if (receivedBytes > maxBytes) { + await reader.cancel().catch(() => undefined); + throw new Error('Repository credential resolver response exceeded the configured size limit'); + } + chunks.push(decoder.decode(result.value, { stream: true })); + } + chunks.push(decoder.decode()); + return chunks.join(''); + } finally { + reader.releaseLock(); + } +} + +function isGitCredentialStoreContent(value: string): boolean { + const lines = value.split(/\r?\n/).filter(line => line.length > 0); + if (lines.length === 0) { + return false; + } + return lines.every(line => { + if (line !== line.trim() || line.includes('\r')) { + return false; + } + try { + const credentialUrl = new URL(line); + return ( + (credentialUrl.protocol === 'http:' || credentialUrl.protocol === 'https:') && + credentialUrl.hostname !== '' && + (credentialUrl.username !== '' || credentialUrl.password !== '') + ); + } catch { + return false; + } + }); +} + +function parseResolverResponse(body: string): ExternalRepositoryCredentialResponse { + let payload: unknown; + try { + payload = JSON.parse(body); + } catch (error) { + throw resolverError({ message: 'Repository credential resolver returned an invalid response', cause: error }); + } + + const parsed = ExternalRepositoryCredentialResponseSchema.safeParse(payload); + if (!parsed.success || !isGitCredentialStoreContent(parsed.data.credentials)) { + throw new HTTPException(424, { message: 'Repository credential resolver returned an invalid response' }); + } + return parsed.data; +} + +/** + * Adapts the in-process repository credential resolver contract to an operator-owned HTTP endpoint. + * The response body is bounded before parsing and is never included in errors or logs. + */ +export function createExternalRepositoryCredentialResolver( + options: ExternalRepositoryCredentialResolverOptions, +): ResolveRepositoryCredentials { + const fetchImpl = options.fetchImpl ?? globalThis.fetch; + return async input => { + const credentialProviderRef = input.repository.credential_provider_ref; + if (credentialProviderRef === null) { + throw new HTTPException(422, { message: 'Repository credential provider reference is required' }); + } + + const headers = new Headers({ + accept: 'application/json', + 'content-type': 'application/json', + }); + if (options.authorization !== undefined) { + headers.set('authorization', options.authorization); + } + + const timeoutSignal = AbortSignal.timeout(options.timeoutMs); + const signal = AbortSignal.any([input.signal, timeoutSignal]); + let response: Response; + try { + response = await fetchImpl(options.endpoint, { + method: 'POST', + headers, + signal, + body: JSON.stringify({ + credential_provider_ref: credentialProviderRef, + tenant_id: input.tenant_id, + session_id: input.session_id, + user_ref: input.user_ref, + repository: { + url: input.repository.url, + ref: input.repository.ref, + access: input.repository.access, + }, + }), + }); + } catch (error) { + throw resolverError({ message: 'Repository credential resolver request failed', cause: error }); + } + + if (!response.ok) { + await response.body?.cancel().catch(() => undefined); + throw new HTTPException(424, { message: 'Repository credential resolver rejected the request' }); + } + + let body: string; + try { + body = await readBoundedResponseBody({ response, maxBytes: options.maxResponseBytes }); + } catch (error) { + throw resolverError({ message: 'Repository credential resolver returned an invalid response', cause: error }); + } + return parseResolverResponse(body).credentials; + }; +} diff --git a/packages/trueforge/src/runtime/sessionResources.ts b/packages/trueforge/src/runtime/sessionResources.ts index 862ea0674..46dc8ec56 100644 --- a/packages/trueforge/src/runtime/sessionResources.ts +++ b/packages/trueforge/src/runtime/sessionResources.ts @@ -1,7 +1,7 @@ /** * Store-backed model/MCP/skill/sandbox resolution for session admit and turns. */ -import type { AgentSpec } from '@truefoundry/trueforge-core/agent-session'; +import type { AgentSpec, SessionRepository } from '@truefoundry/trueforge-core/agent-session'; import { Sandbox, SkillMounter, @@ -33,6 +33,21 @@ export interface McpConnection { headers: RemoteMcpHeaders; } +export type ResolveRepositoryCredentials = (input: { + tenant_id: string; + session_id: string; + user_ref: string; + repository: SessionRepository; + signal: AbortSignal; +}) => Promise; + +export const rejectUnconfiguredRepositoryCredentials: ResolveRepositoryCredentials = input => + Promise.reject( + new HTTPException(422, { + message: `Repository credential provider "${input.repository.credential_provider_ref ?? ''}" is not configured`, + }), + ); + /** Split `provider/model` FQN. Returns undefined when the shape is not exactly one slash. */ export function parseModelFqn(name: string): { providerName: string; modelName: string } | undefined { const slash = name.indexOf('/'); @@ -276,6 +291,8 @@ export function buildTurnSandbox(input: { fileDownloadEnabled: boolean; existingSandboxId?: string | undefined; tracing: AgentTracing; + repository: SessionRepository | null; + resolvedGitCredentialsContent: string | null; }): Sandbox { const skillMounter = input.gitSkills.length > 0 ? new SkillMounter([...input.gitSkills]) : undefined; return new Sandbox({ @@ -288,6 +305,8 @@ export function buildTurnSandbox(input: { ...(skillMounter ? { skillMounter } : {}), tracing: input.tracing, logger: input.logger, + repository: input.repository, + resolvedGitCredentialsContent: input.resolvedGitCredentialsContent, }); } @@ -367,3 +386,23 @@ export async function validateAgentSpec({ } } } + +export async function validateRepositorySandbox({ + repository, + tenant_id, + sandboxProviderStore, +}: { + repository: SessionRepository | null; + tenant_id: string; + sandboxProviderStore: ISandboxProviderStore; +}): Promise { + if (repository === null) { + return; + } + const record = await sandboxProviderStore.getSandboxProvider(tenant_id); + if (record === undefined && !isLocalSandboxFallbackEnabled()) { + throw new HTTPException(422, { + message: 'repository checkouts require a sandbox provider — configure via PUT /settings/sandbox-providers', + }); + } +} diff --git a/packages/trueforge/src/schemas/session.ts b/packages/trueforge/src/schemas/session.ts index 2dad089bb..ee5a79f79 100644 --- a/packages/trueforge/src/schemas/session.ts +++ b/packages/trueforge/src/schemas/session.ts @@ -3,6 +3,7 @@ import { z } from '@hono/zod-openapi'; import { AgentSpecSchema, SessionMetadataSchema, + SessionRepositorySchema, SessionSchema, TokenPaginationSchema, } from '@truefoundry/trueforge-core/agent-session'; @@ -35,6 +36,7 @@ export const CreateSessionRequestSchema = z .object({ agent: CreateSessionAgentSchema, metadata: SessionMetadataSchema.optional(), + repository: SessionRepositorySchema.optional(), }) .strict() .openapi('CreateSessionRequest'); @@ -43,6 +45,7 @@ export const GetOrCreateSessionByExternalIdRequestSchema = z .object({ external_id: z.string().min(1).max(128).describe('Caller-supplied id unique within the tenant.'), agent: CreateSessionAgentSchema, + repository: SessionRepositorySchema.optional(), }) .strict() .openapi('GetOrCreateSessionByExternalIdRequest'); diff --git a/packages/trueforge/tests/db/session-metrics/metricsContractSuite.ts b/packages/trueforge/tests/db/session-metrics/metricsContractSuite.ts index 52e5a2b3e..c0d916ea8 100644 --- a/packages/trueforge/tests/db/session-metrics/metricsContractSuite.ts +++ b/packages/trueforge/tests/db/session-metrics/metricsContractSuite.ts @@ -33,6 +33,7 @@ export function runSessionMetricsStoreContractSuite( agent: { type: 'reference', id: 'agent-abc', name: 'Agent ABC' }, custom: null, metadata: {}, + repository: null, external_id: null, }); await sessionStore.createSession({ @@ -42,6 +43,7 @@ export function runSessionMetricsStoreContractSuite( agent: { type: 'reference', id: 'agent-abc', name: 'Agent ABC' }, custom: null, metadata: {}, + repository: null, external_id: null, }); await sessionStore.createTurn(makeCreateTurnInput({ sessionId: 'metrics-session', turnId: 'metrics-turn' })); @@ -122,6 +124,7 @@ export function runSessionMetricsStoreContractSuite( agent: { type: 'reference', id: 'agent-distributions', name: 'Agent Distributions' }, custom: null, metadata: {}, + repository: null, external_id: null, }); for (const [index, durationMs] of definition.turnDurations.entries()) { @@ -172,6 +175,7 @@ export function runSessionMetricsStoreContractSuite( agent: { type: 'reference', id: 'agent-inflight', name: 'Agent InFlight' }, custom: null, metadata: {}, + repository: null, external_id: null, }); await sessionStore.createSession({ @@ -181,6 +185,7 @@ export function runSessionMetricsStoreContractSuite( agent: { type: 'reference', id: 'agent-inflight', name: 'Agent InFlight' }, custom: null, metadata: {}, + repository: null, external_id: null, }); await sessionStore.createTurn(makeCreateTurnInput({ sessionId: 'inflight-session', turnId: 'inflight-turn' })); diff --git a/packages/trueforge/tests/unit/apis/deletedSessionCrud.test.ts b/packages/trueforge/tests/unit/apis/deletedSessionCrud.test.ts index d8f7a5d65..b965d7b31 100644 --- a/packages/trueforge/tests/unit/apis/deletedSessionCrud.test.ts +++ b/packages/trueforge/tests/unit/apis/deletedSessionCrud.test.ts @@ -66,6 +66,7 @@ describe('public CRUD after session deletion', () => { sandboxProviderStore, logger: createLogger({ silent: true }), resolveUserContext: () => LOCAL_USER_CONTEXT, + resolveRepositoryCredentials: () => Promise.resolve(null), }), ); @@ -82,6 +83,7 @@ describe('public CRUD after session deletion', () => { }, custom: null, metadata: {}, + repository: null, external_id: null, }); expect((await app.request('/s1', { method: 'DELETE' })).status).toBe(204); diff --git a/packages/trueforge/tests/unit/apis/sandboxFileDownload.test.ts b/packages/trueforge/tests/unit/apis/sandboxFileDownload.test.ts index a70d6107a..7a042d473 100644 --- a/packages/trueforge/tests/unit/apis/sandboxFileDownload.test.ts +++ b/packages/trueforge/tests/unit/apis/sandboxFileDownload.test.ts @@ -47,6 +47,7 @@ async function buildApp() { sandboxProviderStore: new SqliteSandboxProviderStore(db), logger: createLogger({ silent: true }), resolveUserContext: () => LOCAL_USER_CONTEXT, + resolveRepositoryCredentials: () => Promise.resolve(null), }), ); diff --git a/packages/trueforge/tests/unit/apis/schedules.test.ts b/packages/trueforge/tests/unit/apis/schedules.test.ts index abdfff846..3d569a8a0 100644 --- a/packages/trueforge/tests/unit/apis/schedules.test.ts +++ b/packages/trueforge/tests/unit/apis/schedules.test.ts @@ -63,6 +63,7 @@ async function setup() { skillStore: {} as never, agentStore, sandboxProviderStore: {} as never, + resolveRepositoryCredentials: () => Promise.resolve(null), logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() } as never, }, withTransaction: callback => db.transaction().execute(callback), diff --git a/packages/trueforge/tests/unit/apis/sessionHttp.test.ts b/packages/trueforge/tests/unit/apis/sessionHttp.test.ts index 3f1767bc8..36b77b513 100644 --- a/packages/trueforge/tests/unit/apis/sessionHttp.test.ts +++ b/packages/trueforge/tests/unit/apis/sessionHttp.test.ts @@ -1,5 +1,5 @@ -import { OpenAPIHono } from '@hono/zod-openapi'; -import { AgentSpecSchema, Sessions } from '@truefoundry/trueforge-core/agent-session'; +import { OpenAPIHono, z } from '@hono/zod-openapi'; +import { AgentSpecSchema, Sessions, SessionSchema } from '@truefoundry/trueforge-core/agent-session'; import { RequestReplyRouter } from '@truefoundry/trueforge-core/request-reply'; import { createClient } from 'redis'; import { createLogger } from 'winston'; @@ -21,6 +21,7 @@ import { SqliteSessionMetricsStore } from '../../../src/db/sqlite/session-metric import { SqliteSessionStore } from '../../../src/db/sqlite/session-store/SqliteSessionStore'; import { SqliteSkillStore } from '../../../src/db/sqlite/skill-store/SqliteSkillStore'; import { ActiveTurnRegistry } from '../../../src/runtime/activeTurns'; +import { setCachedLocalSandboxSupport } from '../../../src/sandbox/localRuntime'; import { GetSessionMetricsChartDataResponseSchema, GetSessionMetricsChartResponseSchema, @@ -45,6 +46,10 @@ describe('sessions HTTP agent binding', () => { let agentStore: SqliteAgentStore; let sessionStore: SqliteSessionStore; + afterEach(() => { + setCachedLocalSandboxSupport(undefined); + }); + beforeEach(async () => { const db = createSqliteDb(':memory:'); await migrateSqliteToLatest(db); @@ -122,6 +127,47 @@ describe('sessions HTTP agent binding', () => { expect(missing.status).toBe(404); }); + it('creates and returns a repository-backed session', async () => { + setCachedLocalSandboxSupport({ + supported: true, + platform: 'darwin', + shell: '/bin/bash', + python: '/usr/bin/python3', + }); + const repository = { + url: 'https://github.com/example/repository.git', + ref: 'main', + path: 'workspace/repository', + access: 'read_write', + credential_provider_ref: 'github-app:installation-123', + }; + const response = await app.request('/', jsonInit('POST', { agent: { spec: inlineSpec }, repository })); + + expect(response.status).toBe(201); + const payload = z.object({ data: SessionSchema }).parse(await response.json()).data; + expect(payload.repository).toEqual(repository); + }); + + it('rejects a repository-backed session when no sandbox provider is available', async () => { + const response = await app.request( + '/', + jsonInit('POST', { + agent: { spec: inlineSpec }, + repository: { + url: 'https://github.com/example/repository.git', + ref: 'main', + path: 'workspace/repository', + access: 'read_only', + }, + }), + ); + + expect(response.status).toBe(422); + await expect(response.text()).resolves.toBe( + 'repository checkouts require a sandbox provider — configure via PUT /settings/sandbox-providers', + ); + }); + it('creates a named session and filters list by agent_id', async () => { const agent = await agentStore.createAgent({ tenant_id: TENANT_ID, @@ -161,6 +207,7 @@ describe('sessions HTTP agent binding', () => { agent: { type: 'reference', id: agent.id, name: agent.name }, custom: null, metadata: {}, + repository: null, external_id: null, }); await sessionStore.createSession({ @@ -170,6 +217,7 @@ describe('sessions HTTP agent binding', () => { agent: { type: 'reference', id: agent.id, name: agent.name }, custom: null, metadata: {}, + repository: null, external_id: null, }); const start = new Date(Date.now() - 60 * 60 * 1000); @@ -232,6 +280,7 @@ describe('sessions HTTP agent binding', () => { agent: { type: 'inline', spec: inlineSpec }, custom: null, metadata: {}, + repository: null, external_id: null, }); @@ -399,6 +448,7 @@ describe('sessions HTTP agent binding', () => { agent: { type: 'inline', spec: inlineSpec }, custom: null, metadata: {}, + repository: null, external_id: 'run-theirs', }); const forbidden = await app.request( diff --git a/packages/trueforge/tests/unit/apis/turnHarnessErrorStatus.test.ts b/packages/trueforge/tests/unit/apis/turnHarnessErrorStatus.test.ts index 2a7b6f76a..8ed95a8ae 100644 --- a/packages/trueforge/tests/unit/apis/turnHarnessErrorStatus.test.ts +++ b/packages/trueforge/tests/unit/apis/turnHarnessErrorStatus.test.ts @@ -64,6 +64,7 @@ async function postTurnRejectingWith(error: AgentHarnessError): Promise LOCAL_USER_CONTEXT, + resolveRepositoryCredentials: () => Promise.resolve(null), }), ); diff --git a/packages/trueforge/tests/unit/apis/turns.test.ts b/packages/trueforge/tests/unit/apis/turns.test.ts index 24cfe0f4e..e6e78de38 100644 --- a/packages/trueforge/tests/unit/apis/turns.test.ts +++ b/packages/trueforge/tests/unit/apis/turns.test.ts @@ -42,6 +42,7 @@ describe('turns', () => { }, custom: null, metadata: {}, + repository: null, external_id: null, }); @@ -61,6 +62,7 @@ describe('turns', () => { sandboxProviderStore: new SqliteSandboxProviderStore(db), logger: createLogger({ silent: true }), resolveUserContext: () => LOCAL_USER_CONTEXT, + resolveRepositoryCredentials: () => Promise.resolve(null), }), ); @@ -180,6 +182,7 @@ describe('turns', () => { sandboxProviderStore: new SqliteSandboxProviderStore(db), logger, resolveUserContext: () => LOCAL_USER_CONTEXT, + resolveRepositoryCredentials: () => Promise.resolve(null), }), ); @@ -280,6 +283,7 @@ describe('turns', () => { sandboxProviderStore: new SqliteSandboxProviderStore(db), logger, resolveUserContext: () => LOCAL_USER_CONTEXT, + resolveRepositoryCredentials: () => Promise.resolve(null), }), ); diff --git a/packages/trueforge/tests/unit/runtime/repositoryCredentialResolver.test.ts b/packages/trueforge/tests/unit/runtime/repositoryCredentialResolver.test.ts new file mode 100644 index 000000000..4fa14e423 --- /dev/null +++ b/packages/trueforge/tests/unit/runtime/repositoryCredentialResolver.test.ts @@ -0,0 +1,177 @@ +import type { SessionRepository } from '@truefoundry/trueforge-core/agent-session'; +import { HTTPException } from 'hono/http-exception'; + +import { createExternalRepositoryCredentialResolver } from '../../../src/runtime/repositoryCredentialResolver'; + +const repository: SessionRepository = { + url: 'https://git.example.com/acme/widgets.git', + ref: 'feature/widgets', + path: 'workspace/widgets', + access: 'read_write', + credential_provider_ref: 'installation:example-123', +}; + +function resolverInput(signal: AbortSignal = new AbortController().signal) { + return { + tenant_id: 'tenant-123', + session_id: 'session-123', + user_ref: 'user-123', + repository, + signal, + }; +} + +function resolverWith(fetchImpl: typeof fetch, overrides: { maxResponseBytes?: number; timeoutMs?: number } = {}) { + return createExternalRepositoryCredentialResolver({ + endpoint: new URL('https://resolver.example.com/v1/repository-credentials'), + authorization: 'Bearer resolver-secret', + timeoutMs: overrides.timeoutMs ?? 1_000, + maxResponseBytes: overrides.maxResponseBytes ?? 1_024, + fetchImpl, + }); +} + +async function rejectionMessage(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + throw new Error('Expected promise to reject'); +} + +describe('createExternalRepositoryCredentialResolver', () => { + it('sends provider-neutral authorization context and returns credential-store content', async () => { + const requests: Request[] = []; + let call = 0; + const fetchImpl: typeof fetch = async (input, init) => { + requests.push(new Request(input, init)); + call += 1; + return Response.json({ credentials: `https://oauth2:token-${String(call)}@git.example.com\n` }); + }; + const resolver = resolverWith(fetchImpl); + + await expect(resolver(resolverInput())).resolves.toBe('https://oauth2:token-1@git.example.com\n'); + await expect(resolver(resolverInput())).resolves.toBe('https://oauth2:token-2@git.example.com\n'); + + expect(requests).toHaveLength(2); + const request = requests[0]; + if (request === undefined) { + throw new Error('Expected resolver request'); + } + expect(request.method).toBe('POST'); + expect(request.headers.get('accept')).toBe('application/json'); + expect(request.headers.get('content-type')).toBe('application/json'); + expect(request.headers.get('authorization')).toBe('Bearer resolver-secret'); + expect(await request.text()).toBe( + JSON.stringify({ + credential_provider_ref: 'installation:example-123', + tenant_id: 'tenant-123', + session_id: 'session-123', + user_ref: 'user-123', + repository: { + url: 'https://git.example.com/acme/widgets.git', + ref: 'feature/widgets', + access: 'read_write', + }, + }), + ); + }); + + it('omits authorization when none is configured', async () => { + let authorization: string | null = 'not-called'; + const fetchImpl: typeof fetch = async (input, init) => { + authorization = new Request(input, init).headers.get('authorization'); + return Response.json({ credentials: 'https://token@git.example.com\n' }); + }; + const resolver = createExternalRepositoryCredentialResolver({ + endpoint: new URL('http://localhost:9090/resolve'), + authorization: undefined, + timeoutMs: 1_000, + maxResponseBytes: 1_024, + fetchImpl, + }); + + await resolver(resolverInput()); + + expect(authorization).toBeNull(); + }); + + it('cancels a request at the configured timeout', async () => { + const fetchImpl: typeof fetch = (_input, init) => + new Promise((_resolve, reject) => { + const signal = init?.signal; + if (signal?.aborted) { + reject(signal.reason); + return; + } + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + const resolver = resolverWith(fetchImpl, { timeoutMs: 10 }); + + await expect(resolver(resolverInput())).rejects.toMatchObject({ + status: 424, + message: 'Repository credential resolver request failed', + } satisfies Partial); + }); + + it('forwards caller cancellation to the request without exposing its reason', async () => { + const fetchImpl: typeof fetch = (_input, init) => + new Promise((_resolve, reject) => { + const signal = init?.signal; + if (signal?.aborted) { + reject(signal.reason); + return; + } + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + const controller = new AbortController(); + const resolver = resolverWith(fetchImpl); + const pending = resolver(resolverInput(controller.signal)); + + controller.abort(new Error('sensitive caller cancellation detail')); + + await expect(pending).rejects.toMatchObject({ + status: 424, + message: 'Repository credential resolver request failed', + } satisfies Partial); + }); + + it('rejects oversized responses without exposing their content', async () => { + const secret = 'credential-response-secret'; + const fetchImpl: typeof fetch = async () => Response.json({ credentials: `https://${secret}@example.com` }); + const resolver = resolverWith(fetchImpl, { maxResponseBytes: 16 }); + + const message = await rejectionMessage(resolver(resolverInput())); + + expect(message).toBe('Repository credential resolver returned an invalid response'); + expect(message).not.toContain(secret); + }); + + it('rejects resolver errors without reading or exposing the response body', async () => { + const secret = 'resolver-error-secret'; + const fetchImpl: typeof fetch = async () => new Response(secret, { status: 403 }); + const resolver = resolverWith(fetchImpl); + + const message = await rejectionMessage(resolver(resolverInput())); + + expect(message).toBe('Repository credential resolver rejected the request'); + expect(message).not.toContain(secret); + }); + + it.each([ + ['malformed JSON', 'not-json'], + ['unexpected fields', JSON.stringify({ credentials: 'https://token@example.com', extra: true })], + ['empty credentials', JSON.stringify({ credentials: '' })], + ['non credential-store content', JSON.stringify({ credentials: 'username=secret' })], + ['credential URL without a secret', JSON.stringify({ credentials: 'https://example.com/repository.git' })], + ])('rejects %s with a redaction-safe error', async (_caseName, body) => { + const fetchImpl: typeof fetch = async () => new Response(body); + const resolver = resolverWith(fetchImpl); + + await expect(resolver(resolverInput())).rejects.toMatchObject({ + status: 424, + message: 'Repository credential resolver returned an invalid response', + } satisfies Partial); + }); +}); diff --git a/packages/trueforge/tests/unit/runtime/sessionResources.test.ts b/packages/trueforge/tests/unit/runtime/sessionResources.test.ts index b3d763f79..617cd385f 100644 --- a/packages/trueforge/tests/unit/runtime/sessionResources.test.ts +++ b/packages/trueforge/tests/unit/runtime/sessionResources.test.ts @@ -7,7 +7,12 @@ import { SqliteMcpServerStore } from '../../../src/db/sqlite/mcp-server-store/Sq import { SqliteModelProviderStore } from '../../../src/db/sqlite/model-provider-store/SqliteModelProviderStore'; import { SqliteSandboxProviderStore } from '../../../src/db/sqlite/sandbox-provider-store/SqliteSandboxProviderStore'; import { SqliteSkillStore } from '../../../src/db/sqlite/skill-store/SqliteSkillStore'; -import { getModelDetails, localSandboxSessionSegment, validateAgentSpec } from '../../../src/runtime/sessionResources'; +import { + getModelDetails, + localSandboxSessionSegment, + rejectUnconfiguredRepositoryCredentials, + validateAgentSpec, +} from '../../../src/runtime/sessionResources'; import { setCachedLocalSandboxSupport } from '../../../src/sandbox/localRuntime'; import type { ReasoningEffort } from '../../../src/schemas/modelProvider'; @@ -22,6 +27,29 @@ describe('localSandboxSessionSegment', () => { }); }); +describe('rejectUnconfiguredRepositoryCredentials', () => { + it('identifies an unresolved opaque provider reference', async () => { + await expect( + rejectUnconfiguredRepositoryCredentials({ + tenant_id: TENANT_ID, + session_id: 'session-1', + user_ref: 'user-1', + repository: { + url: 'https://github.com/example/repository.git', + ref: 'main', + path: 'workspace/repository', + access: 'read_write', + credential_provider_ref: 'github-app:installation-123', + }, + signal: new AbortController().signal, + }), + ).rejects.toMatchObject({ + status: 422, + message: 'Repository credential provider "github-app:installation-123" is not configured', + } satisfies Partial); + }); +}); + describe('validateAgentSpec', () => { afterEach(() => { setCachedLocalSandboxSupport(undefined);