diff --git a/examples/notion-essay-pr/notion-essay-pr.smoke.test.ts b/examples/notion-essay-pr/notion-essay-pr.smoke.test.ts index 033f612c..7448ea78 100644 --- a/examples/notion-essay-pr/notion-essay-pr.smoke.test.ts +++ b/examples/notion-essay-pr/notion-essay-pr.smoke.test.ts @@ -190,6 +190,29 @@ class MockNotionEssayRuntime { read: (filePath) => this.read(filePath), write: (filePath, contents) => this.write(filePath, contents) }, + credentials: { + relayfile: { + url: 'https://relayfile.example.test', + token: 'relayfile-token', + workspaceId: 'rw_proactive' + }, + cloudApi: { + url: 'https://cloud.example.test', + token: 'cloud-api-token' + }, + tryRequire() { + return { + relayfile: this.relayfile, + cloudApi: this.cloudApi + }; + }, + require() { + return { + relayfile: this.relayfile, + cloudApi: this.cloudApi + }; + } + }, memory: { async recall() { return [{ diff --git a/packages/runtime/src/ctx.test.ts b/packages/runtime/src/ctx.test.ts index 45fb3dc0..51c3ac79 100644 --- a/packages/runtime/src/ctx.test.ts +++ b/packages/runtime/src/ctx.test.ts @@ -159,6 +159,80 @@ test('buildCtx exposes ctx.files as a sandbox file helper', async () => { assert.deepEqual(writes, [{ path: '/workspace/output/page-1.md', contents: 'essay' }]); }); +test('buildCtx exposes typed runtime credentials from sandbox env', async () => { + await withEnv( + { + RELAYFILE_URL: 'https://relayfile.example.test', + RELAYFILE_TOKEN: 'relayfile-token', + RELAYFILE_WORKSPACE_ID: 'rw_test', + CLOUD_API_URL: 'https://cloud.example.test', + CLOUD_API_ACCESS_TOKEN: 'cloud-api-token' + }, + async () => { + const ctx = ctxFor(basePersona); + const expected = { + relayfile: { + url: 'https://relayfile.example.test', + token: 'relayfile-token', + workspaceId: 'rw_test' + }, + cloudApi: { + url: 'https://cloud.example.test', + token: 'cloud-api-token' + } + }; + + assert.deepEqual(ctx.credentials.tryRequire(), expected); + assert.deepEqual(ctx.credentials.require(), expected); + assert.deepEqual(ctx.credentials.relayfile, expected.relayfile); + assert.deepEqual(ctx.credentials.cloudApi, expected.cloudApi); + } + ); +}); + +test('ctx.credentials returns null or throws with missing runtime credential keys', async () => { + await withEnv( + { + RELAYFILE_URL: 'https://relayfile.example.test', + RELAYFILE_TOKEN: undefined, + RELAYFILE_WORKSPACE_ID: 'rw_test', + CLOUD_API_URL: 'https://cloud.example.test', + CLOUD_API_ACCESS_TOKEN: 'cloud-api-token' + }, + async () => { + const ctx = ctxFor(basePersona); + + assert.equal(ctx.credentials.tryRequire(), null); + assert.throws( + () => ctx.credentials.require(), + /Runtime credentials are required: missing relayfile\.token/ + ); + assert.throws( + () => ctx.credentials.relayfile, + /Runtime credentials are required: missing relayfile\.token/ + ); + } + ); +}); + +test('ctx.credentials strips trailing slashes from runtime credential URLs', async () => { + await withEnv( + { + RELAYFILE_URL: 'https://relayfile.example.test///', + RELAYFILE_TOKEN: 'relayfile-token', + RELAYFILE_WORKSPACE_ID: 'rw_test', + CLOUD_API_URL: 'https://cloud.example.test/', + CLOUD_API_ACCESS_TOKEN: 'cloud-api-token' + }, + async () => { + const ctx = ctxFor(basePersona); + + assert.equal(ctx.credentials.require().relayfile.url, 'https://relayfile.example.test'); + assert.equal(ctx.credentials.require().cloudApi.url, 'https://cloud.example.test'); + } + ); +}); + test('ctx.memory.save posts to the cloud memory endpoint when sandbox env is present', async () => { await withEnv( { diff --git a/packages/runtime/src/ctx.ts b/packages/runtime/src/ctx.ts index 753af8c9..daa3b986 100644 --- a/packages/runtime/src/ctx.ts +++ b/packages/runtime/src/ctx.ts @@ -3,7 +3,9 @@ import type { LlmContext, MemoryContext, FilesContext, + CredentialsContext, MemoryItem, + RequiredRuntimeCredentials, ScheduleContext, SandboxContext, WorkforceAgentContext, @@ -138,6 +140,7 @@ export function buildCtx(options: CtxBuildOptions): WorkforceCtx { harness: { run: options.harnessRunner }, sandbox: options.sandbox, files, + credentials: credentialsFromEnv(), memory: options.memory ?? defaultMemoryFor(options.persona.memory, options.workspaceId, log), workflow: options.workflow ?? UNAVAILABLE_WORKFLOW, schedule: options.schedule ?? UNAVAILABLE_SCHEDULE, @@ -179,6 +182,7 @@ const CORE_CTX_FIELDS: ReadonlySet = new Set([ 'harness', 'sandbox', 'files', + 'credentials', 'memory', 'workflow', 'schedule', @@ -196,6 +200,69 @@ function filesFromSandbox(sandbox: SandboxContext): FilesContext { }; } +function credentialsFromEnv(processEnv: NodeJS.ProcessEnv = process.env): CredentialsContext { + return { + get relayfile() { + return requireRuntimeCredentials(processEnv).relayfile; + }, + get cloudApi() { + return requireRuntimeCredentials(processEnv).cloudApi; + }, + tryRequire() { + const snapshot = readRuntimeCredentialSnapshot(processEnv); + return snapshot.missing.length > 0 ? null : snapshot.credentials; + }, + require() { + return requireRuntimeCredentials(processEnv); + } + }; +} + +function requireRuntimeCredentials(processEnv: NodeJS.ProcessEnv): RequiredRuntimeCredentials { + const snapshot = readRuntimeCredentialSnapshot(processEnv); + if (snapshot.missing.length > 0) { + throw new Error(`Runtime credentials are required: missing ${snapshot.missing.join(', ')}`); + } + return snapshot.credentials; +} + +function readRuntimeCredentialSnapshot(processEnv: NodeJS.ProcessEnv): { + credentials: RequiredRuntimeCredentials; + missing: string[]; +} { + const relayfileUrl = normalizeOptionalUrl(firstNonEmpty(processEnv.RELAYFILE_URL)); + const relayfileToken = firstNonEmpty(processEnv.RELAYFILE_TOKEN); + const relayfileWorkspaceId = firstNonEmpty(processEnv.RELAYFILE_WORKSPACE_ID); + const cloudApiUrl = normalizeOptionalUrl(firstNonEmpty(processEnv.CLOUD_API_URL)); + const cloudApiToken = firstNonEmpty(processEnv.CLOUD_API_ACCESS_TOKEN); + const missing = [ + ...(!relayfileUrl ? ['relayfile.url'] : []), + ...(!relayfileToken ? ['relayfile.token'] : []), + ...(!relayfileWorkspaceId ? ['relayfile.workspaceId'] : []), + ...(!cloudApiUrl ? ['cloudApi.url'] : []), + ...(!cloudApiToken ? ['cloudApi.token'] : []) + ]; + + return { + credentials: { + relayfile: { + url: relayfileUrl ?? '', + token: relayfileToken ?? '', + workspaceId: relayfileWorkspaceId ?? '' + }, + cloudApi: { + url: cloudApiUrl ?? '', + token: cloudApiToken ?? '' + } + }, + missing + }; +} + +function normalizeOptionalUrl(value: string | undefined): string | undefined { + return value ? normalizeBaseUrl(value) : undefined; +} + function defaultMemoryFor( memoryConfig: PersonaSpec['memory'], workspaceId: string, diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 6f760c8b..d4711b9f 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -6,12 +6,16 @@ export { handler, isWorkforceHandler } from './handler.js'; export type { HarnessRunArgs, HarnessRunResult, + CloudApiCredentials, + CredentialsContext, FilesContext, LlmContext, MemoryContext, MemoryItem, MemoryRecallOptions, MemorySaveOptions, + RelayfileCredentials, + RequiredRuntimeCredentials, SandboxContext, SandboxExecArgs, SandboxExecResult, diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index 4953a6c3..1a12a8bc 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -120,6 +120,29 @@ export interface FilesContext { write(path: string, contents: string): Promise; } +export interface RelayfileCredentials { + url: string; + token: string; + workspaceId: string; +} + +export interface CloudApiCredentials { + url: string; + token: string; +} + +export interface RequiredRuntimeCredentials { + relayfile: RelayfileCredentials; + cloudApi: CloudApiCredentials; +} + +export interface CredentialsContext { + readonly relayfile: RelayfileCredentials; + readonly cloudApi: CloudApiCredentials; + tryRequire(): RequiredRuntimeCredentials | null; + require(): RequiredRuntimeCredentials; +} + export interface MemorySaveOptions { tags?: string[]; scope?: PersonaMemoryScope; @@ -220,6 +243,8 @@ export interface WorkforceCtx { sandbox: SandboxContext; /** Relayfile/sandbox file helpers for handlers that should not shell out. */ files: FilesContext; + /** Runtime credentials populated by the cloud persona launcher. */ + credentials: CredentialsContext; /** Persistent memory (no-op when persona.memory is false or unset). */ memory: MemoryContext; /** Cloud workflows invocation (HTTP). */