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> enrichWithAgentTools( private List> enrichWithConfigs( String httpJson, String agentToolJson, String knownNamesJson, String toolCallsJson) throws Exception { String script = JavaScriptBuilder.enrichToolsScript( - httpJson, "{}", "{}", agentToolJson, "{}", "{}", "{}", "{}", knownNamesJson); + httpJson, "{}", "{}", agentToolJson, "{}", "{}", "{}", "{}", knownNamesJson, "{}"); // Wrap so the script's IIFE return is captured AND we get a JSON string // back — Graal's Value.toString() is JS source, not JSON. String wrapped = "var $ = {" diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/ai/AgentChatCompleteTaskMapper.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/ai/AgentChatCompleteTaskMapper.java index b2479295f..71314fc7e 100644 --- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/ai/AgentChatCompleteTaskMapper.java +++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/ai/AgentChatCompleteTaskMapper.java @@ -42,6 +42,7 @@ import dev.agentspan.runtime.model.AgentSSEEvent; import dev.agentspan.runtime.service.AgentStreamRegistry; +import dev.agentspan.runtime.util.EmbeddedMode; import dev.agentspan.runtime.util.ModelContextWindows; /** @@ -111,8 +112,11 @@ protected TaskModel getMappedTask(TaskMapperContext taskMapperContext) throws Te TaskModel taskModel = super.getMappedTask(taskMapperContext); WorkflowModel workflowModel = taskMapperContext.getWorkflowModel(); - // Per-user LLM key resolution is handled by AgentspanAIModelProvider.getModel() - // which creates a fresh AIModel with the user's credential. No inputData injection needed. + // Standalone: per-user LLM key resolution is handled by AgentspanAIModelProvider.getModel() + // (native store), so no inputData injection is needed. Embedded: the native store is dormant, + // so stamp a ${workflow.secrets.NAME} reference for the host to resolve; the provider then + // reads the resolved apiKey back from the task input. + injectCredentialReferences(taskModel); try { ChatCompletion chatCompletion = objectMapper.convertValue(taskModel.getInputData(), ChatCompletion.class); @@ -144,6 +148,27 @@ protected TaskModel getMappedTask(TaskMapperContext taskMapperContext) throws Te return taskModel; } + /** + * Embedded only: stamp {@code apiKey = ${workflow.secrets.}} so the host + * (orkes-conductor / conductor-oss) resolves the LLM credential when it binds task input. + * Only the required API key is stamped (optional base-url / project id are left to env/config, + * since a missing {@code ${workflow.secrets.X}} reference hard-fails on some hosts). Standalone + * stamps nothing — {@link AgentspanAIModelProvider} resolves keys via the native store. + */ + private void injectCredentialReferences(TaskModel taskModel) { + if (!EmbeddedMode.isEmbedded()) { + return; + } + Object provObj = taskModel.getInputData().get("llmProvider"); + if (!(provObj instanceof String provider) || provider.isBlank()) { + return; + } + String apiKeyEnv = LlmProviderEnv.apiKeyEnv(provider); + if (apiKeyEnv != null) { + taskModel.getInputData().put("apiKey", "${workflow.secrets." + apiKeyEnv + "}"); + } + } + /** * OpenAI's JSON mode validation checks user input messages for the word "json". * System/developer instructions are not sufficient for the Responses API. diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/ai/AgentspanAIModelProvider.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/ai/AgentspanAIModelProvider.java index 5f2f930a0..37e6e3579 100644 --- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/ai/AgentspanAIModelProvider.java +++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/ai/AgentspanAIModelProvider.java @@ -22,6 +22,8 @@ import org.conductoross.conductor.ai.providers.perplexity.PerplexityAIConfiguration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Primary; import org.springframework.core.env.Environment; import org.springframework.stereotype.Component; @@ -31,6 +33,7 @@ import dev.agentspan.runtime.context.RequestContextHolder; import dev.agentspan.runtime.credentials.CredentialResolutionService; import dev.agentspan.runtime.credentials.ExecutionTokenService; +import dev.agentspan.runtime.util.EmbeddedMode; import okhttp3.OkHttpClient; @@ -79,6 +82,27 @@ public class AgentspanAIModelProvider extends AIModelProvider { private final CredentialResolutionService resolutionService; private final ExecutionTokenService tokenService; + /** + * Spring constructor. The native credential beans are optional: when + * {@code agentspan.embedded=true} they are gated off (the host delivers secrets), + * so they resolve to {@code null} here and native per-user resolution is skipped. + */ + @Autowired + public AgentspanAIModelProvider( + List> modelConfigurations, + Environment env, + OkHttpClient conductorAiHttpClient, + ObjectProvider resolutionService, + ObjectProvider tokenService) { + this( + modelConfigurations, + env, + conductorAiHttpClient, + resolutionService.getIfAvailable(), + tokenService.getIfAvailable()); + } + + /** Direct constructor (used by tests, and by the Spring constructor above). */ public AgentspanAIModelProvider( List> modelConfigurations, Environment env, @@ -89,7 +113,9 @@ public AgentspanAIModelProvider( this.conductorAiHttpClient = conductorAiHttpClient; this.resolutionService = resolutionService; this.tokenService = tokenService; - log.info("AgentspanAIModelProvider initialized (per-user credential resolution enabled)"); + log.info( + "AgentspanAIModelProvider initialized (native per-user credential resolution {})", + resolutionService != null ? "enabled" : "disabled — embedded/host-delivered"); } @Override @@ -105,6 +131,11 @@ public AIModel getModel(LLMWorkerInput input) { // Try per-user credential resolution log.debug("getModel called for provider='{}' model='{}'", provider, input.getModel()); String userApiKey = resolveUserApiKey(provider); + // Embedded: native resolution is dormant; the host resolved the stamped + // apiKey = ${workflow.secrets.NAME} reference into the task input. Read it back. + if (userApiKey == null && EmbeddedMode.isEmbedded()) { + userApiKey = readResolvedApiKeyFromTaskInput(); + } log.debug("resolveUserApiKey('{}') returned: {}", provider, userApiKey != null ? "key found" : "null"); if (userApiKey != null || baseUrl != null) { try { @@ -141,6 +172,7 @@ public AIModel getModel(LLMWorkerInput input) { * @return per-user API key, or null if not found */ private String resolveUserApiKey(String provider) { + if (resolutionService == null) return null; // native resolution gated off (embedded) String envVarName = PROVIDER_TO_ENV_VAR.get(provider.toLowerCase()); if (envVarName == null) return null; @@ -170,6 +202,7 @@ private String resolveUserApiKey(String provider) { */ @SuppressWarnings("unchecked") private String extractUserIdFromTaskContext() { + if (tokenService == null) return null; // native token service gated off (embedded) try { TaskContext ctx = TaskContext.get(); if (ctx == null || ctx.getTask() == null) return null; @@ -189,6 +222,26 @@ private String extractUserIdFromTaskContext() { } } + /** + * Read the host-resolved {@code apiKey} from the current task input (embedded mode). The + * compiler stamped {@code apiKey = ${workflow.secrets.NAME}} and the host substitutes the + * plaintext before execution. An unresolved placeholder (still starting with {@code ${}) or a + * blank value is ignored so we fall through to env/server-wide resolution. + */ + private String readResolvedApiKeyFromTaskInput() { + try { + TaskContext ctx = TaskContext.get(); + if (ctx == null || ctx.getTask() == null) return null; + Object v = ctx.getTask().getInputData().get("apiKey"); + if (v instanceof String s && !s.isBlank() && !s.startsWith("${")) { + return s; + } + } catch (Exception e) { + // ignore — fall through to other resolution paths + } + return null; + } + /** * Returns true if the provider is available: either configured at startup (via environment * variables / application.properties) or has an API key credential in the current user's store. @@ -205,6 +258,7 @@ public boolean isProviderConfigured(String provider) { * Resolve any named credential for the current user. */ private String resolveUserCredential(String credentialName) { + if (resolutionService == null) return null; // native resolution gated off (embedded) String userId = extractUserIdFromTaskContext(); if (userId == null) { userId = RequestContextHolder.get().map(ctx -> ctx.getUserId()).orElse(null); diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/ai/LlmProviderEnv.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/ai/LlmProviderEnv.java new file mode 100644 index 000000000..82abab334 --- /dev/null +++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/ai/LlmProviderEnv.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 AgentSpan + * Licensed under the MIT License. + */ +package dev.agentspan.runtime.ai; + +import java.util.Map; + +/** + * Maps Conductor LLM provider names to their credential environment-variable (= secret) names. + * Shared by {@link AgentChatCompleteTaskMapper} (which, in EMBEDDED mode, stamps + * {@code ${workflow.secrets.NAME}} references into the LLM task input for the host to resolve) + * and {@link AgentspanAIModelProvider} (which reads the host-resolved value in embedded mode, or + * resolves natively / falls back to {@code System.getenv} when standalone). + */ +public final class LlmProviderEnv { + + private LlmProviderEnv() {} + + /** Maps Conductor provider names to credential env var names. */ + public static final Map PROVIDER_TO_ENV_VAR = Map.ofEntries( + Map.entry("openai", "OPENAI_API_KEY"), + Map.entry("anthropic", "ANTHROPIC_API_KEY"), + Map.entry("mistral", "MISTRAL_API_KEY"), + Map.entry("cohere", "COHERE_API_KEY"), + Map.entry("grok", "XAI_API_KEY"), + Map.entry("perplexity", "PERPLEXITY_API_KEY"), + Map.entry("huggingface", "HUGGINGFACE_API_KEY"), + Map.entry("azureopenai", "AZURE_OPENAI_API_KEY"), + Map.entry("gemini", "GEMINI_API_KEY"), + Map.entry("google_gemini", "GEMINI_API_KEY")); + + /** Credential env var name for a provider's API key, or {@code null} if unknown. */ + public static String apiKeyEnv(String provider) { + return provider == null ? null : PROVIDER_TO_ENV_VAR.get(provider.toLowerCase()); + } +} diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/AgentCompiler.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/AgentCompiler.java index 8f1a98747..521922399 100644 --- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/AgentCompiler.java +++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/AgentCompiler.java @@ -340,6 +340,31 @@ WorkflowDef compileSimple(AgentConfig config) { // ── Agent with tools ──────────────────────────────────────────── + /** + * Collect {@code toolName -> [credentialNames]} for the agent's tools: each tool's own declared + * credentials, falling back to the agent-level credential list. Fed to + * {@link ToolCompiler#setWorkerCreds} so SIMPLE worker tasks carry {@code __resolved_credentials__} + * secret references in embedded mode. + */ + static Map> collectToolCredentials(AgentConfig config) { + List agentCreds = config.getCredentials() != null ? config.getCredentials() : List.of(); + Map> map = new LinkedHashMap<>(); + if (config.getTools() != null) { + for (ToolConfig tool : config.getTools()) { + if (tool.getName() == null) continue; + List own = new ArrayList<>(); + if (tool.getConfig() != null && tool.getConfig().get("credentials") instanceof List cl) { + for (Object c : cl) { + if (c instanceof String s) own.add(s); + } + } + List effective = own.isEmpty() ? agentCreds : own; + if (!effective.isEmpty()) map.put(tool.getName(), new ArrayList<>(effective)); + } + } + return map; + } + WorkflowDef compileWithTools(AgentConfig config) { ParsedModel parsed = ModelParser.parse(config.getModel()); String llmRef = toRef(config.getName()) + "_llm"; @@ -347,6 +372,7 @@ WorkflowDef compileWithTools(AgentConfig config) { List tools = config.getTools(); ToolCompiler tc = new ToolCompiler(); + tc.setWorkerCreds(collectToolCredentials(config)); boolean hasApproval = tools.stream().anyMatch(ToolConfig::isApprovalRequired); boolean hasMcp = tools.stream().anyMatch(t -> "mcp".equals(t.getToolType())); boolean hasApi = tools.stream().anyMatch(t -> "api".equals(t.getToolType())); @@ -721,6 +747,7 @@ WorkflowDef compileHybrid(AgentConfig config) { } ToolCompiler tc = new ToolCompiler(); + tc.setWorkerCreds(collectToolCredentials(config)); boolean hasApproval = allTools.stream().anyMatch(ToolConfig::isApprovalRequired); boolean hasMcp = allTools.stream().anyMatch(t -> "mcp".equals(t.getToolType())); boolean hasApi = allTools.stream().anyMatch(t -> "api".equals(t.getToolType())); diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/MultiAgentCompiler.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/MultiAgentCompiler.java index 4ebf6ffaa..724e505d9 100644 --- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/MultiAgentCompiler.java +++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/MultiAgentCompiler.java @@ -1364,6 +1364,7 @@ WorkflowDef compileSwarmAgentWorkflow(AgentConfig agent, List transf allTools.addAll(transferTools); ToolCompiler tc = new ToolCompiler(); + tc.setWorkerCreds(AgentCompiler.collectToolCredentials(agent)); boolean hasApproval = allTools.stream().anyMatch(ToolConfig::isApprovalRequired); List> toolSpecs = tc.compileToolSpecs(allTools); @@ -1462,6 +1463,7 @@ private WorkflowDef compileSwarmAgentWorkflowWithSubAgents(AgentConfig agent, Li // 3. LLM step with transfer tools to decide whether to transfer to a peer ToolCompiler tc = new ToolCompiler(); + tc.setWorkerCreds(AgentCompiler.collectToolCredentials(agent)); List> transferToolSpecs = tc.compileToolSpecs(transferTools); WorkflowTask transferLlm = new WorkflowTask(); diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/ToolCompiler.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/ToolCompiler.java index 80553608a..04174addf 100644 --- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/ToolCompiler.java +++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/ToolCompiler.java @@ -119,6 +119,55 @@ private static Map escapeHeadersInConfig(Map cfg Map.entry("rag_search", "LLM_SEARCH_INDEX"), Map.entry("pull_workflow_messages", "PULL_WORKFLOW_MESSAGES")); + /** + * Per-tool credential names ({@code toolName -> [names]}) used to stamp + * {@code __resolved_credentials__} onto SIMPLE worker-tool tasks in EMBEDDED mode, so the host + * resolves each {@code ${workflow.secrets.NAME}} reference from its secret store at poll time. + * Set by {@link AgentCompiler} (preserves agent-level credential fallback); empty by default + * (no stamping — standalone, or non-worker tools whose secrets travel as headers). + */ + private Map> workerCreds = Map.of(); + + /** Inject per-tool credential names so worker-tool SIMPLE tasks can carry secret references. */ + void setWorkerCreds(Map> workerCreds) { + this.workerCreds = workerCreds != null ? workerCreds : Map.of(); + } + + /** True if a tool compiles to a SIMPLE worker task (executed by an external SDK worker). */ + private static boolean isWorkerTool(ToolConfig tool) { + String t = tool.getToolType() != null ? tool.getToolType() : "worker"; + return "SIMPLE".equals(TYPE_MAP.getOrDefault(t, "SIMPLE")); + } + + /** + * Build {@code {toolName -> {NAME: "${workflow.secrets.NAME}"}}} for this agent's SIMPLE + * worker tools, EMBEDDED only. The host resolves the references just-in-time at poll (via + * {@code ParametersUtils.substituteSecrets}); the SDK worker reads {@code __resolved_credentials__} + * from its task input and strips it. HTTP/MCP tools are excluded — their secrets travel as + * {@code ${workflow.secrets.NAME}} headers. + */ + private Map buildWorkerCredConfig(List tools) { + Map cfg = new LinkedHashMap<>(); + if (!EmbeddedMode.isEmbedded() || tools == null || workerCreds.isEmpty()) { + return cfg; + } + for (ToolConfig tool : tools) { + if (tool.getName() == null || !isWorkerTool(tool)) { + continue; + } + List names = workerCreds.get(tool.getName()); + if (names == null || names.isEmpty()) { + continue; + } + Map refs = new LinkedHashMap<>(); + for (String name : names) { + refs.put(name, "${workflow.secrets." + name + "}"); + } + cfg.put(tool.getName(), refs); + } + return cfg; + } + // ── Public API ─────────────────────────────────────────────────────── /** @@ -416,9 +465,11 @@ public Object[] buildEnrichTask(String agentName, String llmRef, List */ @ControllerAdvice +@ConditionalOnProperty(name = "agentspan.embedded", havingValue = "false", matchIfMissing = true) public class CredentialMaskingResponseAdvice implements ResponseBodyAdvice { private static final Logger log = LoggerFactory.getLogger(CredentialMaskingResponseAdvice.class); diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/controller/WorkerController.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/controller/WorkerController.java index c0fb9f0bb..f8813d0a3 100644 --- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/controller/WorkerController.java +++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/controller/WorkerController.java @@ -13,6 +13,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.http.ResponseEntity; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.web.bind.annotation.*; @@ -43,6 +44,7 @@ @RestController @RequestMapping("/api/workers") @RequiredArgsConstructor +@ConditionalOnProperty(name = "agentspan.embedded", havingValue = "false", matchIfMissing = true) public class WorkerController { private static final Logger log = LoggerFactory.getLogger(WorkerController.class); diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/credentials/CredentialAwareMcpService.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/credentials/CredentialAwareMcpService.java index bf99ebefc..7a4fcf5be 100644 --- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/credentials/CredentialAwareMcpService.java +++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/credentials/CredentialAwareMcpService.java @@ -13,6 +13,7 @@ import org.conductoross.conductor.ai.mcp.MCPService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Primary; import org.springframework.stereotype.Component; @@ -42,6 +43,7 @@ */ @Component @Primary +@ConditionalOnProperty(name = "agentspan.embedded", havingValue = "false", matchIfMissing = true) public class CredentialAwareMcpService extends MCPService { private static final Logger log = LoggerFactory.getLogger(CredentialAwareMcpService.class); diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/credentials/CredentialResolutionService.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/credentials/CredentialResolutionService.java index 909f4db60..7d83e526f 100644 --- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/credentials/CredentialResolutionService.java +++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/credentials/CredentialResolutionService.java @@ -6,6 +6,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; import com.fasterxml.jackson.databind.JsonNode; @@ -39,6 +40,7 @@ * own {@code os.environ} fallback when {@code secret_strict_mode=false}.

*/ @Service +@ConditionalOnProperty(name = "agentspan.embedded", havingValue = "false", matchIfMissing = true) public class CredentialResolutionService { private static final Logger log = LoggerFactory.getLogger(CredentialResolutionService.class); diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/credentials/ExecutionTokenService.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/credentials/ExecutionTokenService.java index ec778b5a7..18d87e671 100644 --- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/credentials/ExecutionTokenService.java +++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/credentials/ExecutionTokenService.java @@ -15,6 +15,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.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; @@ -31,6 +32,7 @@ * (bounded risk: tokens expire with workflow TTL).

*/ @Service +@ConditionalOnProperty(name = "agentspan.embedded", havingValue = "false", matchIfMissing = true) public class ExecutionTokenService { private static final Logger log = LoggerFactory.getLogger(ExecutionTokenService.class); diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/service/AgentEventListener.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/service/AgentEventListener.java index 96ab32c42..a094cf6cc 100644 --- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/service/AgentEventListener.java +++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/service/AgentEventListener.java @@ -266,6 +266,7 @@ private void handleWorkflowTerminated(WorkflowModel workflow) { } private void revokeWorkflowToken(WorkflowModel workflow) { + if (executionTokenService == null) return; // native token service gated off (embedded) try { Object ctx = workflow.getVariables() != null ? workflow.getVariables().get("__agentspan_ctx__") : null; diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/util/JavaScriptBuilder.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/util/JavaScriptBuilder.java index 91557657c..f7d07852e 100644 --- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/util/JavaScriptBuilder.java +++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/util/JavaScriptBuilder.java @@ -526,7 +526,8 @@ public static String enrichToolsScript( String cliConfigJson, String humanConfigJson, String wmqConfigJson, - String knownToolNamesJson) { + String knownToolNamesJson, + String workerCredJson) { return iife(" var httpCfg = " + httpConfigJson + ";" + " var mcpCfg = " + mcpConfigJson + ";" + " var mediaCfg = " + mediaConfigJson + ";" + " var agentToolCfg = " @@ -535,6 +536,7 @@ public static String enrichToolsScript( + cliConfigJson + ";" + " var humanCfg = " + humanConfigJson + ";" + " var wmqCfg = " + wmqConfigJson + ";" + " var knownNames = " + knownToolNamesJson + ";" + + " var workerCredCfg = " + workerCredJson + ";" + " var agentState = $.agentState || {};" + " var tcs = $.toolCalls || [];" + " var result = [];" @@ -695,6 +697,7 @@ public static String enrichToolsScript( + " if (t.type === 'SIMPLE') {" + " t.inputParameters._agent_state = agentState;" + " if ($.agentspanCtx) { t.inputParameters.__agentspan_ctx__ = $.agentspanCtx; }" + + " if (workerCredCfg[n]) { t.inputParameters.__resolved_credentials__ = workerCredCfg[n]; }" + " if (cliCfg[n]) { t.inputParameters._allowed_commands = cliCfg[n].allowedCommands; }" + " }" + " result.push(t);" @@ -1152,7 +1155,8 @@ public static String enrichToolsScriptDynamic( String ragConfigJson, String humanConfigJson, String wmqConfigJson, - String knownToolNamesJson) { + String knownToolNamesJson, + String workerCredJson) { return iife(" var httpCfg = " + httpConfigJson + ";" + " var mcpCfg = $.mcpConfig || {};" + " var apiCfg = $.apiConfig || {};" + " var mediaCfg = " @@ -1161,6 +1165,7 @@ public static String enrichToolsScriptDynamic( + ragConfigJson + ";" + " var humanCfg = " + humanConfigJson + ";" + " var wmqCfg = " + wmqConfigJson + ";" + " var knownNames = " + knownToolNamesJson + ";" + + " var workerCredCfg = " + workerCredJson + ";" + " var agentState = $.agentState || {};" + " var tcs = $.toolCalls || [];" + " var result = [];" @@ -1347,6 +1352,7 @@ public static String enrichToolsScriptDynamic( + " if (t.type === 'SIMPLE') {" + " t.inputParameters._agent_state = agentState;" + " if ($.agentspanCtx) { t.inputParameters.__agentspan_ctx__ = $.agentspanCtx; }" + + " if (workerCredCfg[n]) { t.inputParameters.__resolved_credentials__ = workerCredCfg[n]; }" + " }" + " result.push(t);" + " }" diff --git a/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/compiler/ToolCompilerWorkerCredTest.java b/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/compiler/ToolCompilerWorkerCredTest.java new file mode 100644 index 000000000..4386ab603 --- /dev/null +++ b/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/compiler/ToolCompilerWorkerCredTest.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 AgentSpan + * Licensed under the MIT License. + */ +package dev.agentspan.runtime.compiler; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; + +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Value; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.netflix.conductor.common.metadata.workflow.WorkflowTask; + +import dev.agentspan.runtime.model.AgentConfig; +import dev.agentspan.runtime.model.ToolConfig; +import dev.agentspan.runtime.util.EmbeddedMode; + +/** + * Verifies EMBEDDED mode stamps {@code __resolved_credentials__ = { NAME: "${workflow.secrets.NAME}" }} + * onto SIMPLE worker-tool tasks (via the enrich script), and that standalone / non-worker tools are + * left untouched. In embedded, the host resolves the references from its secret store at poll time. + */ +class ToolCompilerWorkerCredTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @AfterEach + void resetEmbedded() { + new EmbeddedMode().setEmbedded(false); + } + + private static ToolConfig worker(String name, String... creds) { + return ToolConfig.builder() + .name(name) + .description(name) + .toolType("worker") + .config(Map.of("credentials", List.of(creds))) + .build(); + } + + private static ToolCompiler compilerFor(AgentConfig config) { + ToolCompiler tc = new ToolCompiler(); + tc.setWorkerCreds(AgentCompiler.collectToolCredentials(config)); + return tc; + } + + private static String enrichScript(ToolCompiler tc, List tools) { + Object[] r = tc.buildEnrichTask("agent", "agent_llm", tools, ""); + return (String) ((WorkflowTask) r[0]).getInputParameters().get("expression"); + } + + /** Execute the enrich script through GraalJS for one tool call; return that task's built map. */ + @SuppressWarnings("unchecked") + private static Map runEnrichForTool(String script, String toolName) throws Exception { + String wrapped = "var $ = {toolCalls: [{name: '" + toolName + "', taskReferenceName: 'call_1'," + + " inputParameters: {}}], agentState: {}, userPrompt: 'test'};" + + " JSON.stringify(" + script + ");"; + try (Context ctx = Context.newBuilder("js").allowAllAccess(true).build()) { + Value v = ctx.eval("js", wrapped); + Map outer = MAPPER.readValue(v.asString(), Map.class); + List> tasks = (List>) outer.get("dynamicTasks"); + return tasks.stream().filter(t -> toolName.equals(t.get("name"))).findFirst().orElseThrow(); + } + } + + @Test + void embedded_stampsPerToolSecretReference() { + new EmbeddedMode().setEmbedded(true); + ToolConfig gh = worker("gh", "GITHUB_TOKEN"); + AgentConfig config = AgentConfig.builder().name("a").model("openai/gpt-4o").tools(List.of(gh)).build(); + + String script = enrichScript(compilerFor(config), List.of(gh)); + + assertThat(script).contains("\"gh\":{\"GITHUB_TOKEN\":\"${workflow.secrets.GITHUB_TOKEN}\"}"); + } + + @Test + @SuppressWarnings("unchecked") + void embedded_injectsResolvedCredentialsOntoSimpleTask() throws Exception { + new EmbeddedMode().setEmbedded(true); + ToolConfig gh = worker("gh", "GITHUB_TOKEN"); + AgentConfig config = AgentConfig.builder().name("a").model("openai/gpt-4o").tools(List.of(gh)).build(); + + Map task = runEnrichForTool(enrichScript(compilerFor(config), List.of(gh)), "gh"); + + Map input = (Map) task.get("inputParameters"); + Map resolved = (Map) input.get("__resolved_credentials__"); + assertThat(resolved).containsEntry("GITHUB_TOKEN", "${workflow.secrets.GITHUB_TOKEN}"); + } + + @Test + @SuppressWarnings("unchecked") + void standalone_leavesWorkerTaskUntouched() throws Exception { + new EmbeddedMode().setEmbedded(false); + ToolConfig gh = worker("gh", "GITHUB_TOKEN"); + AgentConfig config = AgentConfig.builder().name("a").model("openai/gpt-4o").tools(List.of(gh)).build(); + + String script = enrichScript(compilerFor(config), List.of(gh)); + assertThat(script).doesNotContain("__resolved_credentials__\":{\"GITHUB_TOKEN"); + + Map task = runEnrichForTool(script, "gh"); + Map input = (Map) task.get("inputParameters"); + assertThat(input).doesNotContainKey("__resolved_credentials__"); + } +} diff --git a/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/credentials/NativeSecretGatingTest.java b/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/credentials/NativeSecretGatingTest.java new file mode 100644 index 000000000..af066d18a --- /dev/null +++ b/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/credentials/NativeSecretGatingTest.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 AgentSpan + * Licensed under the MIT License. + */ +package dev.agentspan.runtime.credentials; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +import dev.agentspan.runtime.controller.WorkerController; +import dev.agentspan.runtime.spi.CredentialStoreProvider; + +/** + * Verifies the native secret mechanism toggles on {@code agentspan.embedded}: + * its beans are present in standalone mode (flag absent or {@code false}) and + * gated OFF when embedded ({@code agentspan.embedded=true}), where the host + * delivers secrets instead. + */ +class NativeSecretGatingTest { + + /** + * Registers the gated native beans (their class-level {@code @ConditionalOnProperty} + * is evaluated on import) and supplies mock collaborators so they can be constructed + * when the condition allows. + */ + @Configuration + @Import({WorkerController.class, CredentialResolutionService.class, ExecutionTokenService.class}) + static class NativeBeans {} + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withBean(CredentialStoreProvider.class, () -> mock(CredentialStoreProvider.class)) + .withBean("credentialMasterKey", byte[].class, () -> new byte[32]) + .withUserConfiguration(NativeBeans.class); + + @Test + void nativeBeans_present_whenFlagAbsent() { + runner.run( + ctx -> + assertThat(ctx) + .hasSingleBean(WorkerController.class) + .hasSingleBean(CredentialResolutionService.class) + .hasSingleBean(ExecutionTokenService.class)); + } + + @Test + void nativeBeans_present_whenStandalone() { + runner.withPropertyValues("agentspan.embedded=false") + .run( + ctx -> + assertThat(ctx) + .hasSingleBean(WorkerController.class) + .hasSingleBean(CredentialResolutionService.class) + .hasSingleBean(ExecutionTokenService.class)); + } + + @Test + void nativeBeans_dormant_whenEmbedded() { + runner.withPropertyValues("agentspan.embedded=true") + .run( + ctx -> + assertThat(ctx) + .doesNotHaveBean(WorkerController.class) + .doesNotHaveBean(CredentialResolutionService.class) + .doesNotHaveBean(ExecutionTokenService.class)); + } +} From 4af59af965610b667a314b78d3ca959f99812028 Mon Sep 17 00:00:00 2001 From: nicholascole Date: Thu, 9 Jul 2026 16:58:56 -0700 Subject: [PATCH 2/9] fix(build): compile against published conductor 3.32.0-rc.3 (drop local runtimemeta pin) AgentSpan references no PR #1255 API (it only emits ${workflow.secrets.NAME} strings; the embedded host resolves them at runtime via substituteSecrets/SecretsDAO), so it builds against the published conductor. The local 3.32.0-rc.3-runtimemeta-LOCAL build added a conductor-side SecretResource that shadowed agentspan's SecretController GET /api/secrets, breaking CredentialControllerTest in standalone. Reverting the pin fixes it; the full server suite is green. Co-Authored-By: Claude Opus 4.8 (1M context) --- design/2026-07-09-embedded-secret-toggle-design.md | 13 ++++++++----- server/build.gradle | 10 +++++----- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/design/2026-07-09-embedded-secret-toggle-design.md b/design/2026-07-09-embedded-secret-toggle-design.md index c0f7cf58d..7248e7426 100644 --- a/design/2026-07-09-embedded-secret-toggle-design.md +++ b/design/2026-07-09-embedded-secret-toggle-design.md @@ -100,11 +100,14 @@ No client-library change is needed (the map rides in the preserved `inputData`). ## 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.** +AgentSpan references **no** PR #1255 API — it only emits `${workflow.secrets.NAME}` strings — so it +compiles and tests against the **published** conductor (`conductorVersion = 3.32.0-rc.3`). The +`${workflow.secrets.NAME}` references are resolved **at runtime by the embedded host** conductor's +`ParametersUtils.substituteSecrets` / `SecretsDAO` (conductor-oss PR #1255). The host must therefore +run a conductor build that includes PR #1255; agentspan does not need to build against it. +**No SDK client-library changes are required.** (An earlier iteration pinned a local +`3.32.0-rc.3-runtimemeta-LOCAL` build — reverted, because that build's new conductor-side +`SecretResource` shadowed agentspan's `SecretController` `GET /api/secrets` in standalone tests.) ## Tests diff --git a/server/build.gradle b/server/build.gradle index 0a0e56535..ee3a3b9c8 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -15,11 +15,11 @@ repositories { // ── Version catalog ────────────────────────────────────────────── ext { - // 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' + // AgentSpan compiles/tests against the published conductor. Embedded secret delivery uses + // ${workflow.secrets.NAME} references resolved at runtime by the HOST conductor's + // ParametersUtils.substituteSecrets / SecretsDAO (conductor-oss PR #1255) — agentspan itself + // references no PR #1255 API, so it does not need to build against it. + conductorVersion = '3.32.0-rc.3' lombokVersion = '1.18.42' log4jVersion = '2.24.3' sqliteJdbcVersion = '3.47.0.0' From 22bb471fa7251391f808d0b798cce554c10ab9d1 Mon Sep 17 00:00:00 2001 From: nicholascole Date: Thu, 9 Jul 2026 17:38:28 -0700 Subject: [PATCH 3/9] style(server): apply spotless formatting to secret-toggle changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the server-tests CI failure (:spotlessJavaCheck) — reformat ToolCompiler and the two new test classes to the project's spotless style. No logic change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../runtime/compiler/ToolCompiler.java | 10 ++++- .../compiler/ToolCompilerWorkerCredTest.java | 23 +++++++++-- .../credentials/NativeSecretGatingTest.java | 41 ++++++++----------- 3 files changed, 44 insertions(+), 30 deletions(-) diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/ToolCompiler.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/ToolCompiler.java index 04174addf..996d011d0 100644 --- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/ToolCompiler.java +++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/ToolCompiler.java @@ -468,7 +468,15 @@ public Object[] buildEnrichTask(String agentName, String llmRef, List runEnrichForTool(String script, String toolNa Value v = ctx.eval("js", wrapped); Map outer = MAPPER.readValue(v.asString(), Map.class); List> tasks = (List>) outer.get("dynamicTasks"); - return tasks.stream().filter(t -> toolName.equals(t.get("name"))).findFirst().orElseThrow(); + return tasks.stream() + .filter(t -> toolName.equals(t.get("name"))) + .findFirst() + .orElseThrow(); } } @@ -73,7 +76,11 @@ private static Map runEnrichForTool(String script, String toolNa void embedded_stampsPerToolSecretReference() { new EmbeddedMode().setEmbedded(true); ToolConfig gh = worker("gh", "GITHUB_TOKEN"); - AgentConfig config = AgentConfig.builder().name("a").model("openai/gpt-4o").tools(List.of(gh)).build(); + AgentConfig config = AgentConfig.builder() + .name("a") + .model("openai/gpt-4o") + .tools(List.of(gh)) + .build(); String script = enrichScript(compilerFor(config), List.of(gh)); @@ -85,7 +92,11 @@ void embedded_stampsPerToolSecretReference() { void embedded_injectsResolvedCredentialsOntoSimpleTask() throws Exception { new EmbeddedMode().setEmbedded(true); ToolConfig gh = worker("gh", "GITHUB_TOKEN"); - AgentConfig config = AgentConfig.builder().name("a").model("openai/gpt-4o").tools(List.of(gh)).build(); + AgentConfig config = AgentConfig.builder() + .name("a") + .model("openai/gpt-4o") + .tools(List.of(gh)) + .build(); Map task = runEnrichForTool(enrichScript(compilerFor(config), List.of(gh)), "gh"); @@ -99,7 +110,11 @@ void embedded_injectsResolvedCredentialsOntoSimpleTask() throws Exception { void standalone_leavesWorkerTaskUntouched() throws Exception { new EmbeddedMode().setEmbedded(false); ToolConfig gh = worker("gh", "GITHUB_TOKEN"); - AgentConfig config = AgentConfig.builder().name("a").model("openai/gpt-4o").tools(List.of(gh)).build(); + AgentConfig config = AgentConfig.builder() + .name("a") + .model("openai/gpt-4o") + .tools(List.of(gh)) + .build(); String script = enrichScript(compilerFor(config), List.of(gh)); assertThat(script).doesNotContain("__resolved_credentials__\":{\"GITHUB_TOKEN"); diff --git a/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/credentials/NativeSecretGatingTest.java b/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/credentials/NativeSecretGatingTest.java index af066d18a..1176fae3a 100644 --- a/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/credentials/NativeSecretGatingTest.java +++ b/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/credentials/NativeSecretGatingTest.java @@ -32,41 +32,32 @@ class NativeSecretGatingTest { @Import({WorkerController.class, CredentialResolutionService.class, ExecutionTokenService.class}) static class NativeBeans {} - private final ApplicationContextRunner runner = - new ApplicationContextRunner() - .withBean(CredentialStoreProvider.class, () -> mock(CredentialStoreProvider.class)) - .withBean("credentialMasterKey", byte[].class, () -> new byte[32]) - .withUserConfiguration(NativeBeans.class); + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withBean(CredentialStoreProvider.class, () -> mock(CredentialStoreProvider.class)) + .withBean("credentialMasterKey", byte[].class, () -> new byte[32]) + .withUserConfiguration(NativeBeans.class); @Test void nativeBeans_present_whenFlagAbsent() { - runner.run( - ctx -> - assertThat(ctx) - .hasSingleBean(WorkerController.class) - .hasSingleBean(CredentialResolutionService.class) - .hasSingleBean(ExecutionTokenService.class)); + runner.run(ctx -> assertThat(ctx) + .hasSingleBean(WorkerController.class) + .hasSingleBean(CredentialResolutionService.class) + .hasSingleBean(ExecutionTokenService.class)); } @Test void nativeBeans_present_whenStandalone() { - runner.withPropertyValues("agentspan.embedded=false") - .run( - ctx -> - assertThat(ctx) - .hasSingleBean(WorkerController.class) - .hasSingleBean(CredentialResolutionService.class) - .hasSingleBean(ExecutionTokenService.class)); + runner.withPropertyValues("agentspan.embedded=false").run(ctx -> assertThat(ctx) + .hasSingleBean(WorkerController.class) + .hasSingleBean(CredentialResolutionService.class) + .hasSingleBean(ExecutionTokenService.class)); } @Test void nativeBeans_dormant_whenEmbedded() { - runner.withPropertyValues("agentspan.embedded=true") - .run( - ctx -> - assertThat(ctx) - .doesNotHaveBean(WorkerController.class) - .doesNotHaveBean(CredentialResolutionService.class) - .doesNotHaveBean(ExecutionTokenService.class)); + runner.withPropertyValues("agentspan.embedded=true").run(ctx -> assertThat(ctx) + .doesNotHaveBean(WorkerController.class) + .doesNotHaveBean(CredentialResolutionService.class) + .doesNotHaveBean(ExecutionTokenService.class)); } } From cc09c5d3afa035c7f92e5a83975dfdbc7307a9c1 Mon Sep 17 00:00:00 2001 From: nicholascole Date: Thu, 9 Jul 2026 18:01:58 -0700 Subject: [PATCH 4/9] =?UTF-8?q?docs(credentials):=20clarify=20target=20des?= =?UTF-8?q?ign=20=E2=80=94=20TaskDef.runtimeMetadata=20for=20workers,=20${?= =?UTF-8?q?workflow.secrets}=20for=20system=20tasks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State the intended split explicitly: worker tools use TaskDef.runtimeMetadata (host resolves onto wire-only Task.runtimeMetadata at poll); system tasks (LLM apiKey, HTTP/MCP/planner headers) use ${workflow.secrets.NAME}. Add a per-SDK table specifying exactly what the `Task` model change is in each conductor client library (java-sdk, python-sdk, csharp-sdk, orkes TS SDK) required to adopt the target, plus a migration checklist. Document the shipped __resolved_credentials__ path as the interim, client-compatible worker delivery until those clients ship the runtimeMetadata field. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...026-07-09-embedded-secret-toggle-design.md | 191 ++++++++++-------- 1 file changed, 105 insertions(+), 86 deletions(-) diff --git a/design/2026-07-09-embedded-secret-toggle-design.md b/design/2026-07-09-embedded-secret-toggle-design.md index 7248e7426..9b3d92514 100644 --- a/design/2026-07-09-embedded-secret-toggle-design.md +++ b/design/2026-07-09-embedded-secret-toggle-design.md @@ -10,34 +10,63 @@ AgentSpan keeps its full native credential mechanism. A single feature flag, | 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. +| **Embedded** in orkes-conductor / conductor-oss | `true` | **Native dormant** (all beans gated off); the **host** delivers secrets. | + +## Target design (what we want) + +When embedded, secrets are delivered by two distinct mechanisms — deliberately split by task type: + +- **Worker tools (SIMPLE tasks) → `TaskDef.runtimeMetadata`.** The agent's worker-tool `TaskDef` + declares the secret/env names it needs in `runtimeMetadata` (conductor-oss PR #1255). At poll + time the host resolves each name from its `SecretsDAO`/env and injects the resolved + `name → value` map onto the **wire-only `Task.runtimeMetadata`** field of the polled task. The + SDK worker reads `task.runtimeMetadata`. This is the correct home for worker secrets: it is + never persisted into task input, and it is declarative on the TaskDef. +- **System tasks (LLM `apiKey`, HTTP/MCP/planner headers) → `${workflow.secrets.NAME}`.** These run + in-process on the host, so the compiler stamps `${workflow.secrets.NAME}` references into the task + input and the host substitutes them in memory just before execution. These tasks are not polled by + an external SDK worker, so there is no `Task.runtimeMetadata` to read — `${workflow.secrets.NAME}` + is the right mechanism for them. Nothing is deleted; the native code stays intact and active for standalone. -## Why `${workflow.secrets}` in input, not `Task.runtimeMetadata` +## Required change in each Conductor client SDK ⬅️ blocking for the target + +`Task.runtimeMetadata` is a **new top-level field** on the polled task. Today's client libraries +bundle their own `Task` model with **no such field and no catch-all deserializer** +(`@JsonAnySetter` / additionalProperties), so the value the server injects is **silently dropped on +the wire**. To adopt the target worker path, the `runtimeMetadata` field must be added to the `Task` +model in **each** conductor client library an agentspan SDK depends on, then that client must be +released and the agentspan SDK bumped to it. + +The field is always the same shape: JSON key `runtimeMetadata`, a string→string map, output-empty +omitted, **not** persisted server-side (wire-only on the poll response). + +| AgentSpan SDK | Client dependency | Client repo | Exact change to the `Task` model | +|---|---|---|---| +| Java | `org.conductoross:conductor-client:5.0.1` | `conductor-oss/java-sdk` | Add `private Map runtimeMetadata = new HashMap<>();` + `getRuntimeMetadata()`/`setRuntimeMetadata()` to `com.netflix.conductor.common.metadata.tasks.Task`. Jackson maps the `runtimeMetadata` key automatically (add `@JsonInclude(NON_EMPTY)` to match server). | +| Python | `conductor-python>=1.3.11` | `conductor-oss/python-sdk` | In the generated `Task` model (`conductor/client/http/models/task.py`): add `runtime_metadata` to `swagger_types` (`'dict(str, str)'`), to `attribute_map` (`'runtime_metadata': 'runtimeMetadata'`), and a `@property` + setter. Then `task.runtime_metadata` is populated. | +| C# | `conductor-csharp:1.1.4` | `conductor-oss/csharp-sdk` | Add `public Dictionary RuntimeMetadata { get; set; }` to the `Task` model with `[DataMember(Name="runtimeMetadata", EmitDefaultValue=false)]` (and matching `Newtonsoft`/STJ attribute) so the `runtimeMetadata` JSON key binds. | +| TypeScript | `@io-orkes/conductor-javascript:^3.0.3` | Orkes TS SDK (`orkes-io/orkes-conductor-client-js`) | Add `runtimeMetadata?: Record` to the `Task` type. JS keeps unknown JSON keys, so the value already survives at runtime — this is a type-definition change for type-safety and for the read-path to compile. | + +(Go — `conductor-oss/go-sdk` — would need the same field on its `Task` struct, but AgentSpan ships +no Go SDK, so it is out of scope here.) -PR #1255 offers two poll-time delivery paths. We deliberately use only the input-reference one: +Once these land, the agentspan side switches to the target (see "Migration to the target" below). -- **`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.** +## Interim implementation (what is shipped on this branch) + +Because the client libraries above do **not yet** expose `runtimeMetadata`, this branch ships an +**interim, client-compatible** worker path that requires **no client rebuilds**: the compiler stamps +`inputParameters.__resolved_credentials__ = { NAME: "${workflow.secrets.NAME}" }` on SIMPLE +worker-tool tasks, and the host resolves those references at poll time via PR #1255's +`ParametersUtils.substituteSecrets(task.getInputData())` (walks nested maps, resolves from +`SecretsDAO`). `__resolved_credentials__` rides in the task **input `Map`**, which every client +already preserves, so the SDK worker can read it today. Same security property as the target — +persisted input keeps the reference; plaintext appears only in the poll response. + +**System-task delivery is identical to the target already** (`${workflow.secrets.NAME}`), so only the +worker path differs between interim and target. ## Server changes @@ -52,82 +81,72 @@ tolerate their absence: `AgentspanAIModelProvider` injects the two services via (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 — system tasks (target, ✅ 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}`. -**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 +**Part B — worker tools (interim `__resolved_credentials__`, ✅ done + tested).** +`ToolCompiler.buildWorkerCredConfig` + `JavaScriptBuilder` enrich-script injection + +`AgentCompiler.collectToolCredentials` stamp the `__resolved_credentials__` map onto SIMPLE tasks +(baked as a literal so the `${workflow.secrets}` references resolve at each SIMPLE task's own poll, +not prematurely in the INLINE enrich task). Verified by `ToolCompilerWorkerCredTest` (GraalJS). + +## SDK read-path + +Each SDK worker **auto-detects**: prefer the host-delivered map, else fall back to the native +token-pull (standalone). 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. + +- **Interim (shipped):** read `inputData.__resolved_credentials__`. Done + tested in all four SDKs: + TS (`worker.ts`/`credentials.ts`), Java (`WorkerManager.readResolvedCredentials`), Python + (`_dispatch.py`), C# (`WorkerManager.ReadResolvedCredentials`). +- **Target (after the client SDKs ship `runtimeMetadata`):** read `task.runtimeMetadata` + (`task.runtime_metadata` in Python, `RuntimeMetadata` in C#). Same fallback + injection. + +## Migration to the target (once the client SDKs expose `runtimeMetadata`) + +1. Land the `Task.runtimeMetadata` field in each client library (table above) and release; bump the + client version in each agentspan SDK (`sdk/java/build.gradle`, `sdk/python/pyproject.toml`, + `sdk/csharp/.../*.csproj`, `sdk/typescript/package.json`). +2. **Server:** declare `TaskDef.runtimeMetadata = [names]` on each worker tool's registered `TaskDef` + (in `AgentService.registerTaskDef`, gated on `EmbeddedMode.isEmbedded()`) instead of stamping + `__resolved_credentials__`; drop the enrich-script `workerCredCfg` injection. System-task + `${workflow.secrets}` stamping is unchanged. +3. **SDKs:** switch each worker read-path from `inputData.__resolved_credentials__` to + `task.runtimeMetadata`; keep the native-fetch fallback. +4. Remove the interim `__resolved_credentials__` stamping/read once all SDKs are on the new clients. ## Dependency -AgentSpan references **no** PR #1255 API — it only emits `${workflow.secrets.NAME}` strings — so it -compiles and tests against the **published** conductor (`conductorVersion = 3.32.0-rc.3`). The -`${workflow.secrets.NAME}` references are resolved **at runtime by the embedded host** conductor's -`ParametersUtils.substituteSecrets` / `SecretsDAO` (conductor-oss PR #1255). The host must therefore -run a conductor build that includes PR #1255; agentspan does not need to build against it. -**No SDK client-library changes are required.** (An earlier iteration pinned a local -`3.32.0-rc.3-runtimemeta-LOCAL` build — reverted, because that build's new conductor-side -`SecretResource` shadowed agentspan's `SecretController` `GET /api/secrets` in standalone tests.) +AgentSpan references **no** PR #1255 API — the interim path only emits `${workflow.secrets.NAME}` +strings — so it compiles and tests against the **published** conductor (`conductorVersion = +3.32.0-rc.3`). The `${workflow.secrets.NAME}` references (and, in the target, the +`TaskDef.runtimeMetadata` resolution) are performed **at runtime by the embedded host** conductor's +`ParametersUtils.substituteSecrets` / `RuntimeMetadataResolver` / `SecretsDAO` (conductor-oss +PR #1255). The host must run a conductor build that includes PR #1255; agentspan does not build +against it. (An earlier iteration pinned a local `3.32.0-rc.3-runtimemeta-LOCAL` build — reverted, +because that build's new conductor-side `SecretResource` shadowed agentspan's `SecretController` +`GET /api/secrets` in standalone tests.) ## 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. +- `ToolCompilerWorkerCredTest` ✅ — GraalJS-executes the enrich script; SIMPLE task carries + `__resolved_credentials__` when embedded, nothing standalone; fail-first validated. +- `ReadResolvedCredentialsTest` (Java) ✅, `test_resolved_credentials.py` (Python) ✅, TS + `credentials.test.ts` ✅ — host-delivered read-path, all fail-first validated. +- Full CI green: server-tests, build-server, all four SDK unit + e2e suites. ## 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) | +| System-task `${workflow.secrets}` (target = shipped) | ✅ done | +| Worker-tool delivery — **interim** `__resolved_credentials__` | ✅ done + tested (shipped) | +| Worker-tool delivery — **target** `TaskDef.runtimeMetadata` | ⏳ blocked on client-SDK `runtimeMetadata` field (table above) | +| SDK read-path — interim (`__resolved_credentials__`), all 4 SDKs | ✅ done (C# not run locally — CI green) | +| SDK read-path — target (`task.runtimeMetadata`), all 4 SDKs | ⏳ after client SDKs ship the field | From 19dd4d3825962cef856cd93f441f986b86ef6f31 Mon Sep 17 00:00:00 2001 From: nicholascole Date: Thu, 9 Jul 2026 19:43:27 -0700 Subject: [PATCH 5/9] docs(credentials): tighten embedded-secret design; add enrichment diagram + secret-resolution demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite the design doc to be concise: split-by-task-type delivery, interim enrichment sequence diagram + caveat, target-vs-interim (safety, not perf), per-SDK Task.runtimeMetadata change table, and a single status table. - Add sdk/python/examples/demo_secret_resolution.py — a minimal 1-tool + LLM agent that reads DEMO_SECRET via get_secret(); doubles as a CI smoke check (exit 1 unless the secret resolved and the workflow completed). Validated end-to-end on local Orkes via SDK run and webhook trigger. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...026-07-09-embedded-secret-toggle-design.md | 265 +++++++++--------- sdk/python/examples/demo_secret_resolution.py | 118 ++++++++ 2 files changed, 247 insertions(+), 136 deletions(-) create mode 100644 sdk/python/examples/demo_secret_resolution.py diff --git a/design/2026-07-09-embedded-secret-toggle-design.md b/design/2026-07-09-embedded-secret-toggle-design.md index 9b3d92514..a060b593e 100644 --- a/design/2026-07-09-embedded-secret-toggle-design.md +++ b/design/2026-07-09-embedded-secret-toggle-design.md @@ -4,149 +4,142 @@ ## Summary -AgentSpan keeps its full native credential mechanism. A single feature flag, -`agentspan.embedded`, toggles it on/off: +AgentSpan keeps its full native credential mechanism and toggles it with one flag, `agentspan.embedded`: -| Deployment | `agentspan.embedded` | Secret delivery | +| Deployment | `agentspan.embedded` | Secrets | |---|---|---| -| **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** delivers secrets. | - -## Target design (what we want) - -When embedded, secrets are delivered by two distinct mechanisms — deliberately split by task type: - -- **Worker tools (SIMPLE tasks) → `TaskDef.runtimeMetadata`.** The agent's worker-tool `TaskDef` - declares the secret/env names it needs in `runtimeMetadata` (conductor-oss PR #1255). At poll - time the host resolves each name from its `SecretsDAO`/env and injects the resolved - `name → value` map onto the **wire-only `Task.runtimeMetadata`** field of the polled task. The - SDK worker reads `task.runtimeMetadata`. This is the correct home for worker secrets: it is - never persisted into task input, and it is declarative on the TaskDef. -- **System tasks (LLM `apiKey`, HTTP/MCP/planner headers) → `${workflow.secrets.NAME}`.** These run - in-process on the host, so the compiler stamps `${workflow.secrets.NAME}` references into the task - input and the host substitutes them in memory just before execution. These tasks are not polled by - an external SDK worker, so there is no `Task.runtimeMetadata` to read — `${workflow.secrets.NAME}` - is the right mechanism for them. - -Nothing is deleted; the native code stays intact and active for standalone. - -## Required change in each Conductor client SDK ⬅️ blocking for the target - -`Task.runtimeMetadata` is a **new top-level field** on the polled task. Today's client libraries -bundle their own `Task` model with **no such field and no catch-all deserializer** -(`@JsonAnySetter` / additionalProperties), so the value the server injects is **silently dropped on -the wire**. To adopt the target worker path, the `runtimeMetadata` field must be added to the `Task` -model in **each** conductor client library an agentspan SDK depends on, then that client must be -released and the agentspan SDK bumped to it. - -The field is always the same shape: JSON key `runtimeMetadata`, a string→string map, output-empty -omitted, **not** persisted server-side (wire-only on the poll response). - -| AgentSpan SDK | Client dependency | Client repo | Exact change to the `Task` model | +| **Standalone** agentspan server | `false` (default) | **Native**: encrypted store, execution-token minting, `/api/workers/secrets` pull, SDK fetchers. Unchanged from `main`. | +| **Embedded** in orkes-conductor / conductor-oss | `true` | **Native dormant** (beans gated off); the **host** resolves secrets. | + +Nothing is deleted — the native code stays intact for standalone. + +## How secrets are delivered when embedded (split by task type) + +- **Worker tools (SIMPLE, polled by the SDK)** → the worker's `TaskDef.runtimeMetadata` declares the + secret names; the host resolves them at poll and injects the values onto the **wire-only + `Task.runtimeMetadata`**. This is the **target**. Until the client SDKs expose that field (see + table), we ship an **interim**: the compiler stamps + `inputParameters.__resolved_credentials__ = {NAME: "${workflow.secrets.NAME}"}` and the host + resolves it — same delivery, but it rides in the task-input `Map` that today's clients already keep. +- **System tasks (LLM `apiKey`, HTTP/MCP/planner headers — in-process on the host)** → the compiler + stamps `${workflow.secrets.NAME}` into task input; the host substitutes in memory before the call. + Not polled by a worker, so there's no `Task.runtimeMetadata` to read. Identical for target and interim. + +## Interim worker path (enrichment) — how it actually works + +Worker tools aren't static: the LLM picks them, an **INLINE "enrich" task** (GraalJS) builds the +SIMPLE tasks at runtime, and a `FORK_JOIN_DYNAMIC` schedules them. The per-tool cred map is baked +into the enrich script at compile time. + +```mermaid +sequenceDiagram + autonumber + participant C as Compiler + participant WF as WorkflowDef + participant LLM as LLM task + participant EN as Enrich task (INLINE / GraalJS) + participant H as Host (Orkes secretsDAO) + participant FK as FORK_JOIN_DYNAMIC + participant W as SDK worker (SIMPLE task) + participant T as Tool fn + + Note over C,WF: compile / register time (embedded only) + C->>C: collectToolCredentials(agent) - tool creds, agent-level fallback + C->>C: buildWorkerCredConfig - map each tool to its workflow.secrets refs + C->>WF: bake the cred map into the enrich INLINE script + Note over LLM,T: execution time + LLM->>EN: toolCalls (which tools to run) + H->>EN: substituteSecrets resolves the secret refs to plaintext + Note right of EN: caveat - resolves here, not at the SIMPLE task poll + EN->>EN: set inputParameters.__resolved_credentials__ on each SIMPLE task + EN->>FK: dynamicTasks + FK->>W: schedule and poll the SIMPLE task + W->>W: read __resolved_credentials__, set CredentialContext, strip key + W->>T: run tool, then get_secret(NAME) returns the value + T-->>W: result +``` + +**Caveat:** because the reference is baked into the INLINE script, the host resolves +`${workflow.secrets.NAME}` **at the enrich step**, not at the SIMPLE task's poll. So plaintext lands +in the forked task's **persisted** input, and a secret with JS-special chars (`"`, `\`, newline) can +break the script. The target fixes both. + +## Target vs interim — same runtime cost, better safety + +`get_secret(NAME)` (and `getCredential` / `ToolContext.getCredential` / `Secrets.Get`) is an +**in-memory lookup**: the worker stashes the resolved `{NAME: value}` map in a per-invocation context +and the accessor reads it. When embedded, the value is delivered **inline with the task** (poll +response) in both paths — **no extra calls** (only the standalone native path calls +`/api/workers/secrets`). So the choice is about safety, not performance — the target wins on: + +1. **Wire-only, never persisted** — `Task.runtimeMetadata` is on the poll response only; the interim + bakes plaintext into the forked task's persisted input (visible in execution history). +2. **No JS-injection** — declared names on the TaskDef vs a `${...}` reference baked into GraalJS + (special chars break it). +3. **Resolved at the SIMPLE task's own poll**, scoped to that task — not early, in a shared enrich task. +4. **First-class & declarative** — the conductor-native field vs a magic `__resolved_credentials__` key. + +Cost: the target needs the client libraries to expose `Task.runtimeMetadata` first — which is why the +interim ships now. + +## Required change in each Conductor client SDK (blocks the target) + +`Task.runtimeMetadata` is a new top-level field; today's clients drop it on the wire (no field, no +catch-all deserializer). Add it to each client's `Task` model (JSON key `runtimeMetadata`, +string→string, output-empty omitted), release, and bump the SDK's client dependency. + +| AgentSpan SDK | Client dependency | Client repo | Change to the `Task` model | |---|---|---|---| -| Java | `org.conductoross:conductor-client:5.0.1` | `conductor-oss/java-sdk` | Add `private Map runtimeMetadata = new HashMap<>();` + `getRuntimeMetadata()`/`setRuntimeMetadata()` to `com.netflix.conductor.common.metadata.tasks.Task`. Jackson maps the `runtimeMetadata` key automatically (add `@JsonInclude(NON_EMPTY)` to match server). | -| Python | `conductor-python>=1.3.11` | `conductor-oss/python-sdk` | In the generated `Task` model (`conductor/client/http/models/task.py`): add `runtime_metadata` to `swagger_types` (`'dict(str, str)'`), to `attribute_map` (`'runtime_metadata': 'runtimeMetadata'`), and a `@property` + setter. Then `task.runtime_metadata` is populated. | -| C# | `conductor-csharp:1.1.4` | `conductor-oss/csharp-sdk` | Add `public Dictionary RuntimeMetadata { get; set; }` to the `Task` model with `[DataMember(Name="runtimeMetadata", EmitDefaultValue=false)]` (and matching `Newtonsoft`/STJ attribute) so the `runtimeMetadata` JSON key binds. | -| TypeScript | `@io-orkes/conductor-javascript:^3.0.3` | Orkes TS SDK (`orkes-io/orkes-conductor-client-js`) | Add `runtimeMetadata?: Record` to the `Task` type. JS keeps unknown JSON keys, so the value already survives at runtime — this is a type-definition change for type-safety and for the read-path to compile. | - -(Go — `conductor-oss/go-sdk` — would need the same field on its `Task` struct, but AgentSpan ships -no Go SDK, so it is out of scope here.) - -Once these land, the agentspan side switches to the target (see "Migration to the target" below). - -## Interim implementation (what is shipped on this branch) - -Because the client libraries above do **not yet** expose `runtimeMetadata`, this branch ships an -**interim, client-compatible** worker path that requires **no client rebuilds**: the compiler stamps -`inputParameters.__resolved_credentials__ = { NAME: "${workflow.secrets.NAME}" }` on SIMPLE -worker-tool tasks, and the host resolves those references at poll time via PR #1255's -`ParametersUtils.substituteSecrets(task.getInputData())` (walks nested maps, resolves from -`SecretsDAO`). `__resolved_credentials__` rides in the task **input `Map`**, which every client -already preserves, so the SDK worker can read it today. Same security property as the target — -persisted input keeps the reference; plaintext appears only in the poll response. - -**System-task delivery is identical to the target already** (`${workflow.secrets.NAME}`), so only the -worker path differs between interim and target. - -## 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 tasks (target, ✅ 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}`. - -**Part B — worker tools (interim `__resolved_credentials__`, ✅ done + tested).** -`ToolCompiler.buildWorkerCredConfig` + `JavaScriptBuilder` enrich-script injection + -`AgentCompiler.collectToolCredentials` stamp the `__resolved_credentials__` map onto SIMPLE tasks -(baked as a literal so the `${workflow.secrets}` references resolve at each SIMPLE task's own poll, -not prematurely in the INLINE enrich task). Verified by `ToolCompilerWorkerCredTest` (GraalJS). - -## SDK read-path - -Each SDK worker **auto-detects**: prefer the host-delivered map, else fall back to the native -token-pull (standalone). 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. - -- **Interim (shipped):** read `inputData.__resolved_credentials__`. Done + tested in all four SDKs: - TS (`worker.ts`/`credentials.ts`), Java (`WorkerManager.readResolvedCredentials`), Python - (`_dispatch.py`), C# (`WorkerManager.ReadResolvedCredentials`). -- **Target (after the client SDKs ship `runtimeMetadata`):** read `task.runtimeMetadata` - (`task.runtime_metadata` in Python, `RuntimeMetadata` in C#). Same fallback + injection. - -## Migration to the target (once the client SDKs expose `runtimeMetadata`) - -1. Land the `Task.runtimeMetadata` field in each client library (table above) and release; bump the - client version in each agentspan SDK (`sdk/java/build.gradle`, `sdk/python/pyproject.toml`, - `sdk/csharp/.../*.csproj`, `sdk/typescript/package.json`). -2. **Server:** declare `TaskDef.runtimeMetadata = [names]` on each worker tool's registered `TaskDef` - (in `AgentService.registerTaskDef`, gated on `EmbeddedMode.isEmbedded()`) instead of stamping - `__resolved_credentials__`; drop the enrich-script `workerCredCfg` injection. System-task - `${workflow.secrets}` stamping is unchanged. -3. **SDKs:** switch each worker read-path from `inputData.__resolved_credentials__` to - `task.runtimeMetadata`; keep the native-fetch fallback. -4. Remove the interim `__resolved_credentials__` stamping/read once all SDKs are on the new clients. +| Java | `org.conductoross:conductor-client:5.0.1` | `conductor-oss/java-sdk` | Add `Map runtimeMetadata` + getter/setter to `com.netflix.conductor.common.metadata.tasks.Task` (Jackson auto-maps; `@JsonInclude(NON_EMPTY)`). | +| Python | `conductor-python>=1.3.11` | `conductor-oss/python-sdk` | In `.../models/task.py`: add `runtime_metadata` to `swagger_types`/`attribute_map` (`'runtimeMetadata'`) + property. | +| C# | `conductor-csharp:1.1.4` | `conductor-oss/csharp-sdk` | Add `Dictionary RuntimeMetadata` with `[DataMember(Name="runtimeMetadata", EmitDefaultValue=false)]`. | +| TypeScript | `@io-orkes/conductor-javascript:^3.0.3` | Orkes TS SDK | Add `runtimeMetadata?: Record` to the `Task` type (JS keeps unknown keys; this is a type-def change so the read-path compiles). | + +(Go is out of scope — AgentSpan ships no Go SDK.) + +## Implementation (done + tested; CI green) + +- **Gating:** `@ConditionalOnProperty(agentspan.embedded=false, matchIfMissing=true)` on every native + secret bean — `WorkerController`, `CredentialResolutionService`, `ExecutionTokenService`, + `CredentialAwareMcpService`, `CredentialMaskingResponseAdvice`, `EncryptedDbCredentialStoreProvider`, + `MasterKeyConfig`, `CredentialEnvSeeder`, `CredentialSchemaMigrator`, `CredentialDataSourceConfig`, + `NoOpSecretOutputMasker`. Active consumers made tolerant: `AgentspanAIModelProvider` (`ObjectProvider` + + guards); `AgentService` / `AgentEventListener` (`@Autowired(required=false)` + null guards, so token + minting is skipped). +- **System tasks:** `AgentChatCompleteTaskMapper.injectCredentialReferences` + `LlmProviderEnv` stamp + `apiKey=${workflow.secrets.}`; `AgentspanAIModelProvider` reads it back. HTTP/MCP/planner headers + already emit `${workflow.secrets.NAME}`. +- **Worker tools (interim):** `ToolCompiler.buildWorkerCredConfig` + `JavaScriptBuilder` enrich + injection + `AgentCompiler.collectToolCredentials`. +- **SDK read-path (all 4):** prefer the host map, else native token-pull; feed the existing accessor; + strip the key. Interim reads `inputData.__resolved_credentials__`; target reads `task.runtimeMetadata`. +- **Tests (all fail-first validated):** `NativeSecretGatingTest`, `ToolCompilerWorkerCredTest` + (GraalJS-runs the enrich script), `ReadResolvedCredentialsTest` (Java), `test_resolved_credentials.py` + (Python), TS `credentials.test.ts`. + +## Migration to the target (when clients expose `runtimeMetadata`) + +1. Land the field in each client (table), release, bump the SDK client versions. +2. **Server:** set `TaskDef.runtimeMetadata=[names]` in `AgentService.registerTaskDef` (embedded-gated); + drop the enrich `workerCredCfg` injection. System-task stamping unchanged. +3. **SDKs:** read `task.runtimeMetadata` instead of `inputData.__resolved_credentials__`; keep the + native fallback. +4. Remove the interim once all SDKs are on the new clients. ## Dependency -AgentSpan references **no** PR #1255 API — the interim path only emits `${workflow.secrets.NAME}` -strings — so it compiles and tests against the **published** conductor (`conductorVersion = -3.32.0-rc.3`). The `${workflow.secrets.NAME}` references (and, in the target, the -`TaskDef.runtimeMetadata` resolution) are performed **at runtime by the embedded host** conductor's -`ParametersUtils.substituteSecrets` / `RuntimeMetadataResolver` / `SecretsDAO` (conductor-oss -PR #1255). The host must run a conductor build that includes PR #1255; agentspan does not build -against it. (An earlier iteration pinned a local `3.32.0-rc.3-runtimemeta-LOCAL` build — reverted, -because that build's new conductor-side `SecretResource` shadowed agentspan's `SecretController` -`GET /api/secrets` in standalone tests.) - -## Tests - -- `NativeSecretGatingTest` ✅ — native beans present standalone, absent embedded - (`ApplicationContextRunner`); fail-first validated. -- `ToolCompilerWorkerCredTest` ✅ — GraalJS-executes the enrich script; SIMPLE task carries - `__resolved_credentials__` when embedded, nothing standalone; fail-first validated. -- `ReadResolvedCredentialsTest` (Java) ✅, `test_resolved_credentials.py` (Python) ✅, TS - `credentials.test.ts` ✅ — host-delivered read-path, all fail-first validated. -- Full CI green: server-tests, build-server, all four SDK unit + e2e suites. +AgentSpan uses no PR #1255 API, so it builds/tests against the published `conductor 3.32.0-rc.3`. +`${workflow.secrets.NAME}` (and, in the target, `TaskDef.runtimeMetadata`) are resolved **at runtime +by the embedded host** (`substituteSecrets` / `RuntimeMetadataResolver` / `SecretsDAO`, PR #1255) — the +host must include PR #1255; agentspan does not build against it. (An earlier local +`…-runtimemeta-LOCAL` pin was reverted: its conductor-side `SecretResource` shadowed agentspan's +`SecretController` `GET /api/secrets` in standalone tests.) -## Status snapshot +## Status | Item | State | |---|---| -| Part A — native mechanism gated on `agentspan.embedded` | ✅ done + tested | -| System-task `${workflow.secrets}` (target = shipped) | ✅ done | -| Worker-tool delivery — **interim** `__resolved_credentials__` | ✅ done + tested (shipped) | -| Worker-tool delivery — **target** `TaskDef.runtimeMetadata` | ⏳ blocked on client-SDK `runtimeMetadata` field (table above) | -| SDK read-path — interim (`__resolved_credentials__`), all 4 SDKs | ✅ done (C# not run locally — CI green) | -| SDK read-path — target (`task.runtimeMetadata`), all 4 SDKs | ⏳ after client SDKs ship the field | +| Native mechanism gated on `agentspan.embedded` | ✅ done + tested | +| System-task `${workflow.secrets}` delivery | ✅ done | +| Worker tools — interim `__resolved_credentials__` (server + 4 SDKs) | ✅ done + tested (CI green) | +| Worker tools — target `TaskDef.runtimeMetadata` | ⏳ blocked on client-SDK field (table) | diff --git a/sdk/python/examples/demo_secret_resolution.py b/sdk/python/examples/demo_secret_resolution.py new file mode 100644 index 000000000..751e61d44 --- /dev/null +++ b/sdk/python/examples/demo_secret_resolution.py @@ -0,0 +1,118 @@ +# Copyright (c) 2025 Agentspan +# Licensed under the MIT License. See LICENSE file in the project root for details. + +"""Local e2e proof — embedded host (Orkes Conductor) resolves secrets. + +Minimal: ONE worker tool + an LLM call, so a single run exercises BOTH embedded +secret paths and the Orkes UI shows the result: + + * Worker-tool secret -> the tool declares credentials=["DEMO_SECRET"] and reads it + with get_secret(). Embedded, the compiler stamps + inputParameters.__resolved_credentials__ = {DEMO_SECRET: "${workflow.secrets.DEMO_SECRET}"} + and the host resolves it at poll. The tool returns a masked confirmation. + * LLM apiKey secret -> the agent makes an LLM call; embedded, the apiKey is stamped + ${workflow.secrets.} and resolved by the host. If the LLM step succeeds, + that secret resolved too. + +The tool does NO external network calls, so the task output is a clean, deterministic +"secret_resolved: true" you can screenshot. + +-------------------------------------------------------------------------------- +Setup (agentspan embedded in local Orkes, agentspan.embedded=true): + +1) In Orkes create the secrets (UI: Definitions -> Secrets, or the secrets API): + DEMO_SECRET = demo-value-12345 + OPENAI_API_KEY = # or the key matching AGENTSPAN_LLM_MODEL's provider + # (anthropic -> ANTHROPIC_API_KEY, etc.) + +2) Point the SDK at your local Orkes and give it an app key/secret: + export AGENTSPAN_SERVER_URL=http://localhost:8080/api + export AGENTSPAN_AUTH_KEY= + export AGENTSPAN_AUTH_SECRET= + export AGENTSPAN_LLM_MODEL=openai/gpt-4o + +3) Run: + cd sdk/python/examples + uv run python demo_secret_resolution.py + +Screenshot: in the Orkes UI open this execution -> the check_secret task's output +shows {"secret_resolved": true, "value_length": 16, "value_prefix": "demo…"}. + +CI smoke check: the script exits 0 only if the secret resolved AND the workflow +completed; otherwise it prints "SMOKE FAIL ..." and exits 1 (so it can gate CI). +""" + +from settings import settings + +from conductor.ai.agents import ( + Agent, + AgentRuntime, + CredentialNotFoundError, + get_secret, + tool, +) + + +@tool(credentials=["DEMO_SECRET"]) +def check_secret() -> dict: + """Report whether the declared secret was resolved by the host. No external calls. + + Returns a MASKED confirmation only — never logs or returns the full secret. + """ + try: + value = get_secret("DEMO_SECRET") + except CredentialNotFoundError: + return {"secret_resolved": False, "detail": "DEMO_SECRET was not delivered to the worker"} + return { + "secret_resolved": True, + "value_length": len(value), + "value_prefix": (value[:4] + "…") if value else "", + } + + +agent = Agent( + name="secret_resolution_demo", + model=settings.llm_model, + tools=[check_secret], + credentials=["DEMO_SECRET"], + instructions=( + "Call the check_secret tool exactly once, then state whether the secret " + "resolved and its length. Never invent or guess a secret value." + ), +) + + +def _secret_resolved(result) -> bool: + """True iff the check_secret tool ran and reported secret_resolved=True.""" + for call in result.tool_calls: + if call.get("name") != "check_secret" or "result" not in call: + continue + out = call["result"] + # Tool returns its dict directly; tolerate a {"result": {...}} wrapper too. + if isinstance(out, dict) and isinstance(out.get("result"), dict): + out = out["result"] + if isinstance(out, dict) and out.get("secret_resolved") is True: + return True + return False + + +if __name__ == "__main__": + import sys + + with AgentRuntime() as runtime: + result = runtime.run(agent, "Did my secret resolve? Use the tool to check.") + result.print_result() + + ok = result.is_success and _secret_resolved(result) + print("-" * 60) + if ok: + print("✅ SMOKE PASS — host resolved DEMO_SECRET (and the LLM call succeeded).") + sys.exit(0) + reason = ( + "workflow did not complete successfully" + if not result.is_success + else "check_secret did not report secret_resolved=true " + "(secret not delivered, tool not called, or running standalone)" + ) + print(f"❌ SMOKE FAIL — {reason}. status={result.status}") + sys.exit(1) From 941e6fa222b45f6ac8ab37696c8f248ae684e99c Mon Sep 17 00:00:00 2001 From: nicholascole Date: Thu, 9 Jul 2026 19:49:32 -0700 Subject: [PATCH 6/9] docs(credentials): file-level target migration + interim/target sequence diagrams Expand the migration section into a concrete file-by-file breakdown of the target change (AgentService.setRuntimeMetadata + removal of the enrich stamping; per-SDK read swap to task.runtimeMetadata; client dep bumps). Add a labeled target-state sequence diagram alongside the interim one so the two delivery paths can be compared directly. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...026-07-09-embedded-secret-toggle-design.md | 76 +++++++++++++++++-- 1 file changed, 69 insertions(+), 7 deletions(-) diff --git a/design/2026-07-09-embedded-secret-toggle-design.md b/design/2026-07-09-embedded-secret-toggle-design.md index a060b593e..b6550a6ff 100644 --- a/design/2026-07-09-embedded-secret-toggle-design.md +++ b/design/2026-07-09-embedded-secret-toggle-design.md @@ -31,6 +31,8 @@ Worker tools aren't static: the LLM picks them, an **INLINE "enrich" task** (Gra SIMPLE tasks at runtime, and a `FORK_JOIN_DYNAMIC` schedules them. The per-tool cred map is baked into the enrich script at compile time. +### Interim sequence (`__resolved_credentials__`) + ```mermaid sequenceDiagram autonumber @@ -117,14 +119,74 @@ string→string, output-empty omitted), release, and bump the SDK's client depen (GraalJS-runs the enrich script), `ReadResolvedCredentialsTest` (Java), `test_resolved_credentials.py` (Python), TS `credentials.test.ts`. -## Migration to the target (when clients expose `runtimeMetadata`) +## The target change — main files (once clients expose `runtimeMetadata`) + +Net effect: **declare** secret names on the TaskDef instead of **stamping** a value-reference into +task input; the enrich script stops touching credentials entirely, which deletes the +JS-injection/persistence caveat above. System-task delivery (`${workflow.secrets.NAME}`) is untouched. + +### Target sequence (`TaskDef.runtimeMetadata`) + +```mermaid +sequenceDiagram + autonumber + participant C as Compiler + participant MD as Metadata (TaskDef) + participant LLM as LLM task + participant EN as Enrich task (INLINE) + participant FK as FORK_JOIN_DYNAMIC + participant H as Host (RuntimeMetadataResolver + secretsDAO) + participant W as SDK worker (SIMPLE task) + participant T as Tool fn + + Note over C,MD: compile / register time (embedded only) + C->>C: collectToolCredentials(agent) - tool creds, agent-level fallback + C->>MD: register worker TaskDef with runtimeMetadata = [NAMES] + Note over LLM,T: execution time + LLM->>EN: toolCalls (which tools to run) + EN->>FK: dynamicTasks (SIMPLE tasks, NO creds in input) + W->>H: poll SIMPLE task + H->>H: resolve TaskDef.runtimeMetadata names to values (secretsDAO/env) + H-->>W: task, values on wire-only Task.runtimeMetadata (never persisted) + W->>W: read task.runtimeMetadata, set CredentialContext + W->>T: run tool, then get_secret(NAME) returns the value + T-->>W: result +``` + +Versus the interim: the enrich task never touches credentials, resolution happens at the SIMPLE +task's **own poll** (not the enrich step), and the value arrives on the **wire-only** +`Task.runtimeMetadata` — so nothing is baked into the script and no plaintext lands in persisted input. + +**0. Client libraries (prereq)** — add `Task.runtimeMetadata` per the table, release, and bump the +client dep in `sdk/java/build.gradle`, `sdk/python/pyproject.toml`, `sdk/csharp/.../Conductor.AI.csproj`, +`sdk/typescript/package.json`. + +**1. Server — declare, and stop stamping** (all embedded-gated on `EmbeddedMode.isEmbedded()`): + +| File | Change | +|---|---| +| `service/AgentService.java` | **ADD.** In `registerTaskDef`, set `taskDef.setRuntimeMetadata(names)` for each worker tool, where `names = AgentCompiler.collectToolCredentials(config).get(tool)`. This is the whole target delivery on the server. | +| `compiler/ToolCompiler.java` | **REMOVE** `buildWorkerCredConfig()` + `setWorkerCreds` + the `workerCredJson` argument passed to `enrichToolsScript` / `enrichToolsScriptDynamic`. | +| `util/JavaScriptBuilder.java` | **REMOVE** the `workerCredJson` param and the `if (workerCredCfg[n]) t.inputParameters.__resolved_credentials__ = …` lines in both enrich scripts. | +| `compiler/AgentCompiler.java`, `MultiAgentCompiler.java` | **MOVE.** Drop the `tc.setWorkerCreds(...)` calls; `collectToolCredentials` now feeds `AgentService` instead of `ToolCompiler`. | +| `ai/AgentChatCompleteTaskMapper.java`, `LlmProviderEnv.java` | **UNCHANGED** — system-task `${workflow.secrets}` path stays. | + +**2. SDK worker read-path — read the field instead of the input key** (native token-pull fallback +stays in all four): + +| File | Change | +|---|---| +| `sdk/java/.../internal/WorkerManager.java` | `task.getRuntimeMetadata()` instead of `inputData.get("__resolved_credentials__")`. | +| `sdk/python/.../runtime/_dispatch.py` | `task.runtime_metadata` instead of `task.input_data.pop("__resolved_credentials__")`. | +| `sdk/csharp/.../WorkerManager.cs` | `task.RuntimeMetadata` instead of the `__resolved_credentials__` dict; drop the input-strip. | +| `sdk/typescript/src/worker.ts` | `task.runtimeMetadata` instead of `inputData["__resolved_credentials__"]`. The `credentials.ts` accessor (reads the resolved map from the context) is unchanged. | + +**3. Cleanup** — once all SDKs are on the new clients, delete the interim `__resolved_credentials__` +stamping (server) and reads (SDKs), plus `ToolCompilerWorkerCredTest`'s enrich-script assertions. -1. Land the field in each client (table), release, bump the SDK client versions. -2. **Server:** set `TaskDef.runtimeMetadata=[names]` in `AgentService.registerTaskDef` (embedded-gated); - drop the enrich `workerCredCfg` injection. System-task stamping unchanged. -3. **SDKs:** read `task.runtimeMetadata` instead of `inputData.__resolved_credentials__`; keep the - native fallback. -4. Remove the interim once all SDKs are on the new clients. +The compiler/enrich change is a **deletion**; the real new code is one line in `AgentService` +(`setRuntimeMetadata`) plus a one-line read swap per SDK. Everything else (gating, system tasks, +accessors, native fallback) is already in place. ## Dependency From 960172e3216d2980655a98b4d19f9863144b7005 Mon Sep 17 00:00:00 2001 From: nicholascole Date: Thu, 9 Jul 2026 20:19:59 -0700 Subject: [PATCH 7/9] refactor(credentials): drop LLM apiKey stamping; rely on the host AI integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Embedded LLM auth is the host's job: OrkesAIModelProvider (@Primary) resolves the model per call from the AI integration by (orgId, integrationName) and never reads an apiKey from task input — so agentspan's ${workflow.secrets.} stamping was redundant and bypassed, and the hard-coded provider->env-var map was a smell. - Delete LlmProviderEnv and AgentChatCompleteTaskMapper.injectCredentialReferences. - Remove the dead embedded readResolvedApiKeyFromTaskInput branch in AgentspanAIModelProvider. - Worker-tool + HTTP/MCP header secret delivery (${workflow.secrets}) unchanged; standalone per-user LLM key resolution unchanged. - Doc: correct the LLM sequence diagram to the real OrkesAIModelProvider/integration flow (verified in orkes-conductor) and update the delivery/implementation/migration sections. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...026-07-09-embedded-secret-toggle-design.md | 63 ++++++++++++++++--- .../ai/AgentChatCompleteTaskMapper.java | 29 +-------- .../runtime/ai/AgentspanAIModelProvider.java | 26 -------- .../agentspan/runtime/ai/LlmProviderEnv.java | 37 ----------- 4 files changed, 58 insertions(+), 97 deletions(-) delete mode 100644 server/conductor-agentspan/src/main/java/dev/agentspan/runtime/ai/LlmProviderEnv.java diff --git a/design/2026-07-09-embedded-secret-toggle-design.md b/design/2026-07-09-embedded-secret-toggle-design.md index b6550a6ff..fcecf9ef3 100644 --- a/design/2026-07-09-embedded-secret-toggle-design.md +++ b/design/2026-07-09-embedded-secret-toggle-design.md @@ -21,9 +21,58 @@ Nothing is deleted — the native code stays intact for standalone. table), we ship an **interim**: the compiler stamps `inputParameters.__resolved_credentials__ = {NAME: "${workflow.secrets.NAME}"}` and the host resolves it — same delivery, but it rides in the task-input `Map` that today's clients already keep. -- **System tasks (LLM `apiKey`, HTTP/MCP/planner headers — in-process on the host)** → the compiler - stamps `${workflow.secrets.NAME}` into task input; the host substitutes in memory before the call. - Not polled by a worker, so there's no `Task.runtimeMetadata` to read. Identical for target and interim. +- **LLM provider keys → the host's AI integration** (not a workflow secret). The `LLM_CHAT_COMPLETE` + task resolves its model — and its API key — from the configured AI integration by provider name; + agentspan stamps nothing. See the sequence below. (We deliberately do **not** map a provider to a + conventionally-named workflow secret — that would duplicate and can conflict with the integration.) +- **HTTP / MCP / planner-context headers → `${workflow.secrets.NAME}`.** These are the *user's* + external-API secrets (not integration-managed). The compiler rewrites a `${NAME}` placeholder in a + header to `${workflow.secrets.NAME}` (embedded) and the host substitutes it in memory before the + in-process call. Same for target and interim. + +### LLM provider key — via the host AI integration + +Embedded in Orkes, `OrkesAIModelProvider` (`@Primary`) is the active `AIModelProvider`. It resolves +the model **per call** from the integration store, scoped to the org — the API key lives in the +integration config and the built model client, and never touches the workflow definition or task input. +Verified in `orkes-conductor` (`workers/.../integrations/OrkesAIModelProvider.java`, +`ModelConfigurationProvider.java`). + +```mermaid +sequenceDiagram + autonumber + participant OP as Operator / UI + participant IS as IntegrationService (Orkes store) + participant TK as LLM_CHAT_COMPLETE task + participant LW as LLMWorkers (host) + participant MP as OrkesAIModelProvider (@Primary) + participant MC as ModelConfigurationProvider + participant API as Provider API (OpenAI, ...) + + Note over OP,IS: setup - integration stored per org (api_key in its config) + OP->>IS: create AI integration (provider=openai, api_key=...) + + Note over TK,API: execution - per LLM call + TK->>LW: LLM_CHAT_COMPLETE (llmProvider, model, integrationNames[AI_MODEL]) + LW->>MP: getModel(input) + MP->>MP: orgId from taskId, integrationName from input.integrationNames[AI_MODEL] + MP->>IS: getIntegration(orgId, integrationName) + IS-->>MP: Integration.configuration (incl api_key) + MP->>MC: getConfiguration(type, configMap) - build AIModel with api_key (cached) + MC-->>MP: AIModel + MP-->>LW: AIModel + LW->>API: chatComplete(messages) using the integration key + API-->>LW: completion +``` + +Consequences: +- **Agentspan stamps nothing on the LLM task.** `OrkesAIModelProvider` never reads an `apiKey` from + task input — it resolves by `(orgId, integrationName)` — so the interim `injectCredentialReferences` + + `LlmProviderEnv` mapping was redundant *and* bypassed. Both are removed. +- **Standalone** (not embedded): agentspan's own `AgentspanAIModelProvider` resolves per-user keys from + the native store — a separate path, unchanged. +- **Conductor-OSS** (no integration store): the OSS `AIModelProvider` serves models from startup + `ModelConfiguration`s — still not from workflow secrets. ## Interim worker path (enrichment) — how it actually works @@ -108,9 +157,9 @@ string→string, output-empty omitted), release, and bump the SDK's client depen `NoOpSecretOutputMasker`. Active consumers made tolerant: `AgentspanAIModelProvider` (`ObjectProvider` + guards); `AgentService` / `AgentEventListener` (`@Autowired(required=false)` + null guards, so token minting is skipped). -- **System tasks:** `AgentChatCompleteTaskMapper.injectCredentialReferences` + `LlmProviderEnv` stamp - `apiKey=${workflow.secrets.}`; `AgentspanAIModelProvider` reads it back. HTTP/MCP/planner headers - already emit `${workflow.secrets.NAME}`. +- **System tasks:** LLM keys come from the host AI integration (`OrkesAIModelProvider`) — agentspan + stamps nothing (the old `injectCredentialReferences` + `LlmProviderEnv` were removed). HTTP/MCP/ + planner headers emit `${workflow.secrets.NAME}` via `ToolCompiler.rewriteCredentialPlaceholders`. - **Worker tools (interim):** `ToolCompiler.buildWorkerCredConfig` + `JavaScriptBuilder` enrich injection + `AgentCompiler.collectToolCredentials`. - **SDK read-path (all 4):** prefer the host map, else native token-pull; feed the existing accessor; @@ -169,7 +218,7 @@ client dep in `sdk/java/build.gradle`, `sdk/python/pyproject.toml`, `sdk/csharp/ | `compiler/ToolCompiler.java` | **REMOVE** `buildWorkerCredConfig()` + `setWorkerCreds` + the `workerCredJson` argument passed to `enrichToolsScript` / `enrichToolsScriptDynamic`. | | `util/JavaScriptBuilder.java` | **REMOVE** the `workerCredJson` param and the `if (workerCredCfg[n]) t.inputParameters.__resolved_credentials__ = …` lines in both enrich scripts. | | `compiler/AgentCompiler.java`, `MultiAgentCompiler.java` | **MOVE.** Drop the `tc.setWorkerCreds(...)` calls; `collectToolCredentials` now feeds `AgentService` instead of `ToolCompiler`. | -| `ai/AgentChatCompleteTaskMapper.java`, `LlmProviderEnv.java` | **UNCHANGED** — system-task `${workflow.secrets}` path stays. | +| LLM keys | **UNCHANGED** — already handled by the host AI integration (`OrkesAIModelProvider`); no agentspan code. HTTP/MCP/planner headers keep their `${workflow.secrets}` rewrite. | **2. SDK worker read-path — read the field instead of the input key** (native token-pull fallback stays in all four): diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/ai/AgentChatCompleteTaskMapper.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/ai/AgentChatCompleteTaskMapper.java index 71314fc7e..bafb825fa 100644 --- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/ai/AgentChatCompleteTaskMapper.java +++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/ai/AgentChatCompleteTaskMapper.java @@ -42,7 +42,6 @@ import dev.agentspan.runtime.model.AgentSSEEvent; import dev.agentspan.runtime.service.AgentStreamRegistry; -import dev.agentspan.runtime.util.EmbeddedMode; import dev.agentspan.runtime.util.ModelContextWindows; /** @@ -112,11 +111,8 @@ protected TaskModel getMappedTask(TaskMapperContext taskMapperContext) throws Te TaskModel taskModel = super.getMappedTask(taskMapperContext); WorkflowModel workflowModel = taskMapperContext.getWorkflowModel(); - // Standalone: per-user LLM key resolution is handled by AgentspanAIModelProvider.getModel() - // (native store), so no inputData injection is needed. Embedded: the native store is dormant, - // so stamp a ${workflow.secrets.NAME} reference for the host to resolve; the provider then - // reads the resolved apiKey back from the task input. - injectCredentialReferences(taskModel); + // LLM credentials are the host's concern: embedded, the AI integration supplies the key + // (OrkesAIModelProvider); standalone, AgentspanAIModelProvider resolves it. Nothing to stamp here. try { ChatCompletion chatCompletion = objectMapper.convertValue(taskModel.getInputData(), ChatCompletion.class); @@ -148,27 +144,6 @@ protected TaskModel getMappedTask(TaskMapperContext taskMapperContext) throws Te return taskModel; } - /** - * Embedded only: stamp {@code apiKey = ${workflow.secrets.}} so the host - * (orkes-conductor / conductor-oss) resolves the LLM credential when it binds task input. - * Only the required API key is stamped (optional base-url / project id are left to env/config, - * since a missing {@code ${workflow.secrets.X}} reference hard-fails on some hosts). Standalone - * stamps nothing — {@link AgentspanAIModelProvider} resolves keys via the native store. - */ - private void injectCredentialReferences(TaskModel taskModel) { - if (!EmbeddedMode.isEmbedded()) { - return; - } - Object provObj = taskModel.getInputData().get("llmProvider"); - if (!(provObj instanceof String provider) || provider.isBlank()) { - return; - } - String apiKeyEnv = LlmProviderEnv.apiKeyEnv(provider); - if (apiKeyEnv != null) { - taskModel.getInputData().put("apiKey", "${workflow.secrets." + apiKeyEnv + "}"); - } - } - /** * OpenAI's JSON mode validation checks user input messages for the word "json". * System/developer instructions are not sufficient for the Responses API. diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/ai/AgentspanAIModelProvider.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/ai/AgentspanAIModelProvider.java index 37e6e3579..65aadf829 100644 --- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/ai/AgentspanAIModelProvider.java +++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/ai/AgentspanAIModelProvider.java @@ -33,7 +33,6 @@ import dev.agentspan.runtime.context.RequestContextHolder; import dev.agentspan.runtime.credentials.CredentialResolutionService; import dev.agentspan.runtime.credentials.ExecutionTokenService; -import dev.agentspan.runtime.util.EmbeddedMode; import okhttp3.OkHttpClient; @@ -131,11 +130,6 @@ public AIModel getModel(LLMWorkerInput input) { // Try per-user credential resolution log.debug("getModel called for provider='{}' model='{}'", provider, input.getModel()); String userApiKey = resolveUserApiKey(provider); - // Embedded: native resolution is dormant; the host resolved the stamped - // apiKey = ${workflow.secrets.NAME} reference into the task input. Read it back. - if (userApiKey == null && EmbeddedMode.isEmbedded()) { - userApiKey = readResolvedApiKeyFromTaskInput(); - } log.debug("resolveUserApiKey('{}') returned: {}", provider, userApiKey != null ? "key found" : "null"); if (userApiKey != null || baseUrl != null) { try { @@ -222,26 +216,6 @@ private String extractUserIdFromTaskContext() { } } - /** - * Read the host-resolved {@code apiKey} from the current task input (embedded mode). The - * compiler stamped {@code apiKey = ${workflow.secrets.NAME}} and the host substitutes the - * plaintext before execution. An unresolved placeholder (still starting with {@code ${}) or a - * blank value is ignored so we fall through to env/server-wide resolution. - */ - private String readResolvedApiKeyFromTaskInput() { - try { - TaskContext ctx = TaskContext.get(); - if (ctx == null || ctx.getTask() == null) return null; - Object v = ctx.getTask().getInputData().get("apiKey"); - if (v instanceof String s && !s.isBlank() && !s.startsWith("${")) { - return s; - } - } catch (Exception e) { - // ignore — fall through to other resolution paths - } - return null; - } - /** * Returns true if the provider is available: either configured at startup (via environment * variables / application.properties) or has an API key credential in the current user's store. diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/ai/LlmProviderEnv.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/ai/LlmProviderEnv.java deleted file mode 100644 index 82abab334..000000000 --- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/ai/LlmProviderEnv.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright (c) 2025 AgentSpan - * Licensed under the MIT License. - */ -package dev.agentspan.runtime.ai; - -import java.util.Map; - -/** - * Maps Conductor LLM provider names to their credential environment-variable (= secret) names. - * Shared by {@link AgentChatCompleteTaskMapper} (which, in EMBEDDED mode, stamps - * {@code ${workflow.secrets.NAME}} references into the LLM task input for the host to resolve) - * and {@link AgentspanAIModelProvider} (which reads the host-resolved value in embedded mode, or - * resolves natively / falls back to {@code System.getenv} when standalone). - */ -public final class LlmProviderEnv { - - private LlmProviderEnv() {} - - /** Maps Conductor provider names to credential env var names. */ - public static final Map PROVIDER_TO_ENV_VAR = Map.ofEntries( - Map.entry("openai", "OPENAI_API_KEY"), - Map.entry("anthropic", "ANTHROPIC_API_KEY"), - Map.entry("mistral", "MISTRAL_API_KEY"), - Map.entry("cohere", "COHERE_API_KEY"), - Map.entry("grok", "XAI_API_KEY"), - Map.entry("perplexity", "PERPLEXITY_API_KEY"), - Map.entry("huggingface", "HUGGINGFACE_API_KEY"), - Map.entry("azureopenai", "AZURE_OPENAI_API_KEY"), - Map.entry("gemini", "GEMINI_API_KEY"), - Map.entry("google_gemini", "GEMINI_API_KEY")); - - /** Credential env var name for a provider's API key, or {@code null} if unknown. */ - public static String apiKeyEnv(String provider) { - return provider == null ? null : PROVIDER_TO_ENV_VAR.get(provider.toLowerCase()); - } -} From ef8b14ca850cd75a7ed624546e5f6645acb01b16 Mon Sep 17 00:00:00 2001 From: nicholascole Date: Thu, 9 Jul 2026 20:23:59 -0700 Subject: [PATCH 8/9] docs(credentials): make design doc consistent with LLM-via-integration end-state Fix the two spots that still lumped LLM keys into the ${workflow.secrets} system-task path: system tasks are LLM (host AI integration) + HTTP/MCP/planner headers (${workflow.secrets}). Co-Authored-By: Claude Opus 4.8 (1M context) --- design/2026-07-09-embedded-secret-toggle-design.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/design/2026-07-09-embedded-secret-toggle-design.md b/design/2026-07-09-embedded-secret-toggle-design.md index fcecf9ef3..a1406264a 100644 --- a/design/2026-07-09-embedded-secret-toggle-design.md +++ b/design/2026-07-09-embedded-secret-toggle-design.md @@ -172,7 +172,8 @@ string→string, output-empty omitted), release, and bump the SDK's client depen Net effect: **declare** secret names on the TaskDef instead of **stamping** a value-reference into task input; the enrich script stops touching credentials entirely, which deletes the -JS-injection/persistence caveat above. System-task delivery (`${workflow.secrets.NAME}`) is untouched. +JS-injection/persistence caveat above. System tasks are untouched — LLM keys stay on the host AI +integration, HTTP/MCP/planner headers keep their `${workflow.secrets.NAME}` rewrite. ### Target sequence (`TaskDef.runtimeMetadata`) @@ -251,6 +252,6 @@ host must include PR #1255; agentspan does not build against it. (An earlier loc | Item | State | |---|---| | Native mechanism gated on `agentspan.embedded` | ✅ done + tested | -| System-task `${workflow.secrets}` delivery | ✅ done | +| System tasks — LLM via host AI integration; HTTP/MCP/planner headers via `${workflow.secrets}` | ✅ done | | Worker tools — interim `__resolved_credentials__` (server + 4 SDKs) | ✅ done + tested (CI green) | | Worker tools — target `TaskDef.runtimeMetadata` | ⏳ blocked on client-SDK field (table) | From 3aa2b9e355af358e2a17ff0c763942a6574aea9c Mon Sep 17 00:00:00 2001 From: Nicholas Cole <68611647+NicholasDCole@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:08:31 -0700 Subject: [PATCH 9/9] feat: target-state worker secrets via TaskDef.runtimeMetadata (#311) --- .../src/Conductor.AI/Conductor.AI.csproj | 3 + sdk/csharp/src/Conductor.AI/WorkerManager.cs | 31 +-- .../RuntimeMetadataReadTests.cs | 55 ++++ sdk/java/build.gradle | 7 +- .../conductor/ai/internal/WorkerManager.java | 46 +++- .../conductor/ai/SerializerTest.java | 27 +- .../EmbeddedTaskDefRegistrationTest.java | 69 +++++ .../internal/ReadResolvedCredentialsTest.java | 53 ---- .../ai/internal/ReadRuntimeMetadataTest.java | 58 +++++ sdk/python/pyproject.toml | 3 + .../conductor/ai/agents/runtime/_dispatch.py | 8 +- .../conductor/ai/agents/runtime/runtime.py | 4 +- .../ai/agents/runtime/tool_registry.py | 9 +- .../test_embedded_taskdef_registration.py | 34 +++ ...redentials.py => test_runtime_metadata.py} | 14 +- sdk/typescript/src/worker.ts | 19 +- sdk/typescript/tests/unit/worker.test.ts | 33 +++ server/build.gradle | 10 +- .../credentials/AgentspanSecretsDAO.java | 92 +++++++ .../CredentialDataSourceConfig.java | 2 +- .../credentials/CredentialSchemaMigrator.java | 2 +- .../EncryptedDbCredentialStoreProvider.java | 2 +- .../runtime/credentials/MasterKeyConfig.java | 2 +- .../src/main/resources/application.properties | 10 + .../credentials/AgentspanSecretsDAOTest.java | 122 +++++++++ .../runtime/util/EnrichToolsScriptTest.java | 2 +- .../runtime/compiler/AgentCompiler.java | 30 ++- .../runtime/compiler/MultiAgentCompiler.java | 2 - .../runtime/compiler/ToolCompiler.java | 64 +---- .../runtime/service/AgentService.java | 43 ++- .../runtime/util/JavaScriptBuilder.java | 10 +- .../compiler/ToolCompilerWorkerCredTest.java | 126 --------- .../compiler/WorkerRuntimeMetadataTest.java | 246 ++++++++++++++++++ 33 files changed, 897 insertions(+), 341 deletions(-) create mode 100644 sdk/csharp/tests/Conductor.AI.Tests/RuntimeMetadataReadTests.cs create mode 100644 sdk/java/src/test/java/org/conductoross/conductor/ai/internal/EmbeddedTaskDefRegistrationTest.java delete mode 100644 sdk/java/src/test/java/org/conductoross/conductor/ai/internal/ReadResolvedCredentialsTest.java create mode 100644 sdk/java/src/test/java/org/conductoross/conductor/ai/internal/ReadRuntimeMetadataTest.java create mode 100644 sdk/python/tests/unit/test_embedded_taskdef_registration.py rename sdk/python/tests/unit/{test_resolved_credentials.py => test_runtime_metadata.py} (75%) create mode 100644 server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/credentials/AgentspanSecretsDAO.java create mode 100644 server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/credentials/AgentspanSecretsDAOTest.java delete 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/compiler/WorkerRuntimeMetadataTest.java diff --git a/sdk/csharp/src/Conductor.AI/Conductor.AI.csproj b/sdk/csharp/src/Conductor.AI/Conductor.AI.csproj index 3aff60114..872046663 100644 --- a/sdk/csharp/src/Conductor.AI/Conductor.AI.csproj +++ b/sdk/csharp/src/Conductor.AI/Conductor.AI.csproj @@ -38,6 +38,9 @@ + diff --git a/sdk/csharp/src/Conductor.AI/WorkerManager.cs b/sdk/csharp/src/Conductor.AI/WorkerManager.cs index 07c8c1dc9..8d8a7aee4 100644 --- a/sdk/csharp/src/Conductor.AI/WorkerManager.cs +++ b/sdk/csharp/src/Conductor.AI/WorkerManager.cs @@ -95,10 +95,9 @@ 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, "__resolved_credentials__", 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, "method", StringComparison.OrdinalIgnoreCase)) .ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase); // Resolve and inject credentials via the centralized helper so the @@ -106,9 +105,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. - // 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); + // Embedded: the host resolves the worker's declared TaskDef.runtimeMetadata secret names + // at poll time and delivers the values on the wire-only Task.RuntimeMetadata (never + // persisted). Prefer that map; otherwise fall back to the native token-pull. + var resolvedCredentials = ReadRuntimeMetadata(task); if (resolvedCredentials.Count == 0 && _credentialNames.Length > 0) { var creds = await _http.ResolveCredentialsAsync( @@ -221,18 +221,19 @@ 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). + /// Read the host-delivered secret name→value map from Task.RuntimeMetadata (embedded + /// mode). The host resolves the worker's declared TaskDef.runtimeMetadata names from its + /// secret store at poll time and injects the values on the wire only — never persisted to task + /// input (conductor-oss PR #1255). Empty when absent (standalone → the native token-pull). /// - private static Dictionary ReadResolvedCredentials(Dictionary inputData) + private static Dictionary ReadRuntimeMetadata(Task task) { var result = new Dictionary(); - if (inputData.TryGetValue("__resolved_credentials__", out var rc) && rc.ValueKind == JsonValueKind.Object) + if (task?.RuntimeMetadata is { Count: > 0 } rm) { - foreach (var prop in rc.EnumerateObject()) - if (prop.Value.ValueKind == JsonValueKind.String) - result[prop.Name] = prop.Value.GetString()!; + foreach (var (k, v) in rm) + if (k is not null && v is not null) + result[k] = v; } return result; } diff --git a/sdk/csharp/tests/Conductor.AI.Tests/RuntimeMetadataReadTests.cs b/sdk/csharp/tests/Conductor.AI.Tests/RuntimeMetadataReadTests.cs new file mode 100644 index 000000000..7f999d423 --- /dev/null +++ b/sdk/csharp/tests/Conductor.AI.Tests/RuntimeMetadataReadTests.cs @@ -0,0 +1,55 @@ +// Copyright (c) 2025 Agentspan +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Reflection; +using Xunit; +using ModelTask = Conductor.Client.Models.Task; + +namespace Conductor.AI.Tests; + +/// +/// Embedded host-delivery read-path: the worker reads host-resolved secret values from +/// Task.RuntimeMetadata (wire-only, resolved by the host from the worker's declared +/// TaskDef.runtimeMetadata; conductor-oss PR #1255). Absent/empty yields an empty map +/// (standalone falls back to the native token-pull). +/// +public class RuntimeMetadataReadTests +{ + private static Dictionary Invoke(ModelTask task) + { + // WorkerPollLoop is internal; reach ReadRuntimeMetadata (private static) via reflection. + var type = typeof(CredentialScope).Assembly.GetType("Conductor.AI.WorkerPollLoop")!; + var method = type.GetMethod( + "ReadRuntimeMetadata", + BindingFlags.NonPublic | BindingFlags.Static)!; + return (Dictionary)method.Invoke(null, new object?[] { task })!; + } + + [Fact] + public void Extracts_host_delivered_values() + { + var task = new ModelTask( + taskId: "t1", + runtimeMetadata: new Dictionary + { + ["GITHUB_TOKEN"] = "ghp_host", + ["GH_APP_ID"] = "42", + }); + + var result = Invoke(task); + + Assert.Equal(2, result.Count); + Assert.Equal("ghp_host", result["GITHUB_TOKEN"]); + Assert.Equal("42", result["GH_APP_ID"]); + } + + [Fact] + public void Empty_when_absent_or_empty() + { + Assert.Empty(Invoke(new ModelTask(taskId: "t1"))); + Assert.Empty(Invoke(new ModelTask( + taskId: "t1", + runtimeMetadata: new Dictionary()))); + } +} diff --git a/sdk/java/build.gradle b/sdk/java/build.gradle index 23f927dd3..af42140ae 100644 --- a/sdk/java/build.gradle +++ b/sdk/java/build.gradle @@ -14,6 +14,9 @@ java { } repositories { + // TARGET: consumes the local conductor-client build that carries Task.runtimeMetadata + // (conductor-oss/java-sdk feat/task-runtime-metadata). Remove once that release lands. + mavenLocal() mavenCentral() } @@ -28,7 +31,9 @@ ext { // separately from the server engine (engine = 3.30.2); wire-compatible with // the 3.x task REST API, bundles the common DTOs, and provides native auth // via io.orkes.conductor.client.ApiClient (key/secret → token). - conductorClientVersion = '5.0.1' + // TARGET: 5.1.0 adds Task.runtimeMetadata (host-resolved worker secrets, wire-only). + // Currently a local mavenLocal build; repin to the published 5.1.0 once it releases. + conductorClientVersion = '5.1.0' } dependencies { 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 c1a6855e8..889d7f212 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 @@ -250,7 +250,23 @@ public void register( logger.info("Registered worker for task: {} (domain={})", taskName, domain); } - private void registerTaskDef(String taskName, int configuredTimeoutSeconds) { + /** + * Register the worker TaskDef create-only: create it when absent, but never overwrite one that + * already exists. When embedded, the host server pre-registers the worker TaskDef and declares + * its secret names on {@code TaskDef.runtimeMetadata} (conductor-oss PR #1255); overwriting here + * with a bare def (the client TaskDef model carries no runtimeMetadata) would clobber that and + * starve the host resolver. Standalone still gets the def created when absent. The existence + * check chooses correctly with no embedded flag. + */ + void registerTaskDef(String taskName, int configuredTimeoutSeconds) { + try { + if (metadataClient.getTaskDef(taskName) != null) { + logger.debug("Task def {} already exists — leaving it untouched (create-only)", taskName); + return; + } + } catch (Exception lookupFailed) { + // Not found (or lookup errored) — fall through and create it. + } try { long timeout = effectiveTaskTimeout(configuredTimeoutSeconds); TaskDef taskDef = new TaskDef(taskName); @@ -373,9 +389,10 @@ 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()); - // 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); + // Embedded: the host resolves the worker's declared TaskDef.runtimeMetadata secret names at + // poll time and delivers the values on the wire-only Task.runtimeMetadata (never persisted). + // Prefer that map; otherwise fall back to the native token-pull (standalone). + Map hostDelivered = readRuntimeMetadata(task); if (!hostDelivered.isEmpty()) { resolvedSecrets = hostDelivered; } else if (!declared.isEmpty()) { @@ -423,18 +440,19 @@ private TaskResult executeHandler(String taskName, Task task) { } /** - * 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). + * Read the host-delivered secret name→value map from {@code Task.runtimeMetadata} (embedded mode). + * The host resolves the worker's declared {@code TaskDef.runtimeMetadata} names from its secret + * store at poll time and injects the values on the wire only — never persisted to task input + * (conductor-oss PR #1255). Returns an empty map when absent (standalone → native token-pull). */ - 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(); + private static Map readRuntimeMetadata(Task task) { + if (task == null) return Collections.emptyMap(); + Map rm = task.getRuntimeMetadata(); + if (rm == null || rm.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); + for (Map.Entry e : rm.entrySet()) { + if (e.getKey() != null && e.getValue() != null) { + out.put(e.getKey(), e.getValue()); } } return out; diff --git a/sdk/java/src/test/java/org/conductoross/conductor/ai/SerializerTest.java b/sdk/java/src/test/java/org/conductoross/conductor/ai/SerializerTest.java index 0623f5ab7..6969c8d75 100644 --- a/sdk/java/src/test/java/org/conductoross/conductor/ai/SerializerTest.java +++ b/sdk/java/src/test/java/org/conductoross/conductor/ai/SerializerTest.java @@ -483,8 +483,10 @@ void llm_guardrail_requires_model_and_policy() { @Test @SuppressWarnings("unchecked") void on_condition_handoff_serialized_with_target() { - Agent supervisor = - Agent.builder().name("supervisor").model("anthropic/claude-sonnet-4-6").build(); + Agent supervisor = Agent.builder() + .name("supervisor") + .model("anthropic/claude-sonnet-4-6") + .build(); Agent worker = Agent.builder() .name("worker") .model("anthropic/claude-sonnet-4-6") @@ -847,8 +849,10 @@ void planner_context_emitted_with_text_and_url_entries() { // Mirrors the Python + TS serializer tests. The wire shape MUST be // byte-equal across SDKs so the server compiler sees the same // payload regardless of language. - Agent planner = - Agent.builder().name("planner_sub").model("anthropic/claude-sonnet-4-6").build(); + Agent planner = Agent.builder() + .name("planner_sub") + .model("anthropic/claude-sonnet-4-6") + .build(); ToolDef stub = ToolDef.builder() .name("stub") .description("stub") @@ -885,8 +889,10 @@ void planner_context_emitted_with_text_and_url_entries() { void planner_context_omitted_when_unset() { // Counterfactual: without plannerContext the field MUST NOT appear // on the wire. Pairs with the positive test — pins the gating. - Agent planner = - Agent.builder().name("planner_sub").model("anthropic/claude-sonnet-4-6").build(); + Agent planner = Agent.builder() + .name("planner_sub") + .model("anthropic/claude-sonnet-4-6") + .build(); ToolDef stub = ToolDef.builder() .name("stub") .description("stub") @@ -907,7 +913,8 @@ void planner_context_omitted_when_unset() { void planner_context_rejected_on_non_plan_execute_strategy() { // Same guard shape as planner=/fallback= — setting plannerContext // on anything other than PLAN_EXECUTE is a silent bug. - Agent sub = Agent.builder().name("sub").model("anthropic/claude-sonnet-4-6").build(); + Agent sub = + Agent.builder().name("sub").model("anthropic/claude-sonnet-4-6").build(); IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> Agent.builder() .name("h") .model("anthropic/claude-sonnet-4-6") @@ -956,8 +963,10 @@ void parity_fields_serialized() { @Test void parity_fields_absent_when_unset() { - Agent agent = - Agent.builder().name("plain_agent").model("anthropic/claude-sonnet-4-6").build(); + Agent agent = Agent.builder() + .name("plain_agent") + .model("anthropic/claude-sonnet-4-6") + .build(); Map out = ser.serialize(agent); assertFalse(out.containsKey("reasoningEffort"), "reasoningEffort omitted when unset"); assertFalse(out.containsKey("maskedFields"), "maskedFields omitted when unset"); diff --git a/sdk/java/src/test/java/org/conductoross/conductor/ai/internal/EmbeddedTaskDefRegistrationTest.java b/sdk/java/src/test/java/org/conductoross/conductor/ai/internal/EmbeddedTaskDefRegistrationTest.java new file mode 100644 index 000000000..6979acf92 --- /dev/null +++ b/sdk/java/src/test/java/org/conductoross/conductor/ai/internal/EmbeddedTaskDefRegistrationTest.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 AgentSpan + * Licensed under the MIT License. + */ +package org.conductoross.conductor.ai.internal; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Field; +import java.util.List; + +import org.conductoross.conductor.ai.AgentConfig; +import org.junit.jupiter.api.Test; + +import com.netflix.conductor.client.http.ConductorClient; +import com.netflix.conductor.client.http.MetadataClient; +import com.netflix.conductor.common.metadata.tasks.TaskDef; + +/** + * Worker TaskDefs are registered create-only: the SDK creates the def when absent but never + * overwrites one that already exists. When embedded, the host server pre-registers the worker + * TaskDef and declares its secret names on TaskDef.runtimeMetadata (conductor-oss PR #1255); + * overwriting here with a bare def (the client TaskDef model has no runtimeMetadata field) would + * clobber that and starve the host resolver. No embedded flag — the existence check decides. + */ +class EmbeddedTaskDefRegistrationTest { + + /** Fake client: reports whether a def "exists" and records any registration, without network. */ + private static final class RecordingMetadataClient extends MetadataClient { + private final boolean exists; + boolean registered = false; + + RecordingMetadataClient(boolean exists) { + this.exists = exists; + } + + @Override + public TaskDef getTaskDef(String taskType) { + return exists ? new TaskDef(taskType) : null; + } + + @Override + public void registerTaskDefs(List taskDefs) { + this.registered = true; + } + } + + private static boolean didRegister(boolean alreadyExists) throws Exception { + WorkerManager wm = new WorkerManager(new AgentConfig(), new ConductorClient()); + RecordingMetadataClient client = new RecordingMetadataClient(alreadyExists); + Field f = WorkerManager.class.getDeclaredField("metadataClient"); + f.setAccessible(true); + f.set(wm, client); + wm.registerTaskDef("check_secret", 300); + return client.registered; + } + + @Test + void doesNotOverwriteExistingTaskDef() throws Exception { + // Existing def (e.g. server-registered with runtimeMetadata) must be left untouched. + assertFalse(didRegister(true), "must not overwrite an existing TaskDef"); + } + + @Test + void createsTaskDefWhenAbsent() throws Exception { + assertTrue(didRegister(false), "must create the TaskDef when none exists"); + } +} 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 deleted file mode 100644 index 688bddee4..000000000 --- a/sdk/java/src/test/java/org/conductoross/conductor/ai/internal/ReadResolvedCredentialsTest.java +++ /dev/null @@ -1,53 +0,0 @@ -/* - * 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/java/src/test/java/org/conductoross/conductor/ai/internal/ReadRuntimeMetadataTest.java b/sdk/java/src/test/java/org/conductoross/conductor/ai/internal/ReadRuntimeMetadataTest.java new file mode 100644 index 000000000..9c8d8fa13 --- /dev/null +++ b/sdk/java/src/test/java/org/conductoross/conductor/ai/internal/ReadRuntimeMetadataTest.java @@ -0,0 +1,58 @@ +/* + * 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; + +import com.netflix.conductor.common.metadata.tasks.Task; + +/** + * Validates {@code WorkerManager.readRuntimeMetadata} — the embedded host-delivery read-path that + * extracts the host-resolved secret values from {@code Task.runtimeMetadata} (wire-only, resolved by + * the host from the worker's declared {@code TaskDef.runtimeMetadata}; conductor-oss PR #1255). + * Absent/empty → empty map (standalone falls back to the native token-pull). + */ +class ReadRuntimeMetadataTest { + + @SuppressWarnings("unchecked") + private static Map invoke(Task task) throws Exception { + Method m = WorkerManager.class.getDeclaredMethod("readRuntimeMetadata", Task.class); + m.setAccessible(true); + return (Map) m.invoke(null, task); + } + + private static Task taskWithRuntimeMetadata(Map rm) { + Task task = new Task(); + task.setRuntimeMetadata(rm); + return task; + } + + @Test + void extractsHostDeliveredValues() throws Exception { + Map rm = new HashMap<>(); + rm.put("GITHUB_TOKEN", "ghp_host"); + rm.put("GH_APP_ID", "42"); + + Map out = invoke(taskWithRuntimeMetadata(rm)); + + assertEquals(2, out.size()); + assertEquals("ghp_host", out.get("GITHUB_TOKEN")); + assertEquals("42", out.get("GH_APP_ID")); + } + + @Test + void emptyWhenAbsentOrEmpty() throws Exception { + assertTrue(invoke(null).isEmpty()); + assertTrue(invoke(new Task()).isEmpty()); + assertTrue(invoke(taskWithRuntimeMetadata(new HashMap<>())).isEmpty()); + } +} diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index c927fc842..669456643 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -13,6 +13,9 @@ license = {text = "MIT License"} # fall back to source builds and fail. Bump the ceiling once those wheels exist. requires-python = ">=3.10,<3.14" dependencies = [ + # TARGET: requires a conductor-python release that carries Task.runtime_metadata + # (host-resolved worker secrets, wire-only; conductor-oss/python-sdk feat/task-runtime-metadata). + # The field is additive; repin the floor to that release once it lands. "conductor-python>=1.3.11", "httpx>=0.24", "cloudpickle>=2.0", diff --git a/sdk/python/src/conductor/ai/agents/runtime/_dispatch.py b/sdk/python/src/conductor/ai/agents/runtime/_dispatch.py index 298c1f4d8..a5188bb7c 100644 --- a/sdk/python/src/conductor/ai/agents/runtime/_dispatch.py +++ b/sdk/python/src/conductor/ai/agents/runtime/_dispatch.py @@ -419,10 +419,10 @@ 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) + # Embedded: the host resolves the worker's declared TaskDef.runtimeMetadata secret names + # at poll time and delivers the values on the wire-only Task.runtime_metadata (never + # persisted). Prefer that map; otherwise fall back to the native token-pull (standalone). + host_delivered = getattr(task, "runtime_metadata", None) resolved_secrets = {} if isinstance(host_delivered, dict) and host_delivered: resolved_secrets = { diff --git a/sdk/python/src/conductor/ai/agents/runtime/runtime.py b/sdk/python/src/conductor/ai/agents/runtime/runtime.py index 486d2bbd9..53529d9ec 100644 --- a/sdk/python/src/conductor/ai/agents/runtime/runtime.py +++ b/sdk/python/src/conductor/ai/agents/runtime/runtime.py @@ -877,11 +877,13 @@ def prepare(self, agent: Any) -> None: _, workers = serialize_agent(agent) for w in workers: wrapper = make_tool_worker(w.func, w.name) + # Create-only: never overwrite an existing worker TaskDef (preserves server-set + # TaskDef.runtimeMetadata when embedded; see ToolRegistry.register_tool_workers). worker_task( task_definition_name=w.name, task_def=_default_task_def(w.name), register_task_def=True, - overwrite_task_def=True, + overwrite_task_def=False, lease_extend_enabled=True, )(wrapper) if workers: diff --git a/sdk/python/src/conductor/ai/agents/runtime/tool_registry.py b/sdk/python/src/conductor/ai/agents/runtime/tool_registry.py index 13f2d0299..101a60f2a 100644 --- a/sdk/python/src/conductor/ai/agents/runtime/tool_registry.py +++ b/sdk/python/src/conductor/ai/agents/runtime/tool_registry.py @@ -71,6 +71,13 @@ def register_tool_workers( if td.func is not None and td.tool_type in ("worker", "cli"): guardrails = td.guardrails if td.guardrails else None wrapper = make_tool_worker(td.func, td.name, guardrails=guardrails, tool_def=td) + # Create-only (overwrite_task_def=False): register the TaskDef if it does not exist, + # but never overwrite one that does. When embedded, the host server pre-registers the + # worker TaskDef and declares its secret names on TaskDef.runtimeMetadata (conductor-oss + # PR #1255); overwriting here with a bare def (the client TaskDef model carries no + # runtimeMetadata) would clobber that and starve the host resolver. Standalone still + # gets the def created when absent. No embedded flag needed — the existence check makes + # the right choice automatically. worker_task( task_definition_name=td.name, task_def=_default_task_def( @@ -80,7 +87,7 @@ def register_tool_workers( retry_policy=td.retry_policy, ), register_task_def=True, - overwrite_task_def=True, + overwrite_task_def=False, domain=domain if (agent_stateful or td.stateful) else None, lease_extend_enabled=True, )(wrapper) diff --git a/sdk/python/tests/unit/test_embedded_taskdef_registration.py b/sdk/python/tests/unit/test_embedded_taskdef_registration.py new file mode 100644 index 000000000..d28d4dcf4 --- /dev/null +++ b/sdk/python/tests/unit/test_embedded_taskdef_registration.py @@ -0,0 +1,34 @@ +"""Worker TaskDefs are registered create-only (overwrite_task_def=False): the SDK creates the def +when absent but never overwrites an existing one. When embedded, the host server pre-registers the +worker TaskDef and declares its secret names on TaskDef.runtimeMetadata (conductor-oss PR #1255); +overwriting here with a bare def (the client TaskDef model has no runtimeMetadata field) would clobber +that and starve the host resolver. This needs no embedded flag — the existence check chooses correctly. +""" + +from unittest.mock import patch + +from conductor.ai.agents.runtime.tool_registry import ToolRegistry +from conductor.ai.agents.tool import tool + + +def _worker_task_kwargs(): + @tool(credentials=["DEMO_SECRET"]) + def check_secret() -> dict: + return {"ok": True} + + calls = [] + + def fake_worker_task(**kwargs): + calls.append(kwargs) + return lambda fn: fn # decorator passthrough + + with patch("conductor.client.worker.worker_task.worker_task", side_effect=fake_worker_task): + ToolRegistry().register_tool_workers([check_secret], "secret_agent") + return next(c for c in calls if c.get("task_definition_name") == "check_secret") + + +def test_worker_taskdef_is_create_only_never_overwrite(): + kwargs = _worker_task_kwargs() + # create-only: register when missing, but never overwrite (preserves server runtimeMetadata). + assert kwargs["register_task_def"] is True + assert kwargs["overwrite_task_def"] is False diff --git a/sdk/python/tests/unit/test_resolved_credentials.py b/sdk/python/tests/unit/test_runtime_metadata.py similarity index 75% rename from sdk/python/tests/unit/test_resolved_credentials.py rename to sdk/python/tests/unit/test_runtime_metadata.py index 489817709..6fe8e68c4 100644 --- a/sdk/python/tests/unit/test_resolved_credentials.py +++ b/sdk/python/tests/unit/test_runtime_metadata.py @@ -1,6 +1,6 @@ -"""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. +"""Embedded host-delivered credential path: the worker prefers the host-resolved secret values on +``Task.runtime_metadata`` (wire-only, resolved by the host from the worker's declared +``TaskDef.runtimeMetadata``; conductor-oss PR #1255) over the native execution-token pull. """ from unittest.mock import patch @@ -20,10 +20,11 @@ def read_token() -> str: return make_tool_worker(td.func, td.name, tool_def=td) -def test_prefers_host_delivered_resolved_credentials(): +def test_prefers_host_delivered_runtime_metadata(): wrapper = _worker() task = Task() - task.input_data = {"__resolved_credentials__": {"GITHUB_TOKEN": "ghp_host_resolved"}} + task.input_data = {} + task.runtime_metadata = {"GITHUB_TOKEN": "ghp_host_resolved"} task.workflow_instance_id = "wf" task.task_id = "t" @@ -36,10 +37,11 @@ def test_prefers_host_delivered_resolved_credentials(): mock_fetcher.assert_not_called() -def test_falls_back_to_native_fetch_when_no_resolved_map(): +def test_falls_back_to_native_fetch_when_no_runtime_metadata(): wrapper = _worker() task = Task() task.input_data = {"__agentspan_ctx__": {"execution_token": "tok"}} + task.runtime_metadata = None task.workflow_instance_id = "wf" task.task_id = "t" diff --git a/sdk/typescript/src/worker.ts b/sdk/typescript/src/worker.ts index 8e92d1598..d07e9a9a2 100644 --- a/sdk/typescript/src/worker.ts +++ b/sdk/typescript/src/worker.ts @@ -212,7 +212,6 @@ export function stripInternalKeys(inputData: Record): Record - | undefined; + const hostDelivered = (task as { runtimeMetadata?: Record }) + .runtimeMetadata; let resolvedCredentials: Record = {}; if (hostDelivered && Object.keys(hostDelivered).length > 0) { diff --git a/sdk/typescript/tests/unit/worker.test.ts b/sdk/typescript/tests/unit/worker.test.ts index 55c99c982..65185da2a 100644 --- a/sdk/typescript/tests/unit/worker.test.ts +++ b/sdk/typescript/tests/unit/worker.test.ts @@ -550,6 +550,39 @@ describe("WorkerManager", () => { expect(contextAvailable).toBe(true); }); + it("prefers host-delivered task.runtimeMetadata over the native pull (embedded)", async () => { + // Embedded: the host resolves the worker's declared TaskDef.runtimeMetadata secret names and + // delivers the values on the wire-only Task.runtimeMetadata. The worker must use that map and + // never hit the native /workers/secrets endpoint, even with no execution token present. + const serverUrl = "http://cred-embedded"; + const manager = new WorkerManager(serverUrl, {}, 100); + + let resolved: string | undefined; + manager.addWorker("rtm_task", async () => { + const { getCredential } = await import("../../src/credentials.js"); + resolved = await getCredential("MY_CRED"); + return { ok: true }; + }); + + const fetchSpy = vi.fn().mockResolvedValue({ ok: true, status: 200, text: async () => "" }); + vi.stubGlobal("fetch", fetchSpy); + + const wrapped = (manager as any)._wrapWorker((manager as any).pendingWorkers[0]); + await wrapped.execute({ + taskId: "task-1", + workflowInstanceId: "wf-1", + inputData: { arg1: "value" }, // no __agentspan_ctx__ execution token + runtimeMetadata: { MY_CRED: "host-value" }, + }); + + expect(resolved).toBe("host-value"); + expect( + fetchSpy.mock.calls.some( + ([u]: [unknown]) => typeof u === "string" && u.includes("/workers/secrets"), + ), + ).toBe(false); + }); + it("clears credential context after handler completes", async () => { const manager = new WorkerManager("http://test", {}, 100); diff --git a/server/build.gradle b/server/build.gradle index ee3a3b9c8..abfabb53e 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -15,11 +15,11 @@ repositories { // ── Version catalog ────────────────────────────────────────────── ext { - // AgentSpan compiles/tests against the published conductor. Embedded secret delivery uses - // ${workflow.secrets.NAME} references resolved at runtime by the HOST conductor's - // ParametersUtils.substituteSecrets / SecretsDAO (conductor-oss PR #1255) — agentspan itself - // references no PR #1255 API, so it does not need to build against it. - conductorVersion = '3.32.0-rc.3' + // TARGET branch: worker secrets use TaskDef.runtimeMetadata (conductor-oss PR #1255), so the + // server references TaskDef.setRuntimeMetadata and must build against a conductor that has it. + // Pinned to the local runtimemeta build (superset of 3.32.0-rc.3); revert to a published version + // once PR #1255 ships. (The interim on feature/embedded-secret-toggle builds against 3.32.0-rc.3.) + conductorVersion = '3.32.0-rc.5' 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/AgentspanSecretsDAO.java b/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/credentials/AgentspanSecretsDAO.java new file mode 100644 index 000000000..af6023c7f --- /dev/null +++ b/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/credentials/AgentspanSecretsDAO.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 AgentSpan + * Licensed under the MIT License. + */ +package dev.agentspan.runtime.credentials; + +import java.util.List; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import com.netflix.conductor.dao.SecretsDAO; + +import dev.agentspan.runtime.model.credentials.CredentialMeta; +import dev.agentspan.runtime.spi.CredentialStoreProvider; + +/** + * Bridges conductor's global {@link SecretsDAO} to AgentSpan's own {@link CredentialStoreProvider} + * (the encrypted credential store), scoped to the anonymous/system user. + * + *

Active only when {@code conductor.secrets.type=agentspan} — the "agentspan-as-host" mode where + * the AgentSpan server embeds conductor ({@code agentspan.embedded=true}) and also serves as + * the secret-resolving host. In that mode the embedded conductor's {@code RuntimeMetadataResolver} + * calls {@link #getSecret(String)} at each SIMPLE task's poll to resolve the secret names a worker + * declared on {@code TaskDef.runtimeMetadata}, injecting the resolved values onto the wire-only + * {@code Task.runtimeMetadata}. Selecting this DAO ({@code havingValue="agentspan"}) gates conductor's + * own env-variable / noop {@code SecretsDAO} implementations off (they require + * {@code conductor.secrets.type} to be {@code env}/absent or {@code noop}).

+ * + *

Conductor secrets are global (name only); AgentSpan's store is per-user, so every lookup is + * scoped to {@link #ANONYMOUS_USER_ID} — the no-auth/system user, matching {@code CredentialEnvSeeder} + * and {@code AuthFilter.ANONYMOUS}. Names are treated as flat keys (no dotted JSONPath): worker + * credential names are simple identifiers, and {@link CredentialStoreProvider#get} resolves them + * directly.

+ * + *

The backing store beans ({@code EncryptedDbCredentialStoreProvider}, {@code MasterKeyConfig}, + * {@code CredentialDataSourceConfig}, {@code CredentialSchemaMigrator}) are normally dormant when + * embedded; they are re-enabled under this same {@code conductor.secrets.type=agentspan} flag so this + * bridge has a store to read from.

+ */ +@Component +@ConditionalOnProperty(name = "conductor.secrets.type", havingValue = "agentspan") +public class AgentspanSecretsDAO implements SecretsDAO { + + private static final Logger log = LoggerFactory.getLogger(AgentspanSecretsDAO.class); + + /** + * User ID for the anonymous/OSS user — matches {@code CredentialEnvSeeder.ANONYMOUS_USER_ID} and + * {@code AuthFilter.ANONYMOUS}. Conductor's global secret lookups resolve against this user. + */ + static final String ANONYMOUS_USER_ID = "00000000-0000-0000-0000-000000000000"; + + private final CredentialStoreProvider store; + + public AgentspanSecretsDAO(CredentialStoreProvider store) { + this.store = store; + log.info( + "AgentspanSecretsDAO active — embedded conductor secrets resolve from the AgentSpan " + + "credential store (scoped to system user {})", + ANONYMOUS_USER_ID); + } + + @Override + public String getSecret(String key) { + return store.get(ANONYMOUS_USER_ID, key); + } + + @Override + public boolean secretExists(String key) { + return store.get(ANONYMOUS_USER_ID, key) != null; + } + + @Override + public List listSecretNames() { + return store.list(ANONYMOUS_USER_ID).stream() + .map(CredentialMeta::getName) + .collect(Collectors.toList()); + } + + @Override + public void putSecret(String key, String value) { + store.set(ANONYMOUS_USER_ID, key, value); + } + + @Override + public void deleteSecret(String key) { + store.delete(ANONYMOUS_USER_ID, key); + } +} 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 a9d3c2c96..dc7ef9db7 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 @@ -51,7 +51,7 @@ *

PostgreSQL: uses {@code org.postgresql.Driver} with a larger pool (default 8).

*/ @Configuration -@ConditionalOnProperty(name = "agentspan.embedded", havingValue = "false", matchIfMissing = true) +@ConditionalOnProperty(name = "conductor.secrets.type", havingValue = "agentspan") 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/CredentialSchemaMigrator.java b/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/credentials/CredentialSchemaMigrator.java index 17011b1b1..83c83c8c1 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 @@ -31,7 +31,7 @@ * pre-release development builds.

*/ @Component -@ConditionalOnProperty(name = "agentspan.embedded", havingValue = "false", matchIfMissing = true) +@ConditionalOnProperty(name = "conductor.secrets.type", havingValue = "agentspan") 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 6bc8a38eb..4fcd66fba 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 @@ -35,7 +35,7 @@ *

The master key is the 32-byte key from {@code MasterKeyConfig#credentialMasterKey()}.

*/ @Component -@ConditionalOnProperty(name = "agentspan.embedded", havingValue = "false", matchIfMissing = true) +@ConditionalOnProperty(name = "conductor.secrets.type", havingValue = "agentspan") 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 6a2bb937a..ff1f7a3ac 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 @@ -29,7 +29,7 @@ * */ @Configuration -@ConditionalOnProperty(name = "agentspan.embedded", havingValue = "false", matchIfMissing = true) +@ConditionalOnProperty(name = "conductor.secrets.type", havingValue = "agentspan") public class MasterKeyConfig { private static final Logger log = LoggerFactory.getLogger(MasterKeyConfig.class); diff --git a/server/conductor-agentspan-server/src/main/resources/application.properties b/server/conductor-agentspan-server/src/main/resources/application.properties index 3906a87f8..ba92767f4 100644 --- a/server/conductor-agentspan-server/src/main/resources/application.properties +++ b/server/conductor-agentspan-server/src/main/resources/application.properties @@ -162,6 +162,16 @@ agentspan.credentials.store=built-in agentspan.credentials.strict-mode=false agentspan.credentials.resolve.rate-limit=120 +# Secret backend for the embedded conductor (RuntimeMetadataResolver at task poll, and +# ${workflow.secrets.NAME} substitution). 'agentspan' backs it with AgentSpan's encrypted +# credential store via AgentspanSecretsDAO and activates the store beans (datasource, master +# key, schema migrator, store provider) — the same store the native credential services use. +# Defaulted on so the standalone server keeps its store; when embedded as the secret-resolving +# host, set agentspan.embedded=true and leave this at 'agentspan'. Override to conductor's own +# 'env'/'noop' backend only when the host delivers secrets and the native store is not wanted +# (the native credential services require the AgentSpan store, so do not override it standalone). +conductor.secrets.type=${CONDUCTOR_SECRETS_TYPE:agentspan} + # Mask secrets from the host-owned /api/workflow/{id} (raw Conductor) read path too. # Off by default so embedding this library never mutates a host's workflow responses; # AgentSpan's own /api/agent/* reads are always masked regardless of this flag. diff --git a/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/credentials/AgentspanSecretsDAOTest.java b/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/credentials/AgentspanSecretsDAOTest.java new file mode 100644 index 000000000..82ccbd727 --- /dev/null +++ b/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/credentials/AgentspanSecretsDAOTest.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2025 AgentSpan + * Licensed under the MIT License. + */ +package dev.agentspan.runtime.credentials; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +import dev.agentspan.runtime.model.credentials.CredentialMeta; +import dev.agentspan.runtime.spi.CredentialStoreProvider; + +/** + * {@link AgentspanSecretsDAO} bridges conductor's global {@code SecretsDAO} to AgentSpan's per-user + * {@link CredentialStoreProvider}, scoped to the anonymous/system user. Verifies the name→value + * round-trip is scoped to {@code ANONYMOUS_USER_ID} (so other users' secrets are invisible) and that + * the bean is selected only by {@code conductor.secrets.type=agentspan}. + */ +class AgentspanSecretsDAOTest { + + private static final String ANON = "00000000-0000-0000-0000-000000000000"; + + /** In-memory {@link CredentialStoreProvider} keyed by (userId,name) so scope can be asserted. */ + static class FakeStore implements CredentialStoreProvider { + final Map data = new LinkedHashMap<>(); + + private static String k(String u, String n) { + return u + "|" + n; + } + + @Override + public String get(String userId, String name) { + return data.get(k(userId, name)); + } + + @Override + public void set(String userId, String name, String value) { + data.put(k(userId, name), value); + } + + @Override + public void delete(String userId, String name) { + data.remove(k(userId, name)); + } + + @Override + public List list(String userId) { + List out = new ArrayList<>(); + for (String key : data.keySet()) { + int bar = key.indexOf('|'); + if (key.substring(0, bar).equals(userId)) { + out.add(CredentialMeta.builder().name(key.substring(bar + 1)).build()); + } + } + return out; + } + } + + @Test + void roundTrip_scopedToAnonymousUser() { + FakeStore store = new FakeStore(); + AgentspanSecretsDAO dao = new AgentspanSecretsDAO(store); + + assertThat(dao.secretExists("GITHUB_TOKEN")).isFalse(); + assertThat(dao.getSecret("GITHUB_TOKEN")).isNull(); + + dao.putSecret("GITHUB_TOKEN", "ghp_x"); + // written under the anonymous/system user — the scope conductor resolves against + assertThat(store.data).containsEntry(ANON + "|GITHUB_TOKEN", "ghp_x"); + assertThat(dao.getSecret("GITHUB_TOKEN")).isEqualTo("ghp_x"); + assertThat(dao.secretExists("GITHUB_TOKEN")).isTrue(); + + dao.putSecret("SLACK_TOKEN", "xoxb"); + assertThat(dao.listSecretNames()).containsExactlyInAnyOrder("GITHUB_TOKEN", "SLACK_TOKEN"); + + dao.deleteSecret("GITHUB_TOKEN"); + assertThat(dao.getSecret("GITHUB_TOKEN")).isNull(); + assertThat(dao.listSecretNames()).containsExactly("SLACK_TOKEN"); + } + + @Test + void doesNotReadOtherUsersSecrets() { + FakeStore store = new FakeStore(); + store.set("some-other-user", "GITHUB_TOKEN", "not-mine"); + AgentspanSecretsDAO dao = new AgentspanSecretsDAO(store); + assertThat(dao.getSecret("GITHUB_TOKEN")).isNull(); + assertThat(dao.listSecretNames()).isEmpty(); + } + + // ── gating: selected only by conductor.secrets.type=agentspan ── + + @Configuration + @Import(AgentspanSecretsDAO.class) + static class DaoConfig {} + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withBean(CredentialStoreProvider.class, () -> mock(CredentialStoreProvider.class)) + .withUserConfiguration(DaoConfig.class); + + @Test + void beanPresent_whenConductorSecretsTypeAgentspan() { + runner.withPropertyValues("conductor.secrets.type=agentspan") + .run(ctx -> assertThat(ctx).hasSingleBean(AgentspanSecretsDAO.class)); + } + + @Test + void beanAbsent_whenFlagUnsetOrDifferent() { + runner.run(ctx -> assertThat(ctx).doesNotHaveBean(AgentspanSecretsDAO.class)); + runner.withPropertyValues("conductor.secrets.type=env") + .run(ctx -> assertThat(ctx).doesNotHaveBean(AgentspanSecretsDAO.class)); + } +} 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 6949246d9..3f97bfbc6 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> enrichWithAgentTools( private List> enrichWithConfigs( String httpJson, String agentToolJson, String knownNamesJson, String toolCallsJson) throws Exception { String script = JavaScriptBuilder.enrichToolsScript( - httpJson, "{}", "{}", agentToolJson, "{}", "{}", "{}", "{}", knownNamesJson, "{}"); + httpJson, "{}", "{}", agentToolJson, "{}", "{}", "{}", "{}", knownNamesJson); // Wrap so the script's IIFE return is captured AND we get a JSON string // back — Graal's Value.toString() is JS source, not JSON. String wrapped = "var $ = {" diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/AgentCompiler.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/AgentCompiler.java index 521922399..6fe6d2b7a 100644 --- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/AgentCompiler.java +++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/AgentCompiler.java @@ -342,11 +342,11 @@ WorkflowDef compileSimple(AgentConfig config) { /** * Collect {@code toolName -> [credentialNames]} for the agent's tools: each tool's own declared - * credentials, falling back to the agent-level credential list. Fed to - * {@link ToolCompiler#setWorkerCreds} so SIMPLE worker tasks carry {@code __resolved_credentials__} - * secret references in embedded mode. + * credentials, falling back to the agent-level credential list. Used by {@code AgentService} to + * declare each worker tool's {@code TaskDef.runtimeMetadata} (embedded), so the host resolves the + * names at the SIMPLE task's poll and injects the values onto {@code Task.runtimeMetadata}. */ - static Map> collectToolCredentials(AgentConfig config) { + public static Map> collectToolCredentials(AgentConfig config) { List agentCreds = config.getCredentials() != null ? config.getCredentials() : List.of(); Map> map = new LinkedHashMap<>(); if (config.getTools() != null) { @@ -359,12 +359,30 @@ static Map> collectToolCredentials(AgentConfig config) { } } List effective = own.isEmpty() ? agentCreds : own; - if (!effective.isEmpty()) map.put(tool.getName(), new ArrayList<>(effective)); + if (!effective.isEmpty()) map.put(tool.getName(), new ArrayList<>(new LinkedHashSet<>(effective))); } } return map; } + /** + * Collect the agent-level credential names, deduped and order-preserving. Used by + * {@code AgentService} to declare {@code TaskDef.runtimeMetadata} (embedded) on the non-worker + * SIMPLE tasks that run user-authored code — guardrails, callbacks, stop_when, gates, instructions, + * routers, graph node/edge workers — none of which carry their own per-item credential list, so the + * agent-level list is their only source. The host resolves the names at each task's poll and injects + * the values onto the wire-only {@code Task.runtimeMetadata}. + * + *

Note: the SDK worker wrappers for these non-worker task kinds do not yet read + * {@code Task.runtimeMetadata} (only the tool worker does), so declaring it here is currently inert — + * the values ride the wire but {@code get_secret()} inside those user functions won't resolve until + * the SDK wrappers are taught to route {@code runtimeMetadata} into the credential context.

+ */ + public static List collectAgentCredentials(AgentConfig config) { + if (config.getCredentials() == null || config.getCredentials().isEmpty()) return List.of(); + return new ArrayList<>(new LinkedHashSet<>(config.getCredentials())); + } + WorkflowDef compileWithTools(AgentConfig config) { ParsedModel parsed = ModelParser.parse(config.getModel()); String llmRef = toRef(config.getName()) + "_llm"; @@ -372,7 +390,6 @@ WorkflowDef compileWithTools(AgentConfig config) { List tools = config.getTools(); ToolCompiler tc = new ToolCompiler(); - tc.setWorkerCreds(collectToolCredentials(config)); boolean hasApproval = tools.stream().anyMatch(ToolConfig::isApprovalRequired); boolean hasMcp = tools.stream().anyMatch(t -> "mcp".equals(t.getToolType())); boolean hasApi = tools.stream().anyMatch(t -> "api".equals(t.getToolType())); @@ -747,7 +764,6 @@ WorkflowDef compileHybrid(AgentConfig config) { } ToolCompiler tc = new ToolCompiler(); - tc.setWorkerCreds(collectToolCredentials(config)); boolean hasApproval = allTools.stream().anyMatch(ToolConfig::isApprovalRequired); boolean hasMcp = allTools.stream().anyMatch(t -> "mcp".equals(t.getToolType())); boolean hasApi = allTools.stream().anyMatch(t -> "api".equals(t.getToolType())); diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/MultiAgentCompiler.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/MultiAgentCompiler.java index 724e505d9..4ebf6ffaa 100644 --- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/MultiAgentCompiler.java +++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/MultiAgentCompiler.java @@ -1364,7 +1364,6 @@ WorkflowDef compileSwarmAgentWorkflow(AgentConfig agent, List transf allTools.addAll(transferTools); ToolCompiler tc = new ToolCompiler(); - tc.setWorkerCreds(AgentCompiler.collectToolCredentials(agent)); boolean hasApproval = allTools.stream().anyMatch(ToolConfig::isApprovalRequired); List> toolSpecs = tc.compileToolSpecs(allTools); @@ -1463,7 +1462,6 @@ private WorkflowDef compileSwarmAgentWorkflowWithSubAgents(AgentConfig agent, Li // 3. LLM step with transfer tools to decide whether to transfer to a peer ToolCompiler tc = new ToolCompiler(); - tc.setWorkerCreds(AgentCompiler.collectToolCredentials(agent)); List> transferToolSpecs = tc.compileToolSpecs(transferTools); WorkflowTask transferLlm = new WorkflowTask(); diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/ToolCompiler.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/ToolCompiler.java index 996d011d0..80553608a 100644 --- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/ToolCompiler.java +++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/compiler/ToolCompiler.java @@ -119,55 +119,6 @@ private static Map escapeHeadersInConfig(Map cfg Map.entry("rag_search", "LLM_SEARCH_INDEX"), Map.entry("pull_workflow_messages", "PULL_WORKFLOW_MESSAGES")); - /** - * Per-tool credential names ({@code toolName -> [names]}) used to stamp - * {@code __resolved_credentials__} onto SIMPLE worker-tool tasks in EMBEDDED mode, so the host - * resolves each {@code ${workflow.secrets.NAME}} reference from its secret store at poll time. - * Set by {@link AgentCompiler} (preserves agent-level credential fallback); empty by default - * (no stamping — standalone, or non-worker tools whose secrets travel as headers). - */ - private Map> workerCreds = Map.of(); - - /** Inject per-tool credential names so worker-tool SIMPLE tasks can carry secret references. */ - void setWorkerCreds(Map> workerCreds) { - this.workerCreds = workerCreds != null ? workerCreds : Map.of(); - } - - /** True if a tool compiles to a SIMPLE worker task (executed by an external SDK worker). */ - private static boolean isWorkerTool(ToolConfig tool) { - String t = tool.getToolType() != null ? tool.getToolType() : "worker"; - return "SIMPLE".equals(TYPE_MAP.getOrDefault(t, "SIMPLE")); - } - - /** - * Build {@code {toolName -> {NAME: "${workflow.secrets.NAME}"}}} for this agent's SIMPLE - * worker tools, EMBEDDED only. The host resolves the references just-in-time at poll (via - * {@code ParametersUtils.substituteSecrets}); the SDK worker reads {@code __resolved_credentials__} - * from its task input and strips it. HTTP/MCP tools are excluded — their secrets travel as - * {@code ${workflow.secrets.NAME}} headers. - */ - private Map buildWorkerCredConfig(List tools) { - Map cfg = new LinkedHashMap<>(); - if (!EmbeddedMode.isEmbedded() || tools == null || workerCreds.isEmpty()) { - return cfg; - } - for (ToolConfig tool : tools) { - if (tool.getName() == null || !isWorkerTool(tool)) { - continue; - } - List names = workerCreds.get(tool.getName()); - if (names == null || names.isEmpty()) { - continue; - } - Map refs = new LinkedHashMap<>(); - for (String name : names) { - refs.put(name, "${workflow.secrets." + name + "}"); - } - cfg.put(tool.getName(), refs); - } - return cfg; - } - // ── Public API ─────────────────────────────────────────────────────── /** @@ -465,19 +416,9 @@ public Object[] buildEnrichTask(String agentName, String llmRef, List registered) { + // Credential names declared on each SIMPLE task's TaskDef.runtimeMetadata (embedded only, gated + // inside registerTaskDef). Worker tools use their per-tool creds (with agent-level fallback); + // the other user-code task kinds (guardrail/callback/stop_when/gate/instructions/router/graph) + // have no per-item credential list, so they use the agent-level names. Hoisted once per config. + Map> toolCreds = AgentCompiler.collectToolCredentials(config); + List agentCreds = AgentCompiler.collectAgentCredentials(config); + // Register dispatch task for this agent's tools if (config.getTools() != null) { for (ToolConfig tool : config.getTools()) { String tt = tool.getToolType(); if ("worker".equals(tt) && !registered.contains(tool.getName())) { - registerTaskDef(tool.getName()); + registerTaskDef(tool.getName(), toolCreds.get(tool.getName())); registered.add(tool.getName()); } } } - // Register stop_when worker + // Register stop_when worker (user-authored predicate → agent-level creds) if (config.getStopWhen() != null && config.getStopWhen().getTaskName() != null) { String taskName = config.getStopWhen().getTaskName(); if (!registered.contains(taskName)) { - registerTaskDef(taskName); + registerTaskDef(taskName, agentCreds); registered.add(taskName); } } @@ -984,7 +992,7 @@ private void collectAndRegisterTasks(AgentConfig config, Set registered) for (GuardrailConfig g : config.getGuardrails()) { if ("custom".equals(g.getGuardrailType()) && g.getTaskName() != null) { if (!registered.contains(g.getTaskName())) { - registerTaskDef(g.getTaskName()); + registerTaskDef(g.getTaskName(), agentCreds); registered.add(g.getTaskName()); } } @@ -995,7 +1003,7 @@ private void collectAndRegisterTasks(AgentConfig config, Set registered) if (config.getCallbacks() != null) { for (CallbackConfig cb : config.getCallbacks()) { if (cb.getTaskName() != null && !registered.contains(cb.getTaskName())) { - registerTaskDef(cb.getTaskName()); + registerTaskDef(cb.getTaskName(), agentCreds); registered.add(cb.getTaskName()); } } @@ -1004,7 +1012,7 @@ private void collectAndRegisterTasks(AgentConfig config, Set registered) // Register callable gate workers (text_contains gates are INLINE, no registration needed) if (config.getGate() != null && config.getGate().get("taskName") instanceof String gateTaskName) { if (!registered.contains(gateTaskName)) { - registerTaskDef(gateTaskName); + registerTaskDef(gateTaskName, agentCreds); registered.add(gateTaskName); } } @@ -1014,7 +1022,7 @@ private void collectAndRegisterTasks(AgentConfig config, Set registered) && instrMap.get("_worker_ref") instanceof String instrTaskName && !instrTaskName.isBlank()) { if (!registered.contains(instrTaskName)) { - registerTaskDef(instrTaskName); + registerTaskDef(instrTaskName, agentCreds); registered.add(instrTaskName); } } @@ -1023,12 +1031,12 @@ private void collectAndRegisterTasks(AgentConfig config, Set registered) if (config.getRouter() instanceof Map routerMap && routerMap.get("taskName") instanceof String routerTaskName) { if (!registered.contains(routerTaskName)) { - registerTaskDef(routerTaskName); + registerTaskDef(routerTaskName, agentCreds); registered.add(routerTaskName); } } else if (config.getRouter() instanceof WorkerRef workerRef && workerRef.getTaskName() != null) { if (!registered.contains(workerRef.getTaskName())) { - registerTaskDef(workerRef.getTaskName()); + registerTaskDef(workerRef.getTaskName(), agentCreds); registered.add(workerRef.getTaskName()); } } @@ -1117,7 +1125,7 @@ private void collectAndRegisterTasks(AgentConfig config, Set registered) for (Object nodeObj : nodes) { if (nodeObj instanceof Map node && node.get("_worker_ref") instanceof String workerRef) { if (!registered.contains(workerRef)) { - registerTaskDef(workerRef); + registerTaskDef(workerRef, agentCreds); registered.add(workerRef); } } @@ -1128,7 +1136,7 @@ private void collectAndRegisterTasks(AgentConfig config, Set registered) for (Object ceObj : condEdges) { if (ceObj instanceof Map ce && ce.get("_router_ref") instanceof String routerRef) { if (!registered.contains(routerRef)) { - registerTaskDef(routerRef); + registerTaskDef(routerRef, agentCreds); registered.add(routerRef); } } @@ -1409,6 +1417,16 @@ private String extractSubagentIdentifier(Map event) { // ── Task registration ──────────────────────────────────────────── private void registerTaskDef(String taskName) { + registerTaskDef(taskName, null); + } + + /** + * Register a worker TaskDef. When embedded, {@code runtimeMetadata} declares the secret names the + * host must resolve at the SIMPLE task's poll and inject onto the wire-only + * {@code Task.runtimeMetadata} (conductor-oss PR #1255). Standalone leaves it empty — the native + * execution-token pull delivers secrets instead. + */ + private void registerTaskDef(String taskName, List runtimeMetadata) { TaskDef taskDef = new TaskDef(); taskDef.setName(taskName); taskDef.setRetryCount(2); @@ -1417,6 +1435,9 @@ private void registerTaskDef(String taskName) { taskDef.setTimeoutSeconds(0); taskDef.setResponseTimeoutSeconds(3600); taskDef.setTimeoutPolicy(TaskDef.TimeoutPolicy.RETRY); + if (EmbeddedMode.isEmbedded() && runtimeMetadata != null && !runtimeMetadata.isEmpty()) { + taskDef.setRuntimeMetadata(new ArrayList<>(runtimeMetadata)); + } try { TaskDef existing = metadataDAO.getTaskDef(taskName); diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/util/JavaScriptBuilder.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/util/JavaScriptBuilder.java index f7d07852e..91557657c 100644 --- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/util/JavaScriptBuilder.java +++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/util/JavaScriptBuilder.java @@ -526,8 +526,7 @@ public static String enrichToolsScript( String cliConfigJson, String humanConfigJson, String wmqConfigJson, - String knownToolNamesJson, - String workerCredJson) { + String knownToolNamesJson) { return iife(" var httpCfg = " + httpConfigJson + ";" + " var mcpCfg = " + mcpConfigJson + ";" + " var mediaCfg = " + mediaConfigJson + ";" + " var agentToolCfg = " @@ -536,7 +535,6 @@ public static String enrichToolsScript( + cliConfigJson + ";" + " var humanCfg = " + humanConfigJson + ";" + " var wmqCfg = " + wmqConfigJson + ";" + " var knownNames = " + knownToolNamesJson + ";" - + " var workerCredCfg = " + workerCredJson + ";" + " var agentState = $.agentState || {};" + " var tcs = $.toolCalls || [];" + " var result = [];" @@ -697,7 +695,6 @@ public static String enrichToolsScript( + " if (t.type === 'SIMPLE') {" + " t.inputParameters._agent_state = agentState;" + " if ($.agentspanCtx) { t.inputParameters.__agentspan_ctx__ = $.agentspanCtx; }" - + " if (workerCredCfg[n]) { t.inputParameters.__resolved_credentials__ = workerCredCfg[n]; }" + " if (cliCfg[n]) { t.inputParameters._allowed_commands = cliCfg[n].allowedCommands; }" + " }" + " result.push(t);" @@ -1155,8 +1152,7 @@ public static String enrichToolsScriptDynamic( String ragConfigJson, String humanConfigJson, String wmqConfigJson, - String knownToolNamesJson, - String workerCredJson) { + String knownToolNamesJson) { return iife(" var httpCfg = " + httpConfigJson + ";" + " var mcpCfg = $.mcpConfig || {};" + " var apiCfg = $.apiConfig || {};" + " var mediaCfg = " @@ -1165,7 +1161,6 @@ public static String enrichToolsScriptDynamic( + ragConfigJson + ";" + " var humanCfg = " + humanConfigJson + ";" + " var wmqCfg = " + wmqConfigJson + ";" + " var knownNames = " + knownToolNamesJson + ";" - + " var workerCredCfg = " + workerCredJson + ";" + " var agentState = $.agentState || {};" + " var tcs = $.toolCalls || [];" + " var result = [];" @@ -1352,7 +1347,6 @@ public static String enrichToolsScriptDynamic( + " if (t.type === 'SIMPLE') {" + " t.inputParameters._agent_state = agentState;" + " if ($.agentspanCtx) { t.inputParameters.__agentspan_ctx__ = $.agentspanCtx; }" - + " if (workerCredCfg[n]) { t.inputParameters.__resolved_credentials__ = workerCredCfg[n]; }" + " }" + " result.push(t);" + " }" diff --git a/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/compiler/ToolCompilerWorkerCredTest.java b/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/compiler/ToolCompilerWorkerCredTest.java deleted file mode 100644 index 16be2b084..000000000 --- a/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/compiler/ToolCompilerWorkerCredTest.java +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Copyright (c) 2025 AgentSpan - * Licensed under the MIT License. - */ -package dev.agentspan.runtime.compiler; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.util.List; -import java.util.Map; - -import org.graalvm.polyglot.Context; -import org.graalvm.polyglot.Value; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.netflix.conductor.common.metadata.workflow.WorkflowTask; - -import dev.agentspan.runtime.model.AgentConfig; -import dev.agentspan.runtime.model.ToolConfig; -import dev.agentspan.runtime.util.EmbeddedMode; - -/** - * Verifies EMBEDDED mode stamps {@code __resolved_credentials__ = { NAME: "${workflow.secrets.NAME}" }} - * onto SIMPLE worker-tool tasks (via the enrich script), and that standalone / non-worker tools are - * left untouched. In embedded, the host resolves the references from its secret store at poll time. - */ -class ToolCompilerWorkerCredTest { - - private static final ObjectMapper MAPPER = new ObjectMapper(); - - @AfterEach - void resetEmbedded() { - new EmbeddedMode().setEmbedded(false); - } - - private static ToolConfig worker(String name, String... creds) { - return ToolConfig.builder() - .name(name) - .description(name) - .toolType("worker") - .config(Map.of("credentials", List.of(creds))) - .build(); - } - - private static ToolCompiler compilerFor(AgentConfig config) { - ToolCompiler tc = new ToolCompiler(); - tc.setWorkerCreds(AgentCompiler.collectToolCredentials(config)); - return tc; - } - - private static String enrichScript(ToolCompiler tc, List tools) { - Object[] r = tc.buildEnrichTask("agent", "agent_llm", tools, ""); - return (String) ((WorkflowTask) r[0]).getInputParameters().get("expression"); - } - - /** Execute the enrich script through GraalJS for one tool call; return that task's built map. */ - @SuppressWarnings("unchecked") - private static Map runEnrichForTool(String script, String toolName) throws Exception { - String wrapped = "var $ = {toolCalls: [{name: '" + toolName + "', taskReferenceName: 'call_1'," - + " inputParameters: {}}], agentState: {}, userPrompt: 'test'};" - + " JSON.stringify(" + script + ");"; - try (Context ctx = Context.newBuilder("js").allowAllAccess(true).build()) { - Value v = ctx.eval("js", wrapped); - Map outer = MAPPER.readValue(v.asString(), Map.class); - List> tasks = (List>) outer.get("dynamicTasks"); - return tasks.stream() - .filter(t -> toolName.equals(t.get("name"))) - .findFirst() - .orElseThrow(); - } - } - - @Test - void embedded_stampsPerToolSecretReference() { - new EmbeddedMode().setEmbedded(true); - ToolConfig gh = worker("gh", "GITHUB_TOKEN"); - AgentConfig config = AgentConfig.builder() - .name("a") - .model("openai/gpt-4o") - .tools(List.of(gh)) - .build(); - - String script = enrichScript(compilerFor(config), List.of(gh)); - - assertThat(script).contains("\"gh\":{\"GITHUB_TOKEN\":\"${workflow.secrets.GITHUB_TOKEN}\"}"); - } - - @Test - @SuppressWarnings("unchecked") - void embedded_injectsResolvedCredentialsOntoSimpleTask() throws Exception { - new EmbeddedMode().setEmbedded(true); - ToolConfig gh = worker("gh", "GITHUB_TOKEN"); - AgentConfig config = AgentConfig.builder() - .name("a") - .model("openai/gpt-4o") - .tools(List.of(gh)) - .build(); - - Map task = runEnrichForTool(enrichScript(compilerFor(config), List.of(gh)), "gh"); - - Map input = (Map) task.get("inputParameters"); - Map resolved = (Map) input.get("__resolved_credentials__"); - assertThat(resolved).containsEntry("GITHUB_TOKEN", "${workflow.secrets.GITHUB_TOKEN}"); - } - - @Test - @SuppressWarnings("unchecked") - void standalone_leavesWorkerTaskUntouched() throws Exception { - new EmbeddedMode().setEmbedded(false); - ToolConfig gh = worker("gh", "GITHUB_TOKEN"); - AgentConfig config = AgentConfig.builder() - .name("a") - .model("openai/gpt-4o") - .tools(List.of(gh)) - .build(); - - String script = enrichScript(compilerFor(config), List.of(gh)); - assertThat(script).doesNotContain("__resolved_credentials__\":{\"GITHUB_TOKEN"); - - Map task = runEnrichForTool(script, "gh"); - Map input = (Map) task.get("inputParameters"); - assertThat(input).doesNotContainKey("__resolved_credentials__"); - } -} diff --git a/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/compiler/WorkerRuntimeMetadataTest.java b/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/compiler/WorkerRuntimeMetadataTest.java new file mode 100644 index 000000000..380a371d4 --- /dev/null +++ b/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/compiler/WorkerRuntimeMetadataTest.java @@ -0,0 +1,246 @@ +/* + * Copyright (c) 2025 AgentSpan + * Licensed under the MIT License. + */ +package dev.agentspan.runtime.compiler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import com.netflix.conductor.common.metadata.tasks.TaskDef; +import com.netflix.conductor.common.metadata.workflow.WorkflowTask; +import com.netflix.conductor.dao.MetadataDAO; +import com.netflix.conductor.service.MetadataService; + +import dev.agentspan.runtime.model.AgentConfig; +import dev.agentspan.runtime.model.GuardrailConfig; +import dev.agentspan.runtime.model.TerminationConfig; +import dev.agentspan.runtime.model.ToolConfig; +import dev.agentspan.runtime.service.AgentService; +import dev.agentspan.runtime.util.EmbeddedMode; + +/** + * Target-state worker-secret delivery (conductor-oss PR #1255): in EMBEDDED mode the worker's + * {@link TaskDef} declares its secret names on {@code runtimeMetadata}; the host resolves them at the + * SIMPLE task's own poll and injects the values onto the wire-only {@code Task.runtimeMetadata} — the + * enrich script never stamps {@code __resolved_credentials__} into persisted task input. Standalone + * leaves {@code runtimeMetadata} empty (the native execution-token pull delivers secrets instead). + */ +class WorkerRuntimeMetadataTest { + + @AfterEach + void resetEmbedded() { + new EmbeddedMode().setEmbedded(false); + } + + private static ToolConfig worker(String name, String... creds) { + return ToolConfig.builder() + .name(name) + .description(name) + .toolType("worker") + .config(Map.of("credentials", List.of(creds))) + .build(); + } + + private static AgentConfig agentWith(ToolConfig tool) { + return AgentConfig.builder() + .name("a") + .model("openai/gpt-4o") + .tools(List.of(tool)) + .build(); + } + + // ── The enrich script must NOT stamp __resolved_credentials__ (the retired interim) ── + + @Test + void enrichScript_neverStampsResolvedCredentials_embedded() { + new EmbeddedMode().setEmbedded(true); + ToolConfig gh = worker("gh", "GITHUB_TOKEN"); + Object[] r = new ToolCompiler().buildEnrichTask("agent", "agent_llm", List.of(gh), ""); + String script = (String) ((WorkflowTask) r[0]).getInputParameters().get("expression"); + assertThat(script).doesNotContain("__resolved_credentials__"); + } + + @Test + void enrichScript_neverStampsResolvedCredentials_standalone() { + new EmbeddedMode().setEmbedded(false); + ToolConfig gh = worker("gh", "GITHUB_TOKEN"); + Object[] r = new ToolCompiler().buildEnrichTask("agent", "agent_llm", List.of(gh), ""); + String script = (String) ((WorkflowTask) r[0]).getInputParameters().get("expression"); + assertThat(script).doesNotContain("__resolved_credentials__"); + } + + // ── AgentService declares runtimeMetadata on the worker TaskDef only when embedded ── + + @Test + void embedded_stampsRuntimeMetadataOnWorkerTaskDef() throws Exception { + new EmbeddedMode().setEmbedded(true); + TaskDef registered = registerWorkerTaskDef("gh", List.of("GITHUB_TOKEN")); + assertThat(registered.getRuntimeMetadata()).containsExactly("GITHUB_TOKEN"); + } + + @Test + void standalone_leavesRuntimeMetadataEmpty() throws Exception { + new EmbeddedMode().setEmbedded(false); + TaskDef registered = registerWorkerTaskDef("gh", List.of("GITHUB_TOKEN")); + assertThat(registered.getRuntimeMetadata()).isNullOrEmpty(); + } + + @Test + void collectToolCredentials_mapsWorkerToItsSecretNames() { + AgentConfig config = agentWith(worker("gh", "GITHUB_TOKEN", "GH_APP_ID")); + Map> creds = AgentCompiler.collectToolCredentials(config); + assertThat(creds.get("gh")).containsExactlyInAnyOrder("GITHUB_TOKEN", "GH_APP_ID"); + } + + // ── Agent-level creds feed the non-worker user-code task defs (guardrail/callback/etc.) ── + + @Test + void collectAgentCredentials_returnsDedupedOrdered() { + AgentConfig config = AgentConfig.builder() + .name("a") + .model("openai/gpt-4o") + .credentials(List.of("A", "B", "A")) + .build(); + assertThat(AgentCompiler.collectAgentCredentials(config)).containsExactly("A", "B"); + } + + @Test + void collectAgentCredentials_emptyWhenNoneDeclared() { + AgentConfig config = + AgentConfig.builder().name("a").model("openai/gpt-4o").build(); + assertThat(AgentCompiler.collectAgentCredentials(config)).isEmpty(); + } + + /** + * Wiring test: embedded, {@code collectAndRegisterTasks} must declare the agent-level creds on a + * custom-guardrail worker's {@link TaskDef} (user code → needs secrets), but leave the declarative + * {@code _termination} def empty (no user function runs there). Fails until agent-level creds are + * threaded into the guardrail registration site. + */ + @Test + void embedded_declaresAgentCredsOnGuardrailButNotTermination() throws Exception { + new EmbeddedMode().setEmbedded(true); + AgentConfig config = AgentConfig.builder() + .name("a") + .model("openai/gpt-4o") + .credentials(List.of("DEMO_SECRET")) + .guardrails(List.of(GuardrailConfig.builder() + .guardrailType("custom") + .taskName("a_guard") + .build())) + .termination(TerminationConfig.builder().build()) + .build(); + + Map defs = registerAllTaskDefs(config); + + assertThat(defs.get("a_guard").getRuntimeMetadata()).containsExactly("DEMO_SECRET"); + assertThat(defs.get("a_termination").getRuntimeMetadata()).isNullOrEmpty(); + } + + @Test + void standalone_leavesNonWorkerRuntimeMetadataEmpty() throws Exception { + new EmbeddedMode().setEmbedded(false); + AgentConfig config = AgentConfig.builder() + .name("a") + .model("openai/gpt-4o") + .credentials(List.of("DEMO_SECRET")) + .guardrails(List.of(GuardrailConfig.builder() + .guardrailType("custom") + .taskName("a_guard") + .build())) + .build(); + + Map defs = registerAllTaskDefs(config); + + assertThat(defs.get("a_guard").getRuntimeMetadata()).isNullOrEmpty(); + } + + /** + * Drive {@link AgentService}'s private {@code registerTaskDefinitions(AgentConfig)} and return every + * {@link TaskDef} handed to {@code MetadataService.registerTaskDef}, keyed by task name — so a test + * can assert per-task-kind {@code runtimeMetadata}. + */ + private static Map registerAllTaskDefs(AgentConfig config) throws Exception { + MetadataDAO metadataDAO = mock(MetadataDAO.class); + MetadataService metadataService = mock(MetadataService.class); + + AgentService service = new AgentService( + mock(dev.agentspan.runtime.compiler.AgentCompiler.class), + mock(dev.agentspan.runtime.normalizer.NormalizerRegistry.class), + mock(com.netflix.conductor.dao.ExecutionDAO.class), + metadataDAO, + mock(com.netflix.conductor.core.execution.WorkflowExecutor.class), + mock(com.netflix.conductor.service.WorkflowService.class), + mock(dev.agentspan.runtime.service.AgentStreamRegistry.class), + mock(com.netflix.conductor.service.ExecutionService.class), + mock(dev.agentspan.runtime.util.ProviderValidator.class)); + + Field msField = AgentService.class.getDeclaredField("metadataService"); + msField.setAccessible(true); + msField.set(service, metadataService); + + Method m = AgentService.class.getDeclaredMethod("registerTaskDefinitions", AgentConfig.class); + m.setAccessible(true); + m.invoke(service, config); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(metadataService, atLeastOnce()).registerTaskDef(captor.capture()); + Map byName = new HashMap<>(); + for (List batch : captor.getAllValues()) { + for (TaskDef def : batch) { + byName.put(def.getName(), def); + } + } + return byName; + } + + /** + * Drive {@link AgentService}'s private {@code registerTaskDef(String, List)} with the credential + * names {@link AgentCompiler#collectToolCredentials} yields for {@code toolName}, and capture the + * {@link TaskDef} handed to {@code MetadataService.registerTaskDef}. + */ + private static TaskDef registerWorkerTaskDef(String toolName, List creds) throws Exception { + MetadataDAO metadataDAO = mock(MetadataDAO.class); + MetadataService metadataService = mock(MetadataService.class); + when(metadataDAO.getTaskDef(toolName)).thenReturn(null); + + AgentService service = new AgentService( + mock(dev.agentspan.runtime.compiler.AgentCompiler.class), + mock(dev.agentspan.runtime.normalizer.NormalizerRegistry.class), + mock(com.netflix.conductor.dao.ExecutionDAO.class), + metadataDAO, + mock(com.netflix.conductor.core.execution.WorkflowExecutor.class), + mock(com.netflix.conductor.service.WorkflowService.class), + mock(dev.agentspan.runtime.service.AgentStreamRegistry.class), + mock(com.netflix.conductor.service.ExecutionService.class), + mock(dev.agentspan.runtime.util.ProviderValidator.class)); + + Field msField = AgentService.class.getDeclaredField("metadataService"); + msField.setAccessible(true); + msField.set(service, metadataService); + + Method m = AgentService.class.getDeclaredMethod("registerTaskDef", String.class, List.class); + m.setAccessible(true); + m.invoke(service, toolName, creds); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(metadataService).registerTaskDef(captor.capture()); + return captor.getValue().get(0); + } +}