Skip to content
Merged
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
23 changes: 23 additions & 0 deletions examples/notion-essay-pr/notion-essay-pr.smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [{
Expand Down
74 changes: 74 additions & 0 deletions packages/runtime/src/ctx.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down
67 changes: 67 additions & 0 deletions packages/runtime/src/ctx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import type {
LlmContext,
MemoryContext,
FilesContext,
CredentialsContext,
MemoryItem,
RequiredRuntimeCredentials,
ScheduleContext,
SandboxContext,
WorkforceAgentContext,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -179,6 +182,7 @@ const CORE_CTX_FIELDS: ReadonlySet<string> = new Set([
'harness',
'sandbox',
'files',
'credentials',
'memory',
'workflow',
'schedule',
Expand All @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions packages/runtime/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,29 @@ export interface FilesContext {
write(path: string, contents: string): Promise<void>;
}

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;
Expand Down Expand Up @@ -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). */
Expand Down
Loading