From 0699bde4f8624832415974e0668d26bff8b19319 Mon Sep 17 00:00:00 2001
From: nicholascole
Date: Thu, 9 Jul 2026 20:53:05 -0700
Subject: [PATCH 1/5] feat(server): switch embedded worker secrets to
TaskDef.runtimeMetadata (target)
Replace the interim __resolved_credentials__ enrich-script stamping with the
target PR #1255 mechanism: when embedded, the worker's TaskDef declares its
secret names on runtimeMetadata; the host resolves them at the SIMPLE task's
own poll and injects values onto the wire-only Task.runtimeMetadata (never
persisted, no JS injection, resolved at the right task).
- AgentService.registerTaskDef(String, List) stamps runtimeMetadata
only when EmbeddedMode.isEmbedded() && names non-empty; standalone leaves it
empty (native execution-token pull still delivers).
- Worker-tool branch passes AgentCompiler.collectToolCredentials(config) names.
- Remove interim stamping: ToolCompiler workerCred helpers + enrich args,
JavaScriptBuilder workerCredJson params/vars/injection, setWorkerCreds calls
in AgentCompiler/MultiAgentCompiler.
- Pin conductorVersion to the local runtimemeta build (has TaskDef.runtimeMetadata).
- Replace ToolCompilerWorkerCredTest with WorkerRuntimeMetadataTest: asserts
runtimeMetadata declared when embedded, empty standalone, and the enrich
script no longer emits __resolved_credentials__ (validated fail-first).
System-task delivery unchanged (LLM keys via host AI integration; HTTP/MCP/
planner headers via ${workflow.secrets}).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
server/build.gradle | 10 +-
.../runtime/compiler/AgentCompiler.java | 10 +-
.../runtime/compiler/MultiAgentCompiler.java | 2 -
.../runtime/compiler/ToolCompiler.java | 64 +-------
.../runtime/service/AgentService.java | 18 ++-
.../runtime/util/JavaScriptBuilder.java | 10 +-
.../compiler/ToolCompilerWorkerCredTest.java | 126 ----------------
.../compiler/WorkerRuntimeMetadataTest.java | 139 ++++++++++++++++++
8 files changed, 169 insertions(+), 210 deletions(-)
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/server/build.gradle b/server/build.gradle
index ee3a3b9c..de1a31cc 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.3-runtimemeta-LOCAL'
lombokVersion = '1.18.42'
log4jVersion = '2.24.3'
sqliteJdbcVersion = '3.47.0.0'
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 52192239..513a364b 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) {
@@ -372,7 +372,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 +746,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 724e505d..4ebf6ffa 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
*/
@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 6bc8a38e..4fcd66fb 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 6a2bb937..ff1f7a3a 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 3906a87f..ba92767f 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 00000000..82ccbd72
--- /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));
+ }
+}