Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/external-repository-credential-resolver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@truefoundry/trueforge': minor
---

Add an optional external HTTP resolver for short-lived repository credentials.
6 changes: 6 additions & 0 deletions .changeset/session-repository-checkouts.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion packages/trueforge-core/src/agent-session/Sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ import { SessionExternalIdConflictError } from './store/SessionStoreErrors';

export type SessionsCreateInput<TSessionCustom extends object> = Omit<
CreateSessionInput<TSessionCustom>,
'custom' | 'metadata'
'custom' | 'metadata' | 'repository'
> & {
custom?: TSessionCustom | undefined;
metadata?: CreateSessionInput<TSessionCustom>['metadata'] | undefined;
repository?: CreateSessionInput<TSessionCustom>['repository'] | undefined;
};

export class Sessions<
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -118,7 +120,7 @@ export class TurnResourceResolver<
signal: AbortSignal;
tracing: AgentTracing;
}): Promise<Sandbox | undefined> {
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({
Expand Down
4 changes: 2 additions & 2 deletions packages/trueforge-core/src/agent-session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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`
Expand Down Expand Up @@ -37,5 +37,7 @@ export interface SessionRecord<TCustom extends object = Record<string, never>> {
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -32,6 +33,9 @@ export const SessionMetadataSchema = z

export type SessionMetadata = z.infer<typeof SessionMetadataSchema>;

export { SessionRepositorySchema };
export type { SessionRepository };

export const SessionMetricsSchema = z
.object({
total_cost_in_usd: z.number().nonnegative(),
Expand Down Expand Up @@ -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');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import type { CancellationReason, TerminalTurnState } from '../schemas/turn';
*/
export type CreateSessionInput<TSessionCustom extends object = Record<string, never>> = Pick<
SessionRecord<TSessionCustom>,
'tenant_id' | 'session_id' | 'agent' | 'created_by' | 'external_id' | 'metadata'
'tenant_id' | 'session_id' | 'agent' | 'created_by' | 'external_id' | 'metadata' | 'repository'
> & {
custom: TSessionCustom | null;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] });
Expand Down
3 changes: 3 additions & 0 deletions packages/trueforge-core/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
32 changes: 32 additions & 0 deletions packages/trueforge-core/src/core/sandbox/RepositoryCheckout.ts
Original file line number Diff line number Diff line change
@@ -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<typeof SessionRepositorySchema>;
54 changes: 51 additions & 3 deletions packages/trueforge-core/src/core/sandbox/Sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`). */
Expand Down Expand Up @@ -79,6 +80,27 @@ function buildGitCredentialHelperEnv(credentialsPath: string): Record<string, st
};
}

/** Idempotently provision a repository without resetting a resumed sandbox's working tree. */
export function buildRepositoryCheckoutCommand(repository: SessionRepository): string {
const path = shellEscape(repository.path);
const url = shellEscape(repository.url);
const fetchRef = shellEscape(`+${repository.ref}:refs/trueforge/session-source`);
const pushPolicy =
repository.access === 'read_only'
? ` && git -C ${path} remote set-url --push origin disabled://read-only`
: ` && git -C ${path} config remote.origin.push ${shellEscape(`HEAD:${repository.ref}`)}`;
return (
`if [ -d ${path}/.git ]; then ` +
`(git -C ${path} remote get-url origin >/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;
Expand All @@ -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).
Expand Down Expand Up @@ -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). */
Expand All @@ -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 };
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -684,13 +716,31 @@ export class Sandbox extends LocalToolMCP {
ensureExecSuccess(result);
}

private async prepareRepository(): Promise<void> {
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<void> {
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;
Expand Down Expand Up @@ -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();
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading