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
22 changes: 21 additions & 1 deletion design/execution-drivers.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,8 @@ HOST CONTAINER
1. Build ExecutionRequest
├─ globalArgs, methodArgs
├─ definitionMeta
└─ bundle (Uint8Array)
├─ bundle (Uint8Array)
└─ Resolve vault sentinels in args

2. Create temp dir
├─ bundle.js ──mount──▶ /swamp/bundle.js
Expand All @@ -284,6 +285,25 @@ HOST CONTAINER
via DataWriter
```

## Vault Secret Resolution

Vault expressions (`${{ vault.get(...) }}`) produce sentinel tokens during
runtime expression resolution. These sentinels must be replaced with actual
secret values before execution. Each driver path handles this differently:

- **Raw driver**: The `DefaultMethodExecutionService.execute()` method calls
`secretBag.resolveDeep()` on method args and global args before invoking the
model's `execute` function. The shell model additionally resolves sentinels
via environment variables (`resolveForShell`) to prevent shell injection.

- **Out-of-process drivers** (docker, custom): The method execution service
resolves sentinels in `executionRequest.methodArgs` and
`executionRequest.globalArgs` before dispatching to the driver. This ensures
drivers receive plaintext values without needing vault awareness. The
resolution operates on cloned data — the original definition is never mutated,
so sentinel tokens remain in the persisted definition while only the in-flight
request carries resolved values.

## Output Parity

Both drivers produce `DriverOutput[]` but with different `kind` values:
Expand Down
13 changes: 13 additions & 0 deletions src/domain/models/method_execution_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,19 @@ export class DefaultMethodExecutionService implements MethodExecutionService {
);
}

// Resolve vault sentinels for out-of-process drivers. The raw driver
// resolves sentinels internally via DefaultMethodExecutionService.execute(),
// but out-of-process drivers receive the ExecutionRequest as-is.
const secretBag = context.vaultSecrets;
if (secretBag && !secretBag.isEmpty) {
executionRequest.methodArgs = secretBag.resolveDeep(
executionRequest.methodArgs,
) as Record<string, unknown>;
executionRequest.globalArgs = secretBag.resolveDeep(
executionRequest.globalArgs,
) as Record<string, unknown>;
}

// Look up a registered driver type
await driverTypeRegistry.ensureTypeLoaded(driverType);
const driverInfo = driverTypeRegistry.get(driverType);
Expand Down
71 changes: 71 additions & 0 deletions src/domain/models/method_execution_service_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ import { Data } from "../data/data.ts";
import { UserError } from "../errors.ts";
import { getLogger } from "@logtape/logtape";
import { VaultSecretBag } from "../vaults/vault_secret_bag.ts";
import type {
ExecutionRequest,
ExecutionResult,
} from "../drivers/execution_driver.ts";
import { driverTypeRegistry } from "../drivers/driver_type_registry.ts";

/**
* Test model that mimics the echo model's write method.
Expand Down Expand Up @@ -2819,3 +2824,69 @@ Deno.test("executeWorkflow - rejects unknown global arg key", async () => {
"Global arguments validation failed",
);
});

Deno.test(
"executeWorkflow: out-of-process driver receives resolved vault secrets, not sentinels",
async () => {
const service = new DefaultMethodExecutionService();

const driverType = `test-capture-${crypto.randomUUID().slice(0, 8)}`;
let capturedRequest: ExecutionRequest | null = null;

driverTypeRegistry.register({
type: driverType,
name: "Test capture driver",
description: "Captures ExecutionRequest for assertions",
isBuiltIn: false,
createDriver: () => ({
type: driverType,
execute: (request: ExecutionRequest): Promise<ExecutionResult> => {
capturedRequest = request;
return Promise.resolve({
status: "success",
outputs: [],
logs: [],
durationMs: 0,
});
},
}),
});

const model: ModelDefinition = {
type: ModelType.create("test/driver-vault"),
version: "1.0.0",
globalArguments: z.object({ apiKey: z.string() }),
resources: {},
methods: {
run: {
description: "Test method",
arguments: z.object({ token: z.string() }),
execute: () => Promise.resolve({ dataHandles: [] }),
},
},
};

const secretBag = new VaultSecretBag();
const apiKeySentinel = secretBag.addSecret("real-api-key-value");
const tokenSentinel = secretBag.addSecret("real-token-value");

const definition = Definition.create({
name: "test-driver-vault-def",
type: "test/driver-vault",
globalArguments: { apiKey: apiKeySentinel },
methods: { run: { arguments: { token: tokenSentinel } } },
});

const { context } = createTestContext({
modelType: model.type,
vaultSecrets: secretBag,
driver: driverType,
});

await service.executeWorkflow(definition, model, "run", context);

assertEquals(capturedRequest !== null, true);
assertEquals(capturedRequest!.globalArgs.apiKey, "real-api-key-value");
assertEquals(capturedRequest!.methodArgs.token, "real-token-value");
},
);
Loading