From a846c7feae23b97f74b3b9689804c686fb10bb9f Mon Sep 17 00:00:00 2001
From: nicholascole
Date: Thu, 9 Jul 2026 16:28:33 -0700
Subject: [PATCH 1/9] feat(credentials): toggle native secret mechanism on
agentspan.embedded; host-delivered secrets when embedded
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Keep AgentSpan's full native credential mechanism and gate it on the
`agentspan.embedded` feature flag instead of deleting it:
- Standalone (embedded=false): native mechanism active, unchanged (encrypted
store, execution-token minting, /api/workers/secrets pull, SDK fetchers).
- Embedded (embedded=true): native beans dormant; the host resolves secrets via
`${workflow.secrets.NAME}` (conductor-oss PR #1255 substituteSecrets/SecretsDAO).
Server:
- Part A: @ConditionalOnProperty(agentspan.embedded=false, matchIfMissing=true)
on all native secret beans (WorkerController, CredentialResolutionService,
ExecutionTokenService, CredentialAwareMcpService, CredentialMaskingResponseAdvice,
store/masterkey/seeder/migrator/datasource, NoOpSecretOutputMasker). Widely-injected
consumers made tolerant (AgentspanAIModelProvider via ObjectProvider + guards).
- Part B: worker tools stamp inputParameters.__resolved_credentials__ =
{NAME: "${workflow.secrets.NAME}"} (embedded only, via the enrich script);
LLM apiKey stamped ${workflow.secrets.} (LlmProviderEnv).
SDKs: each worker prefers host-delivered __resolved_credentials__ from task input,
falling back to the native token-pull (standalone). No conductor client-library
rebuilds — the map rides in the preserved inputData.
Tests (fail-first validated): NativeSecretGatingTest, ToolCompilerWorkerCredTest,
ReadResolvedCredentialsTest (Java), test_resolved_credentials.py, TS credentials tests.
Conductor pinned to a local runtimemeta build (PR #1255) until it ships.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
...026-07-09-embedded-secret-toggle-design.md | 130 ++++++++++++++++++
sdk/csharp/src/Conductor.AI/WorkerManager.cs | 30 +++-
.../conductor/ai/internal/WorkerManager.java | 25 +++-
.../internal/ReadResolvedCredentialsTest.java | 53 +++++++
.../conductor/ai/agents/runtime/_dispatch.py | 10 +-
.../tests/unit/test_resolved_credentials.py | 59 ++++++++
sdk/typescript/src/credentials.ts | 17 ++-
sdk/typescript/src/worker.ts | 74 ++++++----
sdk/typescript/tests/unit/credentials.test.ts | 123 +++++++++++------
server/build.gradle | 6 +-
.../CredentialDataSourceConfig.java | 2 +
.../credentials/CredentialEnvSeeder.java | 2 +
.../credentials/CredentialSchemaMigrator.java | 2 +
.../EncryptedDbCredentialStoreProvider.java | 2 +
.../runtime/credentials/MasterKeyConfig.java | 2 +
.../credentials/NoOpSecretOutputMasker.java | 2 +
.../src/main/resources/application.properties | 7 +
.../runtime/util/EnrichToolsScriptTest.java | 2 +-
.../ai/AgentChatCompleteTaskMapper.java | 29 +++-
.../runtime/ai/AgentspanAIModelProvider.java | 56 +++++++-
.../agentspan/runtime/ai/LlmProviderEnv.java | 37 +++++
.../runtime/compiler/AgentCompiler.java | 27 ++++
.../runtime/compiler/MultiAgentCompiler.java | 2 +
.../runtime/compiler/ToolCompiler.java | 56 +++++++-
.../CredentialMaskingResponseAdvice.java | 2 +
.../runtime/controller/WorkerController.java | 2 +
.../CredentialAwareMcpService.java | 2 +
.../CredentialResolutionService.java | 2 +
.../credentials/ExecutionTokenService.java | 2 +
.../runtime/service/AgentEventListener.java | 1 +
.../runtime/util/JavaScriptBuilder.java | 10 +-
.../compiler/ToolCompilerWorkerCredTest.java | 111 +++++++++++++++
.../credentials/NativeSecretGatingTest.java | 72 ++++++++++
33 files changed, 869 insertions(+), 90 deletions(-)
create mode 100644 design/2026-07-09-embedded-secret-toggle-design.md
create mode 100644 sdk/java/src/test/java/org/conductoross/conductor/ai/internal/ReadResolvedCredentialsTest.java
create mode 100644 sdk/python/tests/unit/test_resolved_credentials.py
create mode 100644 server/conductor-agentspan/src/main/java/dev/agentspan/runtime/ai/LlmProviderEnv.java
create mode 100644 server/conductor-agentspan/src/test/java/dev/agentspan/runtime/compiler/ToolCompilerWorkerCredTest.java
create mode 100644 server/conductor-agentspan/src/test/java/dev/agentspan/runtime/credentials/NativeSecretGatingTest.java
diff --git a/design/2026-07-09-embedded-secret-toggle-design.md b/design/2026-07-09-embedded-secret-toggle-design.md
new file mode 100644
index 000000000..c0f7cf58d
--- /dev/null
+++ b/design/2026-07-09-embedded-secret-toggle-design.md
@@ -0,0 +1,130 @@
+# Secret delivery toggle: native (standalone) vs host-delivered (embedded)
+
+**Date:** 2026-07-09 · **Status:** In progress · **Branch:** `feature/embedded-secret-toggle`
+
+## Summary
+
+AgentSpan keeps its full native credential mechanism. A single feature flag,
+`agentspan.embedded`, toggles it on/off:
+
+| Deployment | `agentspan.embedded` | Secret delivery |
+|---|---|---|
+| **Standalone** agentspan server | `false` (default) | **Native** — encrypted store, execution-token minting, `POST /api/workers/secrets` pull, SDK fetchers. Unchanged from `main`. |
+| **Embedded** in orkes-conductor / conductor-oss | `true` | **Native dormant** (all beans gated off); the **host** resolves `${workflow.secrets.NAME}`. |
+
+**Everything embedded flows through `${workflow.secrets.NAME}`** — no new wire fields, no
+client-library changes. The host resolves those references from its secret store:
+- **System tasks** (LLM `apiKey`, HTTP/MCP/planner headers) — `${workflow.secrets.NAME}` in task
+ input, resolved in-process before the task runs.
+- **Worker tools** (SIMPLE tasks) — `inputParameters.__resolved_credentials__ = { NAME:
+ "${workflow.secrets.NAME}" }`, resolved at poll time by conductor-oss PR #1255's
+ `ParametersUtils.substituteSecrets(task.getInputData())` (which walks nested maps and resolves
+ each reference from the `SecretsDAO`). The SDK worker reads `__resolved_credentials__` from the
+ task input and strips it.
+
+Nothing is deleted; the native code stays intact and active for standalone.
+
+## Why `${workflow.secrets}` in input, not `Task.runtimeMetadata`
+
+PR #1255 offers two poll-time delivery paths. We deliberately use only the input-reference one:
+
+- **`Task.runtimeMetadata`** (rejected) is a *new top-level field* on the polled Task. The SDK
+ polling clients bundle their own `Task` model — `conductor-client:5.0.1` (Java),
+ `conductor-csharp:1.1.4`, `conductor-python:1.3.11` — none of which have that field or an
+ `@JsonAnySetter`, so the value is silently dropped on the wire. Using it would force **rebuilding
+ and republishing all three client libraries** (separate `conductor-oss/java-sdk`, `csharp-sdk`,
+ `python-sdk` repos). Not worth it.
+- **`__resolved_credentials__` in `inputData`** (chosen) lives in the task's input `Map`, which
+ every client already preserves as-is. Same security property — the persisted input keeps the
+ `${workflow.secrets.NAME}` *reference*; plaintext appears only in the poll response. **No client
+ rebuilds, no `conductor-client` version bump.**
+
+## Server changes
+
+**Part A — gate the native mechanism. ✅ Done + tested.** Every native secret bean carries
+`@ConditionalOnProperty(name = "agentspan.embedded", havingValue = "false", matchIfMissing = true)`
+so it is absent when embedded: `WorkerController`, `CredentialResolutionService`,
+`ExecutionTokenService`, `CredentialAwareMcpService`, `CredentialMaskingResponseAdvice`,
+`EncryptedDbCredentialStoreProvider`, `MasterKeyConfig`, `CredentialEnvSeeder`,
+`CredentialSchemaMigrator`, `CredentialDataSourceConfig`, `NoOpSecretOutputMasker` (plus the
+already-gated `SecretController`, `CredentialAwareHttpTaskConfig`). Consumers that stay active
+tolerate their absence: `AgentspanAIModelProvider` injects the two services via `ObjectProvider`
+(null when embedded, guarded at each use); `AgentService` / `AgentEventListener` already use
+`@Autowired(required = false)` + null guards (so token minting is simply skipped when embedded).
+
+**Part B — system-task host delivery. ✅ Done.**
+`AgentChatCompleteTaskMapper.injectCredentialReferences` stamps `apiKey =
+${workflow.secrets.}` (via `LlmProviderEnv`) when embedded; `AgentspanAIModelProvider`
+reads the host-resolved `apiKey` back from task input. HTTP/MCP/planner headers already branch on
+`EmbeddedMode.isEmbedded()` to emit `${workflow.secrets.NAME}` (unchanged from `main`).
+
+**Part B — worker-tool host delivery. ✅ Done + tested.** Stamps
+`inputParameters.__resolved_credentials__ = { NAME: "${workflow.secrets.NAME}" }` on SIMPLE
+worker-tool tasks, embedded only (ported from `fa64a9cc`, keeping the native code):
+- `ToolCompiler`: `workerCreds` map + `setWorkerCreds`, `NON_WORKER_TOOL_TYPES`/`isWorkerTool`,
+ `buildWorkerCredConfig` (builds `{tool -> {NAME: "${workflow.secrets.NAME}"}}`), and thread a
+ `workerCredJson` literal into the enrich script.
+- `JavaScriptBuilder`: the enrich script injects `t.inputParameters.__resolved_credentials__ =
+ workerCredCfg[name]` onto each dynamically-forked SIMPLE task (baked as a literal so the
+ `${workflow.secrets}` references are *not* resolved prematurely by the in-process INLINE enrich
+ task — they resolve at each SIMPLE task's own poll).
+- `AgentCompiler`: `collectToolCredentials` / `collectCredentialUnion` (per-tool names with
+ agent-level fallback) + direct `__resolved_credentials__` stamping on the static prefill and
+ framework-passthrough SIMPLE tasks; wire `setWorkerCreds(...)`.
+- `MultiAgentCompiler`: wire `setWorkerCreds(...)`.
+- Test with `ToolCompilerWorkerCredTest` (GraalJS-executes the enrich script and asserts the built
+ SIMPLE task carries `__resolved_credentials__` when embedded, nothing when standalone).
+
+## SDK read-path — why every SDK must change
+
+The resolved secrets arrive on `inputData.__resolved_credentials__` (embedded) instead of the
+native `/api/workers/secrets` pull (standalone). Each SDK worker must therefore **auto-detect**:
+prefer `inputData.__resolved_credentials__` when present; otherwise fall back to the existing
+native token-pull fetcher. The resolved `{NAME: value}` map feeds the existing injection/accessor
+machinery unchanged, and the key is stripped before the handler runs. The native fetcher code stays.
+No client-library change is needed (the map rides in the preserved `inputData`).
+
+- **TypeScript — ✅ Done + tested.** `worker.ts` prefers `inputData.__resolved_credentials__`,
+ else native pull; `getCredential` reads the host-delivered map from the credential context;
+ `stripInternalKeys` drops the key. Unit tests in `credentials.test.ts` (fail-first validated).
+- **Java — ✅ Done + tested.** `internal/WorkerManager.java` `executeHandler`: `readResolvedCredentials(inputData)`
+ (non-empty → use it) else `credentialFetcher.fetch(execToken, declared)`; feeds `CredentialContext`.
+ `ReadResolvedCredentialsTest` (fail-first validated); root SDK suite green.
+- **Python — ✅ Done + tested.** `runtime/_dispatch.py`: pops `task.input_data["__resolved_credentials__"]`
+ (non-empty → use it) else the token-pull fetcher; feeds the contextvar / `inject_via_env`.
+ `test_resolved_credentials.py` (fail-first validated).
+- **C# — ✅ Done (not run locally — no `dotnet` toolchain here).** `WorkerManager.cs`:
+ `ReadResolvedCredentials(inputData)` (non-empty → use it) else `ResolveCredentialsAsync(...)`; feeds
+ `CredentialScope`; strips the key from handler input. Mirrors the Java/Python logic; needs a
+ `dotnet test` run in CI to confirm.
+
+## Dependency
+
+Requires a conductor build with PR #1255 (`ParametersUtils.substituteSecrets` + `SecretsDAO`
+resolution of `${workflow.secrets.NAME}` in task input at poll). Currently built from
+`conductor-oss` `feat/env-backed-secrets-and-environment` → mavenLocal
+`3.32.0-rc.3-runtimemeta-LOCAL` (superset of 3.32.0-rc.3), pinned in `server/build.gradle`. Revert
+to a published version once PR #1255 ships. **No SDK client-library changes are required.**
+
+## Tests
+
+- `NativeSecretGatingTest` ✅ — native beans present standalone, absent embedded
+ (`ApplicationContextRunner`); fail-first validated.
+- TS `credentials.test.ts` ✅ — host-delivered map read by `getCredential` without an endpoint pull;
+ undelivered secret with no token → NotFound (off-host trim); fail-first validated.
+- Planned: `ToolCompilerWorkerCredTest` (GraalJS enrich-script assertion), and Java/C#/Python SDK
+ unit tests for the `__resolved_credentials__` auto-detect.
+- Standalone credential e2e suites remain unchanged and green.
+
+## Status snapshot
+
+| Item | State |
+|---|---|
+| Part A — native mechanism gated on `agentspan.embedded` | ✅ done + tested |
+| System-task `${workflow.secrets}` (LLM apiKey, HTTP/MCP/planner headers) | ✅ done |
+| Conductor `runtimemeta` build + pin | ✅ done |
+| Worker-tool `__resolved_credentials__` server stamping | ✅ done + tested (`ToolCompilerWorkerCredTest`, fail-first) |
+| TypeScript SDK read-path | ✅ done + tested |
+| Java SDK read-path | ✅ done + tested (`ReadResolvedCredentialsTest`, fail-first) |
+| Python SDK read-path | ✅ done + tested (`test_resolved_credentials.py`, fail-first) |
+| C# SDK read-path | ✅ done (not run locally — needs `dotnet test` in CI) |
diff --git a/sdk/csharp/src/Conductor.AI/WorkerManager.cs b/sdk/csharp/src/Conductor.AI/WorkerManager.cs
index 1c4d194af..07c8c1dc9 100644
--- a/sdk/csharp/src/Conductor.AI/WorkerManager.cs
+++ b/sdk/csharp/src/Conductor.AI/WorkerManager.cs
@@ -95,9 +95,10 @@ private async System.Threading.Tasks.Task ExecuteAsync(Task task, CancellationTo
// Strip internal keys from the handler-visible input
var handlerInput = inputData
- .Where(kv => !string.Equals(kv.Key, "__agentspan_ctx__", StringComparison.OrdinalIgnoreCase)
- && !string.Equals(kv.Key, "_agent_state", StringComparison.OrdinalIgnoreCase)
- && !string.Equals(kv.Key, "method", StringComparison.OrdinalIgnoreCase))
+ .Where(kv => !string.Equals(kv.Key, "__agentspan_ctx__", StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(kv.Key, "_agent_state", StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(kv.Key, "__resolved_credentials__", StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(kv.Key, "method", StringComparison.OrdinalIgnoreCase))
.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase);
// Resolve and inject credentials via the centralized helper so the
@@ -105,8 +106,10 @@ private async System.Threading.Tasks.Task ExecuteAsync(Task task, CancellationTo
// process-wide lock. See docs/design/secret-injection-contract.md.
// Tier-2 (env-injection) path; tier-1 (explicit-key) lands when the
// user-facing API exposes a `credentials` parameter to agent factories.
- Dictionary resolvedCredentials = new();
- if (_credentialNames.Length > 0)
+ // Embedded: the host resolves ${workflow.secrets.NAME} into __resolved_credentials__
+ // at poll time. Prefer that map; otherwise fall back to the native token-pull.
+ var resolvedCredentials = ReadResolvedCredentials(inputData);
+ if (resolvedCredentials.Count == 0 && _credentialNames.Length > 0)
{
var creds = await _http.ResolveCredentialsAsync(
toolCtx?.ExecutionToken, _credentialNames, ct);
@@ -217,6 +220,23 @@ or CredentialRateLimitException
}
}
+ ///
+ /// Read the host-delivered __resolved_credentials__ name→value map from task input
+ /// (embedded mode). The host resolves the stamped ${workflow.secrets.NAME} references at
+ /// poll time. Empty when absent (standalone → the native token-pull is used instead).
+ ///
+ private static Dictionary ReadResolvedCredentials(Dictionary inputData)
+ {
+ var result = new Dictionary();
+ if (inputData.TryGetValue("__resolved_credentials__", out var rc) && rc.ValueKind == JsonValueKind.Object)
+ {
+ foreach (var prop in rc.EnumerateObject())
+ if (prop.Value.ValueKind == JsonValueKind.String)
+ result[prop.Name] = prop.Value.GetString()!;
+ }
+ return result;
+ }
+
// ── JSON bridges (Newtonsoft ↔ System.Text.Json) ──────────
/// Convert conductor-csharp's Newtonsoft-deserialized inputData to STJ JsonElements.
diff --git a/sdk/java/src/main/java/org/conductoross/conductor/ai/internal/WorkerManager.java b/sdk/java/src/main/java/org/conductoross/conductor/ai/internal/WorkerManager.java
index 4c8444433..c1a6855e8 100644
--- a/sdk/java/src/main/java/org/conductoross/conductor/ai/internal/WorkerManager.java
+++ b/sdk/java/src/main/java/org/conductoross/conductor/ai/internal/WorkerManager.java
@@ -373,7 +373,12 @@ private TaskResult executeHandler(String taskName, Task task) {
// problem. See docs/design/secret-injection-contract.md.
Map resolvedSecrets = Collections.emptyMap();
List declared = taskCredentials.getOrDefault(taskName, Collections.emptyList());
- if (!declared.isEmpty()) {
+ // Embedded: the host resolves ${workflow.secrets.NAME} into __resolved_credentials__ at
+ // poll time. Prefer that map; otherwise fall back to the native token-pull (standalone).
+ Map hostDelivered = readResolvedCredentials(inputData);
+ if (!hostDelivered.isEmpty()) {
+ resolvedSecrets = hostDelivered;
+ } else if (!declared.isEmpty()) {
String execToken = extractExecutionToken(inputData);
try {
resolvedSecrets = credentialFetcher.fetch(execToken, declared);
@@ -417,6 +422,24 @@ private TaskResult executeHandler(String taskName, Task task) {
return result;
}
+ /**
+ * Read the host-delivered {@code __resolved_credentials__} name→value map from task input
+ * (embedded mode). The host resolves the stamped {@code ${workflow.secrets.NAME}} references at
+ * poll time. Returns an empty map when absent (standalone → native token-pull is used instead).
+ */
+ private static Map readResolvedCredentials(Map inputData) {
+ if (inputData == null) return Collections.emptyMap();
+ Object rc = inputData.get("__resolved_credentials__");
+ if (!(rc instanceof Map, ?> m) || m.isEmpty()) return Collections.emptyMap();
+ Map out = new HashMap<>();
+ for (Map.Entry, ?> e : m.entrySet()) {
+ if (e.getKey() != null && e.getValue() instanceof String s) {
+ out.put(e.getKey().toString(), s);
+ }
+ }
+ return out;
+ }
+
/**
* Pull the execution token out of {@code inputData["__agentspan_ctx__"]["execution_token"]}.
* Returns {@code null} if no token is present.
diff --git a/sdk/java/src/test/java/org/conductoross/conductor/ai/internal/ReadResolvedCredentialsTest.java b/sdk/java/src/test/java/org/conductoross/conductor/ai/internal/ReadResolvedCredentialsTest.java
new file mode 100644
index 000000000..688bddee4
--- /dev/null
+++ b/sdk/java/src/test/java/org/conductoross/conductor/ai/internal/ReadResolvedCredentialsTest.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2025 AgentSpan
+ * Licensed under the MIT License.
+ */
+package org.conductoross.conductor.ai.internal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Method;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Validates {@code WorkerManager.readResolvedCredentials} — the embedded host-delivery read-path
+ * that extracts {@code __resolved_credentials__} (resolved by the host from
+ * {@code ${workflow.secrets.NAME}}) from task input. Absent/empty → empty map (standalone falls
+ * back to the native token-pull).
+ */
+class ReadResolvedCredentialsTest {
+
+ @SuppressWarnings("unchecked")
+ private static Map invoke(Map inputData) throws Exception {
+ Method m = WorkerManager.class.getDeclaredMethod("readResolvedCredentials", Map.class);
+ m.setAccessible(true);
+ return (Map) m.invoke(null, inputData);
+ }
+
+ @Test
+ void extractsHostDeliveredStringValues() throws Exception {
+ Map rc = new HashMap<>();
+ rc.put("GITHUB_TOKEN", "ghp_host");
+ rc.put("NOT_A_STRING", 123); // non-string values are skipped
+ Map input = new HashMap<>();
+ input.put("__resolved_credentials__", rc);
+
+ Map out = invoke(input);
+
+ assertEquals(1, out.size());
+ assertEquals("ghp_host", out.get("GITHUB_TOKEN"));
+ }
+
+ @Test
+ void emptyWhenKeyAbsentOrNull() throws Exception {
+ assertTrue(invoke(new HashMap<>()).isEmpty());
+ assertTrue(invoke(null).isEmpty());
+ Map emptyMap = new HashMap<>();
+ emptyMap.put("__resolved_credentials__", new HashMap<>());
+ assertTrue(invoke(emptyMap).isEmpty());
+ }
+}
diff --git a/sdk/python/src/conductor/ai/agents/runtime/_dispatch.py b/sdk/python/src/conductor/ai/agents/runtime/_dispatch.py
index 2fd569ed8..298c1f4d8 100644
--- a/sdk/python/src/conductor/ai/agents/runtime/_dispatch.py
+++ b/sdk/python/src/conductor/ai/agents/runtime/_dispatch.py
@@ -419,8 +419,16 @@ def tool_worker(task: Task) -> TaskResult:
credential_names = list(
_workflow_credentials.get(task.workflow_instance_id, [])
)
+ # Embedded: the host resolves ${workflow.secrets.NAME} into __resolved_credentials__
+ # at poll time. Prefer that map; otherwise fall back to the native token-pull
+ # (standalone). Pop the key so it never leaks into the tool's kwargs.
+ host_delivered = task.input_data.pop("__resolved_credentials__", None)
resolved_secrets = {}
- if credential_names:
+ if isinstance(host_delivered, dict) and host_delivered:
+ resolved_secrets = {
+ k: v for k, v in host_delivered.items() if isinstance(v, str)
+ }
+ elif credential_names:
token = _extract_execution_token(task)
fetcher = _get_credential_fetcher()
try:
diff --git a/sdk/python/tests/unit/test_resolved_credentials.py b/sdk/python/tests/unit/test_resolved_credentials.py
new file mode 100644
index 000000000..489817709
--- /dev/null
+++ b/sdk/python/tests/unit/test_resolved_credentials.py
@@ -0,0 +1,59 @@
+"""Embedded host-delivered credential path: the worker prefers
+``__resolved_credentials__`` from task input (resolved by the host from
+``${workflow.secrets.NAME}``) over the native execution-token pull.
+"""
+
+from unittest.mock import patch
+
+from conductor.ai.agents.runtime._dispatch import make_tool_worker
+from conductor.ai.agents.runtime.credentials.accessor import get_secret
+from conductor.ai.agents.tool import get_tool_def, tool
+from conductor.client.http.models.task import Task
+
+
+def _worker():
+ @tool(credentials=["GITHUB_TOKEN"])
+ def read_token() -> str:
+ return get_secret("GITHUB_TOKEN")
+
+ td = get_tool_def(read_token)
+ return make_tool_worker(td.func, td.name, tool_def=td)
+
+
+def test_prefers_host_delivered_resolved_credentials():
+ wrapper = _worker()
+ task = Task()
+ task.input_data = {"__resolved_credentials__": {"GITHUB_TOKEN": "ghp_host_resolved"}}
+ task.workflow_instance_id = "wf"
+ task.task_id = "t"
+
+ # The native fetcher must NOT be consulted when the host already delivered the map.
+ with patch("conductor.ai.agents.runtime._dispatch._get_credential_fetcher") as mock_fetcher:
+ result = wrapper(task)
+
+ assert result.status == "COMPLETED"
+ assert result.output_data["result"] == "ghp_host_resolved"
+ mock_fetcher.assert_not_called()
+
+
+def test_falls_back_to_native_fetch_when_no_resolved_map():
+ wrapper = _worker()
+ task = Task()
+ task.input_data = {"__agentspan_ctx__": {"execution_token": "tok"}}
+ task.workflow_instance_id = "wf"
+ task.task_id = "t"
+
+ class _Fetcher:
+ def fetch(self, token, names):
+ assert token == "tok"
+ assert names == ["GITHUB_TOKEN"]
+ return {"GITHUB_TOKEN": "ghp_native_pull"}
+
+ with patch(
+ "conductor.ai.agents.runtime._dispatch._get_credential_fetcher",
+ return_value=_Fetcher(),
+ ):
+ result = wrapper(task)
+
+ assert result.status == "COMPLETED"
+ assert result.output_data["result"] == "ghp_native_pull"
diff --git a/sdk/typescript/src/credentials.ts b/sdk/typescript/src/credentials.ts
index e99943094..2b9745114 100644
--- a/sdk/typescript/src/credentials.ts
+++ b/sdk/typescript/src/credentials.ts
@@ -13,6 +13,10 @@ interface CredentialContext {
serverUrl: string;
headers: Record;
executionToken: string;
+ // Pre-resolved name→value map. Embedded: the host resolves declared secrets at poll
+ // time and injects them onto task.runtimeMetadata; getCredential() reads them from here
+ // instead of pulling via the (dormant) execution-token endpoint.
+ resolved?: Record;
}
// AsyncLocalStorage scopes context per async-call chain so concurrent worker
@@ -36,8 +40,9 @@ export function runWithCredentialContext(
headers: Record,
executionToken: string,
fn: () => Promise,
+ resolved?: Record,
): Promise {
- return _credentialStore.run({ serverUrl, headers, executionToken }, fn);
+ return _credentialStore.run({ serverUrl, headers, executionToken, resolved }, fn);
}
/**
@@ -202,7 +207,17 @@ export async function getCredential(name: string): Promise {
);
}
+ // Embedded / host-delivered: read from the pre-resolved map, no endpoint pull.
+ if (ctx.resolved && ctx.resolved[name] !== undefined) {
+ return ctx.resolved[name];
+ }
+
const { serverUrl, headers, executionToken } = ctx;
+ // No token (embedded, native endpoint dormant) and not in the resolved map → the secret
+ // was not delivered. Surface as not-found (the intended off-host trim) rather than pulling.
+ if (!executionToken) {
+ throw new CredentialNotFoundError(name);
+ }
const resolved = await resolveCredentials(serverUrl, headers, executionToken, [name]);
const value = resolved[name];
diff --git a/sdk/typescript/src/worker.ts b/sdk/typescript/src/worker.ts
index dd606468a..8e92d1598 100644
--- a/sdk/typescript/src/worker.ts
+++ b/sdk/typescript/src/worker.ts
@@ -1,4 +1,8 @@
-import { createConductorClient, TaskManager, NonRetryableException } from "@io-orkes/conductor-javascript";
+import {
+ createConductorClient,
+ TaskManager,
+ NonRetryableException,
+} from "@io-orkes/conductor-javascript";
import type { ConductorWorker, Task, TaskResult } from "@io-orkes/conductor-javascript";
import type { ToolContext } from "./types.js";
import { TerminalToolError } from "./errors.js";
@@ -208,6 +212,7 @@ export function stripInternalKeys(inputData: Record): Record w.taskName === taskName && w.domain === domain);
+ const idx = this.pendingWorkers.findIndex(
+ (w) => w.taskName === taskName && w.domain === domain,
+ );
if (idx >= 0) {
this.pendingWorkers[idx] = { taskName, handler, credentials, domain };
} else {
@@ -336,9 +348,7 @@ export class WorkerManager {
leaseExtendEnabled: true,
...(pw.domain ? { domain: pw.domain } : {}),
- async execute(
- task: Task,
- ): Promise> {
+ async execute(task: Task): Promise> {
// Circuit breaker
if (isCircuitBreakerOpen(pw.taskName)) {
throw new NonRetryableException(`Circuit breaker open for ${pw.taskName}`);
@@ -355,21 +365,22 @@ export class WorkerManager {
cleaned["__workflowInstanceId__"] = task.workflowInstanceId;
if (toolContext) cleaned["__toolContext__"] = toolContext;
- // Credential setup
+ // Credential setup. Embedded: the compiler stamps
+ // inputParameters.__resolved_credentials__ = { NAME: "${workflow.secrets.NAME}" } and the
+ // host resolves those references from its secret store at poll time. Prefer that map;
+ // otherwise fall back to the native execution-token pull (standalone). Resolution is
+ // up-front (no env mutation yet) — injection happens inside runHandler() via
+ // injectSecretsForInvocation so mutate-invoke-restore is atomic under a process lock.
+ // See docs/design/secret-injection-contract.md.
const execToken = extractExecutionToken(inputData);
+ const hostDelivered = inputData["__resolved_credentials__"] as
+ | Record
+ | undefined;
- // Resolve credentials up-front (no env mutation yet). Injection happens
- // inside runHandler() via injectSecretsForInvocation so the mutate-
- // invoke-restore sequence is atomic under a process-wide lock.
- // See docs/design/secret-injection-contract.md.
let resolvedCredentials: Record = {};
- if (pw.credentials?.length) {
- if (!execToken) {
- throw new NonRetryableException(
- `Required credentials not found: ${pw.credentials.join(", ")}. ` +
- `No execution token available.`,
- );
- }
+ if (hostDelivered && Object.keys(hostDelivered).length > 0) {
+ resolvedCredentials = hostDelivered;
+ } else if (pw.credentials?.length && execToken) {
try {
resolvedCredentials = await resolveCredentials(
serverUrl,
@@ -383,14 +394,13 @@ export class WorkerManager {
);
}
}
+ // else: no host delivery and no execution token — proceed with empty credentials; a
+ // tool that genuinely needs a secret fails via the accessor (the intended off-host trim).
- const runHandler = async (): Promise<
- Omit
- > => {
+ const runHandler = async (): Promise> => {
try {
- let result = await injectSecretsForInvocation(
- resolvedCredentials,
- () => pw.handler(cleaned),
+ let result = await injectSecretsForInvocation(resolvedCredentials, () =>
+ pw.handler(cleaned),
);
// State mutation capture
@@ -416,11 +426,17 @@ export class WorkerManager {
}
};
- // Scope credential context per-async-call so concurrent workers do not
- // share (and clobber) module-level state. Runs even without an exec
- // token so handlers see a consistent context shape.
- if (execToken) {
- return runWithCredentialContext(serverUrl, headers, execToken, runHandler);
+ // Scope credential context per-async-call so getCredential() sees the resolved
+ // (host-delivered or pulled) values and concurrent workers do not clobber each
+ // other's module-level state.
+ if (execToken || Object.keys(resolvedCredentials).length > 0) {
+ return runWithCredentialContext(
+ serverUrl,
+ headers,
+ execToken ?? "",
+ runHandler,
+ resolvedCredentials,
+ );
}
return runHandler();
},
diff --git a/sdk/typescript/tests/unit/credentials.test.ts b/sdk/typescript/tests/unit/credentials.test.ts
index 1f84852c6..1a40d8bc7 100644
--- a/sdk/typescript/tests/unit/credentials.test.ts
+++ b/sdk/typescript/tests/unit/credentials.test.ts
@@ -304,48 +304,83 @@ describe("runWithCredentialContext", () => {
vi.restoreAllMocks();
});
- it.each([1, 2, 3])(
- "isolates concurrent executions (run %i)",
- async () => {
- // Reproduce the worker race that breaks test_suite2_tool_calling:
- // 1. Worker A enters context, starts handler.
- // 2. Worker B enters context, finishes, exits.
- // 3. Worker A's handler later calls getCredential — without per-async
- // isolation, B's exit nulled A's context and getCredential throws.
- // Test re-runs (1-3) to surface scheduling-dependent regressions.
- vi.stubGlobal(
- "fetch",
- vi.fn().mockImplementation(async (_url, init: RequestInit) => {
- const body = JSON.parse(String(init.body));
- // Echo the token back in the resolved value so we can verify isolation.
- const result: Record = {};
- for (const n of body.names) result[n] = `${body.token}:${n}`;
- return { ok: true, json: async () => result };
- }),
- );
-
- async function workerHandler(execToken: string, delayMs: number) {
- return runWithCredentialContext(serverUrl, headers, execToken, async () => {
- await new Promise((r) => setTimeout(r, delayMs));
- return getCredential("MY_KEY");
- });
- }
-
- const results = await Promise.all([
- workerHandler("tok-A", 30),
- workerHandler("tok-B", 5),
- workerHandler("tok-C", 20),
- workerHandler("tok-D", 10),
- workerHandler("tok-E", 15),
- ]);
-
- expect(results).toEqual([
- "tok-A:MY_KEY",
- "tok-B:MY_KEY",
- "tok-C:MY_KEY",
- "tok-D:MY_KEY",
- "tok-E:MY_KEY",
- ]);
- },
- );
+ it.each([1, 2, 3])("isolates concurrent executions (run %i)", async () => {
+ // Reproduce the worker race that breaks test_suite2_tool_calling:
+ // 1. Worker A enters context, starts handler.
+ // 2. Worker B enters context, finishes, exits.
+ // 3. Worker A's handler later calls getCredential — without per-async
+ // isolation, B's exit nulled A's context and getCredential throws.
+ // Test re-runs (1-3) to surface scheduling-dependent regressions.
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockImplementation(async (_url, init: RequestInit) => {
+ const body = JSON.parse(String(init.body));
+ // Echo the token back in the resolved value so we can verify isolation.
+ const result: Record = {};
+ for (const n of body.names) result[n] = `${body.token}:${n}`;
+ return { ok: true, json: async () => result };
+ }),
+ );
+
+ async function workerHandler(execToken: string, delayMs: number) {
+ return runWithCredentialContext(serverUrl, headers, execToken, async () => {
+ await new Promise((r) => setTimeout(r, delayMs));
+ return getCredential("MY_KEY");
+ });
+ }
+
+ const results = await Promise.all([
+ workerHandler("tok-A", 30),
+ workerHandler("tok-B", 5),
+ workerHandler("tok-C", 20),
+ workerHandler("tok-D", 10),
+ workerHandler("tok-E", 15),
+ ]);
+
+ expect(results).toEqual([
+ "tok-A:MY_KEY",
+ "tok-B:MY_KEY",
+ "tok-C:MY_KEY",
+ "tok-D:MY_KEY",
+ "tok-E:MY_KEY",
+ ]);
+ });
+});
+
+// ── host-delivered credentials (embedded: task.runtimeMetadata) ──────────
+
+describe("getCredential with host-delivered resolved map", () => {
+ const serverUrl = "https://api.test";
+ const headers = {};
+
+ afterEach(() => {
+ clearCredentialContext();
+ vi.restoreAllMocks();
+ });
+
+ it("reads from the resolved map without pulling the endpoint", async () => {
+ const fetchSpy = vi.fn();
+ vi.stubGlobal("fetch", fetchSpy);
+ // Embedded shape: no execution token, values pre-resolved by the host onto the context.
+ const value = await runWithCredentialContext(
+ serverUrl,
+ headers,
+ "",
+ async () => getCredential("GITHUB_TOKEN"),
+ { GITHUB_TOKEN: "ghp_host_resolved" },
+ );
+ expect(value).toBe("ghp_host_resolved");
+ expect(fetchSpy).not.toHaveBeenCalled(); // native /workers/secrets pull is bypassed
+ });
+
+ it("throws NotFound for an undelivered secret with no token (off-host trim)", async () => {
+ const fetchSpy = vi.fn();
+ vi.stubGlobal("fetch", fetchSpy);
+ await expect(
+ runWithCredentialContext(serverUrl, headers, "", async () => getCredential("MISSING"), {
+ GITHUB_TOKEN: "ghp_host_resolved",
+ }),
+ ).rejects.toBeInstanceOf(CredentialNotFoundError);
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
});
diff --git a/server/build.gradle b/server/build.gradle
index be6f1de45..0a0e56535 100644
--- a/server/build.gradle
+++ b/server/build.gradle
@@ -15,7 +15,11 @@ repositories {
// ── Version catalog ──────────────────────────────────────────────
ext {
- conductorVersion = '3.32.0-rc.3'
+ // Local conductor build off conductor-oss feat/env-backed-secrets-and-environment
+ // (PR #1255: TaskDef.runtimeMetadata + Task.runtimeMetadata + poll-time resolution),
+ // published to mavenLocal. Superset of 3.32.0-rc.3. Needed for embedded host-delivered
+ // secrets via TaskDef.runtimeMetadata. Revert to a published version once PR #1255 ships.
+ conductorVersion = '3.32.0-rc.3-runtimemeta-LOCAL'
lombokVersion = '1.18.42'
log4jVersion = '2.24.3'
sqliteJdbcVersion = '3.47.0.0'
diff --git a/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/credentials/CredentialDataSourceConfig.java b/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/credentials/CredentialDataSourceConfig.java
index a57a61b8d..a9d3c2c96 100644
--- a/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/credentials/CredentialDataSourceConfig.java
+++ b/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/credentials/CredentialDataSourceConfig.java
@@ -9,6 +9,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
@@ -50,6 +51,7 @@
* PostgreSQL: uses {@code org.postgresql.Driver} with a larger pool (default 8).
*/
@Configuration
+@ConditionalOnProperty(name = "agentspan.embedded", havingValue = "false", matchIfMissing = true)
public class CredentialDataSourceConfig {
private static final Logger log = LoggerFactory.getLogger(CredentialDataSourceConfig.class);
diff --git a/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/credentials/CredentialEnvSeeder.java b/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/credentials/CredentialEnvSeeder.java
index 4a9bc395f..45ed18874 100644
--- a/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/credentials/CredentialEnvSeeder.java
+++ b/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/credentials/CredentialEnvSeeder.java
@@ -15,6 +15,7 @@
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import dev.agentspan.runtime.spi.CredentialStoreProvider;
@@ -34,6 +35,7 @@
* (Vault, AWS SM, etc.) manage their own secrets.
*/
@Component
+@ConditionalOnProperty(name = "agentspan.embedded", havingValue = "false", matchIfMissing = true)
public class CredentialEnvSeeder implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(CredentialEnvSeeder.class);
diff --git a/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/credentials/CredentialSchemaMigrator.java b/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/credentials/CredentialSchemaMigrator.java
index cb4175f3b..17011b1b1 100644
--- a/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/credentials/CredentialSchemaMigrator.java
+++ b/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/credentials/CredentialSchemaMigrator.java
@@ -11,6 +11,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
@@ -30,6 +31,7 @@
* pre-release development builds.
*/
@Component
+@ConditionalOnProperty(name = "agentspan.embedded", havingValue = "false", matchIfMissing = true)
public class CredentialSchemaMigrator {
private static final Logger log = LoggerFactory.getLogger(CredentialSchemaMigrator.class);
diff --git a/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/credentials/EncryptedDbCredentialStoreProvider.java b/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/credentials/EncryptedDbCredentialStoreProvider.java
index e9b1f4cd3..6bc8a38eb 100644
--- a/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/credentials/EncryptedDbCredentialStoreProvider.java
+++ b/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/credentials/EncryptedDbCredentialStoreProvider.java
@@ -18,6 +18,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Component;
@@ -34,6 +35,7 @@
* The master key is the 32-byte key from {@code MasterKeyConfig#credentialMasterKey()}.
*/
@Component
+@ConditionalOnProperty(name = "agentspan.embedded", havingValue = "false", matchIfMissing = true)
public class EncryptedDbCredentialStoreProvider implements CredentialStoreProvider {
private static final Logger log = LoggerFactory.getLogger(EncryptedDbCredentialStoreProvider.class);
diff --git a/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/credentials/MasterKeyConfig.java b/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/credentials/MasterKeyConfig.java
index 3eff09e6b..6a2bb937a 100644
--- a/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/credentials/MasterKeyConfig.java
+++ b/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/credentials/MasterKeyConfig.java
@@ -15,6 +15,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -28,6 +29,7 @@
*
*/
@Configuration
+@ConditionalOnProperty(name = "agentspan.embedded", havingValue = "false", matchIfMissing = true)
public class MasterKeyConfig {
private static final Logger log = LoggerFactory.getLogger(MasterKeyConfig.class);
diff --git a/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/credentials/NoOpSecretOutputMasker.java b/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/credentials/NoOpSecretOutputMasker.java
index 304927246..e225df954 100644
--- a/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/credentials/NoOpSecretOutputMasker.java
+++ b/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/credentials/NoOpSecretOutputMasker.java
@@ -4,6 +4,7 @@
*/
package dev.agentspan.runtime.credentials;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import dev.agentspan.runtime.spi.SecretOutputMasker;
@@ -20,6 +21,7 @@
* containing newlines, quotes, or other JSON-escaped characters are still caught).
*/
@Service
+@ConditionalOnProperty(name = "agentspan.embedded", havingValue = "false", matchIfMissing = true)
public class NoOpSecretOutputMasker implements SecretOutputMasker {
@Override
diff --git a/server/conductor-agentspan-server/src/main/resources/application.properties b/server/conductor-agentspan-server/src/main/resources/application.properties
index 5d7da732a..3906a87f8 100644
--- a/server/conductor-agentspan-server/src/main/resources/application.properties
+++ b/server/conductor-agentspan-server/src/main/resources/application.properties
@@ -151,6 +151,13 @@ agentspan.skills.max-file-count=${AGENTSPAN_SKILLS_MAX_FILE_COUNT:2000}
# =============================================================================
# Credential Store Configuration
# =============================================================================
+# Deployment mode toggle. false = standalone: AgentSpan's native credential
+# mechanism is ACTIVE (encrypted store, execution-token minting,
+# /api/workers/secrets pull, SDK fetchers). true = embedded in a host
+# (orkes-conductor / conductor-oss): the native mechanism is DORMANT (all its
+# beans are gated off) and the host delivers secrets — worker tools via
+# TaskDef.runtimeMetadata, system tasks via ${workflow.secrets.NAME}.
+agentspan.embedded=false
agentspan.credentials.store=built-in
agentspan.credentials.strict-mode=false
agentspan.credentials.resolve.rate-limit=120
diff --git a/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/util/EnrichToolsScriptTest.java b/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/util/EnrichToolsScriptTest.java
index 3f97bfbc6..6949246d9 100644
--- a/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/util/EnrichToolsScriptTest.java
+++ b/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/util/EnrichToolsScriptTest.java
@@ -55,7 +55,7 @@ private List