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
deleted file mode 100644
index e225df954..000000000
--- a/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/credentials/NoOpSecretOutputMasker.java
+++ /dev/null
@@ -1,31 +0,0 @@
-/*
- * Copyright (c) 2025 AgentSpan
- * Licensed under the MIT License.
- */
-package dev.agentspan.runtime.credentials;
-
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.stereotype.Service;
-
-import dev.agentspan.runtime.spi.SecretOutputMasker;
-
-/**
- * No-op {@link SecretOutputMasker} — the OSS / standalone default.
- *
- * OSS has no per-execution disclosure tracking ({@code credential_disclosures}),
- * so there is nothing to redact against: {@link #mask} returns the payload unchanged.
- *
- *
An embedding host (e.g. orkes-conductor) supplies a real implementation that
- * queries the disclosure log, fetches the current plaintext values from the secret
- * store, and redacts them from the response body via a Jackson-tree walk (so values
- * 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
- public String mask(String executionId, String userId, String payload) {
- return payload;
- }
-}
diff --git a/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/service/skill/FileSystemSkillMetadataDAO.java b/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/service/skill/FileSystemSkillMetadataDAO.java
index 711dce57c..4e4a2f9f1 100644
--- a/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/service/skill/FileSystemSkillMetadataDAO.java
+++ b/server/conductor-agentspan-server/src/main/java/dev/agentspan/runtime/service/skill/FileSystemSkillMetadataDAO.java
@@ -25,11 +25,11 @@
/**
* Default {@link SkillMetadataDAO} — stores skill metadata as {@code metadata.json} files on the
- * local filesystem under {@code /owners////}, with a per-skill
- * {@code latest} pointer file. This is the standalone-server default; it preserves the exact
- * on-disk layout used before the SPI extraction. Embedding hosts (e.g. orkes-conductor) supply a
- * durable/HA implementation instead (this class ships only in {@code conductor-agentspan-server},
- * so it is never on an embedding host's classpath).
+ * local filesystem under {@code ///}, with a per-skill {@code latest}
+ * pointer file. This is the standalone-server default; skills are global (no per-caller
+ * scoping). Embedding hosts (e.g. orkes-conductor) supply a durable/HA implementation instead
+ * (this class ships only in {@code conductor-agentspan-server}, so it is never on an embedding
+ * host's classpath).
*/
@Component
public class FileSystemSkillMetadataDAO implements SkillMetadataDAO {
@@ -45,13 +45,12 @@ public FileSystemSkillMetadataDAO(
@Override
public void save(SkillDetail detail, boolean makeLatest) {
- Path metadataPath = metadataPath(detail.getOwnerId(), detail.getName(), detail.getVersion());
+ Path metadataPath = metadataPath(detail.getName(), detail.getVersion());
try {
Files.createDirectories(metadataPath.getParent());
writeDetail(metadataPath, detail);
if (makeLatest) {
- Files.writeString(
- latestPath(detail.getOwnerId(), detail.getName()), detail.getVersion(), StandardCharsets.UTF_8);
+ Files.writeString(latestPath(detail.getName()), detail.getVersion(), StandardCharsets.UTF_8);
}
} catch (IOException e) {
throw new IllegalStateException("Failed to write skill metadata: " + e.getMessage(), e);
@@ -59,8 +58,8 @@ public void save(SkillDetail detail, boolean makeLatest) {
}
@Override
- public Optional find(String ownerId, String name, String version) {
- Path metadataPath = metadataPath(ownerId, name, version);
+ public Optional find(String name, String version) {
+ Path metadataPath = metadataPath(name, version);
if (!Files.exists(metadataPath)) {
return Optional.empty();
}
@@ -68,8 +67,8 @@ public Optional find(String ownerId, String name, String version) {
}
@Override
- public Optional latestVersion(String ownerId, String name) {
- Path latest = latestPath(ownerId, name);
+ public Optional latestVersion(String name) {
+ Path latest = latestPath(name);
if (!Files.exists(latest)) {
return Optional.empty();
}
@@ -81,8 +80,8 @@ public Optional latestVersion(String ownerId, String name) {
}
@Override
- public List listVersions(String ownerId, String name) {
- Path skillRoot = skillRoot(ownerId, name);
+ public List listVersions(String name) {
+ Path skillRoot = skillRoot(name);
List details = new ArrayList<>();
if (!Files.isDirectory(skillRoot)) {
return details;
@@ -101,13 +100,12 @@ public List listVersions(String ownerId, String name) {
}
@Override
- public List list(String ownerId, boolean allVersions) {
- Path ownerRoot = ownerRoot(ownerId);
+ public List list(boolean allVersions) {
List details = new ArrayList<>();
- if (!Files.isDirectory(ownerRoot)) {
+ if (!Files.isDirectory(storageRoot)) {
return details;
}
- try (var skillDirs = Files.list(ownerRoot)) {
+ try (var skillDirs = Files.list(storageRoot)) {
for (Path skillDir : skillDirs.filter(Files::isDirectory).toList()) {
if (allVersions) {
try (var versions = Files.list(skillDir)) {
@@ -138,8 +136,8 @@ public List list(String ownerId, boolean allVersions) {
}
@Override
- public void delete(String ownerId, String name, String version) {
- Path dir = versionDir(ownerId, name, version);
+ public void delete(String name, String version) {
+ Path dir = versionDir(name, version);
if (!Files.exists(dir)) {
return;
}
@@ -147,26 +145,26 @@ public void delete(String ownerId, String name, String version) {
for (Path p : paths.sorted(Comparator.reverseOrder()).toList()) {
Files.deleteIfExists(p);
}
- Path latest = latestPath(ownerId, name);
+ Path latest = latestPath(name);
if (Files.exists(latest)
&& version.equals(
Files.readString(latest, StandardCharsets.UTF_8).trim())) {
- updateLatestAfterDelete(ownerId, name);
+ updateLatestAfterDelete(name);
}
} catch (IOException e) {
throw new IllegalStateException("Failed to delete skill metadata: " + e.getMessage(), e);
}
}
- private void updateLatestAfterDelete(String ownerId, String name) throws IOException {
- Path skillRoot = skillRoot(ownerId, name);
+ private void updateLatestAfterDelete(String name) throws IOException {
+ Path skillRoot = skillRoot(name);
if (!Files.isDirectory(skillRoot)) {
- Files.deleteIfExists(latestPath(ownerId, name));
+ Files.deleteIfExists(latestPath(name));
return;
}
- List remaining = listVersions(ownerId, name);
+ List remaining = listVersions(name);
if (remaining.isEmpty()) {
- Files.deleteIfExists(latestPath(ownerId, name));
+ Files.deleteIfExists(latestPath(name));
try (var children = Files.list(skillRoot)) {
if (children.findAny().isEmpty()) {
Files.deleteIfExists(skillRoot);
@@ -176,8 +174,7 @@ private void updateLatestAfterDelete(String ownerId, String name) throws IOExcep
}
remaining.sort(Comparator.comparing(SkillDetail::getCreatedAt, Comparator.nullsFirst(Long::compareTo))
.thenComparing(SkillDetail::getVersion));
- Files.writeString(
- latestPath(ownerId, name), remaining.get(remaining.size() - 1).getVersion(), StandardCharsets.UTF_8);
+ Files.writeString(latestPath(name), remaining.get(remaining.size() - 1).getVersion(), StandardCharsets.UTF_8);
}
private SkillDetail readDetail(Path metadataPath) {
@@ -196,24 +193,20 @@ private void writeDetail(Path metadataPath, SkillDetail detail) {
}
}
- private Path ownerRoot(String ownerId) {
- return storageRoot.resolve("owners").resolve(encoded(ownerId));
+ private Path skillRoot(String name) {
+ return storageRoot.resolve(encoded(name));
}
- private Path skillRoot(String ownerId, String name) {
- return ownerRoot(ownerId).resolve(encoded(name));
+ private Path versionDir(String name, String version) {
+ return skillRoot(name).resolve(encoded(version));
}
- private Path versionDir(String ownerId, String name, String version) {
- return skillRoot(ownerId, name).resolve(encoded(version));
+ private Path metadataPath(String name, String version) {
+ return versionDir(name, version).resolve("metadata.json");
}
- private Path metadataPath(String ownerId, String name, String version) {
- return versionDir(ownerId, name, version).resolve("metadata.json");
- }
-
- private Path latestPath(String ownerId, String name) {
- return skillRoot(ownerId, name).resolve("latest");
+ private Path latestPath(String name) {
+ return skillRoot(name).resolve("latest");
}
private String encoded(String value) {
diff --git a/server/conductor-agentspan-server/src/main/resources/application.properties b/server/conductor-agentspan-server/src/main/resources/application.properties
index 9d3a3b3a4..48497be1d 100644
--- a/server/conductor-agentspan-server/src/main/resources/application.properties
+++ b/server/conductor-agentspan-server/src/main/resources/application.properties
@@ -39,6 +39,9 @@ conductor.workflow-message-queue.maxBatchSize=100
conductor.task-status-listener.type=agent
conductor.workflow-status-listener.type=agent
+conductor.app.taskWorkerConfigs.HTTP.threadCount=20
+conductor.app.taskWorkerConfigs.LLM_CHAT_COMPLETE.threadCount=20
+
# Relaxed validation
conductor.app.workflow.name-validation.enabled=false
conductor.app.ownerEmailMandatory=false
@@ -159,10 +162,8 @@ agentspan.skills.max-file-count=${AGENTSPAN_SKILLS_MAX_FILE_COUNT:2000}
# host (orkes-conductor / conductor-oss): those beans are DORMANT and the host
# delivers secrets — worker tools via TaskDef.runtimeMetadata, system tasks via
# ${workflow.secrets.NAME}.
-agentspan.embedded=false
+agentspan.embedded=true
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
@@ -174,11 +175,6 @@ agentspan.credentials.resolve.rate-limit=120
# (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.
-# agentspan.credentials.mask-workflow-reads=false
-
# AGENTSPAN_MASTER_KEY: base64-encoded 256-bit key for AES-256-GCM.
# Unset + localhost → auto-generated and warned.
# Unset + non-localhost → server refuses to start.
diff --git a/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/controller/CredentialMaskingIntegrationTest.java b/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/controller/CredentialMaskingIntegrationTest.java
deleted file mode 100644
index 92d72aed0..000000000
--- a/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/controller/CredentialMaskingIntegrationTest.java
+++ /dev/null
@@ -1,107 +0,0 @@
-/*
- * Copyright (c) 2025 AgentSpan
- * Licensed under the MIT License.
- */
-package dev.agentspan.runtime.controller;
-
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.when;
-import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
-
-import java.util.Map;
-
-import org.hamcrest.Matchers;
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
-import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.boot.test.mock.mockito.MockBean;
-import org.springframework.test.context.ActiveProfiles;
-import org.springframework.test.web.servlet.MockMvc;
-
-import dev.agentspan.runtime.AgentRuntime;
-import dev.agentspan.runtime.model.AgentExecutionDetail;
-import dev.agentspan.runtime.service.AgentService;
-import dev.agentspan.runtime.spi.CredentialStoreProvider;
-
-/**
- * Verifies that {@link CredentialMaskingResponseAdvice} activates on the right
- * URI patterns and leaves the payload unchanged in OSS (no-op masker — disclosure
- * tracking is an enterprise feature).
- *
- * Masking-correctness tests (redaction, tree-walk, JSON-escape handling) live
- * in the enterprise module where the real {@link dev.agentspan.runtime.credentials.NoOpSecretOutputMasker}
- * implementation is provided.
- */
-@SpringBootTest(classes = AgentRuntime.class)
-@AutoConfigureMockMvc
-@ActiveProfiles("test")
-class CredentialMaskingIntegrationTest {
-
- @Autowired
- private MockMvc mvc;
-
- @Autowired
- private CredentialStoreProvider store;
-
- @MockBean
- private AgentService agentService;
-
- private static final String CRED_NAME = "_MASK_E2E_TOKEN";
- private static final String CRED_VALUE = "ghp_thisisasecretthatshouldbemasked";
- private static final String EXEC_ID = "exec-mask-e2e-001";
- private static final String userId = "00000000-0000-0000-0000-000000000000";
-
- @BeforeEach
- void setUp() {
- store.set(CRED_NAME, CRED_VALUE);
- }
-
- @AfterEach
- void cleanUp() {
- store.delete(CRED_NAME);
- }
-
- // ── Advice URI coverage ─────────────────────────────────────────────
-
- @Test
- void executionDetail_adviceActivates_passesThrough() throws Exception {
- // In OSS the masker is a no-op: value passes through unchanged.
- // This test confirms the advice activates on /api/agent/executions/{id}
- // without throwing and without blocking the response.
- AgentExecutionDetail detail = AgentExecutionDetail.builder()
- .executionId(EXEC_ID)
- .agentName("test-agent")
- .status("COMPLETED")
- .output(Map.of("result", "token is " + CRED_VALUE))
- .build();
- when(agentService.getExecutionDetail(eq(EXEC_ID))).thenReturn(detail);
-
- mvc.perform(get("/api/agent/executions/" + EXEC_ID))
- .andExpect(status().isOk())
- // OSS no-op: value is still present (masking is enterprise)
- .andExpect(content().string(Matchers.containsString(CRED_VALUE)));
- }
-
- @Test
- void statusEndpoint_adviceActivates_passesThrough() throws Exception {
- when(agentService.getStatus(eq(EXEC_ID)))
- .thenReturn(Map.of("executionId", EXEC_ID, "status", "COMPLETED", "note", "token=" + CRED_VALUE));
-
- mvc.perform(get("/api/agent/" + EXEC_ID + "/status"))
- .andExpect(status().isOk())
- .andExpect(content().string(Matchers.containsString(CRED_VALUE)));
- }
-
- @Test
- void getCredential_endpointNotIntercepted() throws Exception {
- // /api/secrets/{key} is the CRUD endpoint — the advice must NOT
- // intercept it regardless of what credentials are in the store.
- mvc.perform(get("/api/secrets/" + CRED_NAME))
- .andExpect(status().isOk())
- .andExpect(content().string(CRED_VALUE));
- }
-}
diff --git a/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/controller/CredentialMaskingWorkflowOptInTest.java b/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/controller/CredentialMaskingWorkflowOptInTest.java
deleted file mode 100644
index 4f280a891..000000000
--- a/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/controller/CredentialMaskingWorkflowOptInTest.java
+++ /dev/null
@@ -1,104 +0,0 @@
-/*
- * Copyright (c) 2025 AgentSpan
- * Licensed under the MIT License.
- */
-package dev.agentspan.runtime.controller;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-import java.net.URI;
-import java.time.Instant;
-import java.util.Map;
-import java.util.UUID;
-
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.Test;
-import org.springframework.http.MediaType;
-import org.springframework.http.server.ServerHttpRequest;
-import org.springframework.http.server.ServerHttpResponse;
-
-import com.fasterxml.jackson.databind.ObjectMapper;
-
-import dev.agentspan.runtime.context.RequestContext;
-import dev.agentspan.runtime.context.RequestContextHolder;
-import dev.agentspan.runtime.spi.SecretOutputMasker;
-
-/**
- * Verifies that {@link CredentialMaskingResponseAdvice} only intercepts the host-owned
- * {@code /api/workflow/{id}} endpoint when explicitly opted in
- * ({@code agentspan.credentials.mask-workflow-reads=true}), while AgentSpan's own
- * {@code /api/agent/*} reads are always masked. This guards against the library mutating
- * an embedding host's raw Conductor responses just by being on the classpath.
- */
-class CredentialMaskingWorkflowOptInTest {
-
- private static final String USER_ID = "00000000-0000-0000-0000-000000000000";
-
- private final SecretOutputMasker masker = mock(SecretOutputMasker.class);
- private final ObjectMapper mapper = new ObjectMapper();
-
- @AfterEach
- void clearContext() {
- RequestContextHolder.clear();
- }
-
- private CredentialMaskingResponseAdvice advice(boolean maskWorkflowReads) {
- // Masker echoes the payload back (no-op) so the advice's return value is the parsed body.
- when(masker.mask(any(), any(), any())).thenAnswer(inv -> inv.getArgument(2));
- return new CredentialMaskingResponseAdvice(masker, mapper, maskWorkflowReads);
- }
-
- private void setUser() {
- RequestContextHolder.set(RequestContext.builder()
- .requestId(UUID.randomUUID().toString())
- .userId(USER_ID)
- .createdAt(Instant.now())
- .build());
- }
-
- private Object invoke(CredentialMaskingResponseAdvice advice, String path, Object body) {
- ServerHttpRequest request = mock(ServerHttpRequest.class);
- when(request.getURI()).thenReturn(URI.create("http://localhost:6767" + path));
- ServerHttpResponse response = mock(ServerHttpResponse.class);
- return advice.beforeBodyWrite(body, null, MediaType.APPLICATION_JSON, null, request, response);
- }
-
- @Test
- void workflowRead_notMasked_whenOptInDisabled() {
- setUser();
- Object body = Map.of("workflowId", "wf-1", "output", "secret-value");
-
- Object result = invoke(advice(false), "/api/workflow/wf-1", body);
-
- // Host endpoint must be left completely untouched — same instance, masker never consulted.
- assertThat(result).isSameAs(body);
- verify(masker, never()).mask(any(), any(), any());
- }
-
- @Test
- void workflowRead_masked_whenOptInEnabled() {
- setUser();
- Object body = Map.of("workflowId", "wf-1", "output", "secret-value");
-
- invoke(advice(true), "/api/workflow/wf-1", body);
-
- verify(masker).mask(eq("wf-1"), eq(USER_ID), any());
- }
-
- @Test
- void agentExecutionRead_alwaysMasked_evenWhenWorkflowOptInDisabled() {
- setUser();
- Object body = Map.of("executionId", "exec-1", "output", "secret-value");
-
- invoke(advice(false), "/api/agent/executions/exec-1", body);
-
- // AgentSpan owns /api/agent/* — masking there is unaffected by the workflow opt-in.
- verify(masker).mask(eq("exec-1"), eq(USER_ID), any());
- }
-}
diff --git a/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/controller/ProviderStatusEndpointTest.java b/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/controller/ProviderStatusEndpointTest.java
index f5d5d61ec..e4f0dd152 100644
--- a/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/controller/ProviderStatusEndpointTest.java
+++ b/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/controller/ProviderStatusEndpointTest.java
@@ -22,9 +22,9 @@
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.netflix.conductor.dao.SecretsDAO;
import dev.agentspan.runtime.AgentRuntime;
-import dev.agentspan.runtime.spi.CredentialStoreProvider;
/**
* GET /api/providers/status — the server is the source of truth for provider
@@ -46,20 +46,20 @@ class ProviderStatusEndpointTest {
private int port;
@Autowired
- private CredentialStoreProvider store;
+ private SecretsDAO store;
private String savedOllamaUrl;
@BeforeEach
void setUp() {
- savedOllamaUrl = store.get("OLLAMA_BASE_URL");
- store.set("OLLAMA_BASE_URL", UNREACHABLE_URL);
+ savedOllamaUrl = store.getSecret("OLLAMA_BASE_URL");
+ store.putSecret("OLLAMA_BASE_URL", UNREACHABLE_URL);
}
@AfterEach
void cleanUp() {
- store.delete("OLLAMA_BASE_URL");
- if (savedOllamaUrl != null) store.set("OLLAMA_BASE_URL", savedOllamaUrl);
+ store.deleteSecret("OLLAMA_BASE_URL");
+ if (savedOllamaUrl != null) store.putSecret("OLLAMA_BASE_URL", savedOllamaUrl);
}
private JsonNode getStatus() throws Exception {
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
index 33f7c59b3..78d1902a6 100644
--- 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
@@ -7,74 +7,118 @@
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.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+import org.springframework.test.context.ActiveProfiles;
+import dev.agentspan.runtime.AgentRuntime;
import dev.agentspan.runtime.model.credentials.CredentialMeta;
-import dev.agentspan.runtime.spi.CredentialStoreProvider;
/**
- * {@link AgentspanSecretsDAO} bridges conductor's global {@code SecretsDAO} to AgentSpan's
- * (single-scope) {@link CredentialStoreProvider}. Verifies the name→value round-trip and that the
- * bean is selected only by {@code conductor.secrets.type=agentspan}.
+ * {@link AgentspanSecretsDAO} is conductor's global {@code SecretsDAO} backed directly by the
+ * AES-256-GCM encrypted {@code credentials_store} table — the single storage backend for
+ * conductor's own secret substitution AND AgentSpan's own credential surfaces (Credentials UI,
+ * resolution service, env seeder). Verifies the encrypted round-trip against a real DB and that
+ * the bean is selected only by {@code conductor.secrets.type=agentspan}.
*/
+@SpringBootTest(classes = AgentRuntime.class, webEnvironment = SpringBootTest.WebEnvironment.NONE)
+@ActiveProfiles("test")
class AgentspanSecretsDAOTest {
- /** In-memory {@link CredentialStoreProvider} keyed by name (the store is global — no userId). */
- static class FakeStore implements CredentialStoreProvider {
- final Map data = new LinkedHashMap<>();
-
- @Override
- public String get(String name) {
- return data.get(name);
- }
-
- @Override
- public void set(String name, String value) {
- data.put(name, value);
- }
-
- @Override
- public void delete(String name) {
- data.remove(name);
- }
-
- @Override
- public List list() {
- List out = new ArrayList<>();
- for (String name : data.keySet()) {
- out.add(CredentialMeta.builder().name(name).build());
- }
- return out;
- }
+ @Autowired
+ private AgentspanSecretsDAO dao;
+
+ @Autowired
+ @Qualifier("credentialJdbc")
+ private NamedParameterJdbcTemplate jdbc;
+
+ private static final String USER_ID = "00000000-0000-0000-0000-000000000000";
+
+ @BeforeEach
+ void setUp() {
+ jdbc.update("DELETE FROM credentials_store WHERE user_id = :uid", Map.of("uid", USER_ID));
}
@Test
- void roundTrip() {
- FakeStore store = new FakeStore();
- AgentspanSecretsDAO dao = new AgentspanSecretsDAO(store);
+ void putSecret_andGetSecret_roundTripsEncryptedValue() {
+ dao.putSecret("GITHUB_TOKEN", "ghp_supersecret");
+ assertThat(dao.getSecret("GITHUB_TOKEN")).isEqualTo("ghp_supersecret");
+ }
- assertThat(dao.secretExists("GITHUB_TOKEN")).isFalse();
- assertThat(dao.getSecret("GITHUB_TOKEN")).isNull();
+ @Test
+ void getSecret_returnsNull_whenNotFound() {
+ assertThat(dao.getSecret("DOES_NOT_EXIST")).isNull();
+ }
+ @Test
+ void secretExists_reflectsPresence() {
+ assertThat(dao.secretExists("GITHUB_TOKEN")).isFalse();
dao.putSecret("GITHUB_TOKEN", "ghp_x");
- assertThat(store.data).containsEntry("GITHUB_TOKEN", "ghp_x");
- assertThat(dao.getSecret("GITHUB_TOKEN")).isEqualTo("ghp_x");
assertThat(dao.secretExists("GITHUB_TOKEN")).isTrue();
+ }
+ @Test
+ void deleteSecret_removesCredential() {
+ dao.putSecret("TO_DELETE", "value");
+ dao.deleteSecret("TO_DELETE");
+ assertThat(dao.getSecret("TO_DELETE")).isNull();
+ }
+
+ @Test
+ void listSecretNames_returnsAllNames() {
+ dao.putSecret("GITHUB_TOKEN", "ghp_x");
dao.putSecret("SLACK_TOKEN", "xoxb");
+
assertThat(dao.listSecretNames()).containsExactlyInAnyOrder("GITHUB_TOKEN", "SLACK_TOKEN");
+ }
+
+ @Test
+ void listWithMeta_returnsPartialValues_notPlaintext() {
+ dao.putSecret("OPENAI_KEY", "sk-abcdefghijklmnop");
+
+ List list = dao.listWithMeta();
+
+ CredentialMeta meta = list.stream()
+ .filter(m -> m.getName().equals("OPENAI_KEY"))
+ .findFirst()
+ .orElseThrow();
+
+ // Partial: first 4 + ... + last 4
+ assertThat(meta.getPartial()).isEqualTo("sk-a...mnop");
+ assertThat(meta.getUpdatedAt()).isNotNull();
+ // Plaintext is NOT in the list response
+ assertThat(meta.toString()).doesNotContain("abcdefghijklmnop");
+ }
+
+ @Test
+ void putSecret_updatesExistingCredential() {
+ dao.putSecret("MY_KEY", "original");
+ dao.putSecret("MY_KEY", "updated");
+ assertThat(dao.getSecret("MY_KEY")).isEqualTo("updated");
+ }
+
+ @Test
+ void encryptedValueInDb_isNotPlaintext() {
+ dao.putSecret("SECRET", "plaintext_value");
+
+ // Read raw bytes from DB
+ byte[] raw = jdbc.queryForObject(
+ "SELECT encrypted_value FROM credentials_store WHERE user_id=:uid AND name=:n",
+ Map.of("uid", USER_ID, "n", "SECRET"),
+ byte[].class);
- dao.deleteSecret("GITHUB_TOKEN");
- assertThat(dao.getSecret("GITHUB_TOKEN")).isNull();
- assertThat(dao.listSecretNames()).containsExactly("SLACK_TOKEN");
+ assertThat(raw).isNotNull();
+ assertThat(new String(raw)).doesNotContain("plaintext_value");
}
// ── gating: selected only by conductor.secrets.type=agentspan ──
@@ -83,20 +127,21 @@ void roundTrip() {
@Import(AgentspanSecretsDAO.class)
static class DaoConfig {}
- private final ApplicationContextRunner runner = new ApplicationContextRunner()
- .withBean(CredentialStoreProvider.class, () -> mock(CredentialStoreProvider.class))
+ private final ApplicationContextRunner gatingRunner = new ApplicationContextRunner()
+ .withBean("credentialJdbc", NamedParameterJdbcTemplate.class, () -> mock(NamedParameterJdbcTemplate.class))
+ .withBean("credentialMasterKey", byte[].class, () -> new byte[32])
.withUserConfiguration(DaoConfig.class);
@Test
void beanPresent_whenConductorSecretsTypeAgentspan() {
- runner.withPropertyValues("conductor.secrets.type=agentspan")
- .run(ctx -> assertThat(ctx).hasSingleBean(AgentspanSecretsDAO.class));
+ gatingRunner.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));
+ gatingRunner.run(ctx -> assertThat(ctx).doesNotHaveBean(AgentspanSecretsDAO.class));
+ gatingRunner.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/credentials/ConcurrentPutRaceTest.java b/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/credentials/ConcurrentPutRaceTest.java
index eac75d02c..da8df6794 100644
--- a/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/credentials/ConcurrentPutRaceTest.java
+++ b/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/credentials/ConcurrentPutRaceTest.java
@@ -16,8 +16,9 @@
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
+import com.netflix.conductor.dao.SecretsDAO;
+
import dev.agentspan.runtime.AgentRuntime;
-import dev.agentspan.runtime.spi.CredentialStoreProvider;
/**
* Regression test for Bug #3 — concurrent PUT on the same (user, name) was
@@ -39,19 +40,19 @@
class ConcurrentPutRaceTest {
@Autowired
- private CredentialStoreProvider store;
+ private SecretsDAO store;
private static final String USER = "race-test-user";
private static final String NAME = "_RACE_TEST_KEY";
@BeforeEach
void clean() {
- store.delete(NAME);
+ store.deleteSecret(NAME);
}
@AfterEach
void clean2() {
- store.delete(NAME);
+ store.deleteSecret(NAME);
}
@Test
@@ -63,7 +64,7 @@ void concurrentPut_newCredential_doesNotThrow() throws Exception {
final int idx = i;
futures[i] = CompletableFuture.runAsync(() -> {
try {
- store.set(NAME, "value-" + idx);
+ store.putSecret(NAME, "value-" + idx);
} catch (Throwable t) {
errors.incrementAndGet();
}
@@ -77,12 +78,12 @@ void concurrentPut_newCredential_doesNotThrow() throws Exception {
.isZero();
// And the value is set (last writer wins; we just assert SOME write succeeded).
- assertThat(store.get(NAME)).isNotNull();
+ assertThat(store.getSecret(NAME)).isNotNull();
}
@Test
void concurrentPut_existingCredential_doesNotThrow() throws Exception {
- store.set(NAME, "initial-value");
+ store.putSecret(NAME, "initial-value");
int N = 50;
AtomicInteger errors = new AtomicInteger();
@@ -91,7 +92,7 @@ void concurrentPut_existingCredential_doesNotThrow() throws Exception {
final int idx = i;
futures[i] = CompletableFuture.runAsync(() -> {
try {
- store.set(NAME, "updated-value-" + idx);
+ store.putSecret(NAME, "updated-value-" + idx);
} catch (Throwable t) {
errors.incrementAndGet();
}
@@ -100,6 +101,6 @@ void concurrentPut_existingCredential_doesNotThrow() throws Exception {
for (var f : futures) f.get();
assertThat(errors.get()).isZero();
- assertThat(store.get(NAME)).startsWith("updated-value-");
+ assertThat(store.getSecret(NAME)).startsWith("updated-value-");
}
}
diff --git a/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/credentials/CredentialEnvSeederIntegrationTest.java b/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/credentials/CredentialEnvSeederIntegrationTest.java
index b1c30d515..a5cce897e 100644
--- a/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/credentials/CredentialEnvSeederIntegrationTest.java
+++ b/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/credentials/CredentialEnvSeederIntegrationTest.java
@@ -11,8 +11,9 @@
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
+import com.netflix.conductor.dao.SecretsDAO;
+
import dev.agentspan.runtime.AgentRuntime;
-import dev.agentspan.runtime.spi.CredentialStoreProvider;
/**
* Integration test that verifies CredentialEnvSeeder can store credentials
@@ -26,18 +27,18 @@
class CredentialEnvSeederIntegrationTest {
@Autowired
- private CredentialStoreProvider storeProvider;
+ private SecretsDAO storeProvider;
@Test
void seeder_storesCredential_withoutForeignKeyError() {
// The seeder runs at startup. If ANTHROPIC_API_KEY is in the env
// (or test properties), the credential should exist.
// At minimum, verify we can write and read back without errors.
- storeProvider.set("INTEGRATION_TEST_KEY", "test-value-123");
- String value = storeProvider.get("INTEGRATION_TEST_KEY");
+ storeProvider.putSecret("INTEGRATION_TEST_KEY", "test-value-123");
+ String value = storeProvider.getSecret("INTEGRATION_TEST_KEY");
assertThat(value).isEqualTo("test-value-123");
// Cleanup
- storeProvider.delete("INTEGRATION_TEST_KEY");
+ storeProvider.deleteSecret("INTEGRATION_TEST_KEY");
}
}
diff --git a/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/credentials/CredentialEnvSeederTest.java b/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/credentials/CredentialEnvSeederTest.java
index 231de59f6..17e6e6e35 100644
--- a/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/credentials/CredentialEnvSeederTest.java
+++ b/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/credentials/CredentialEnvSeederTest.java
@@ -17,8 +17,9 @@
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
+import com.netflix.conductor.dao.SecretsDAO;
+
import dev.agentspan.runtime.AgentRuntime;
-import dev.agentspan.runtime.spi.CredentialStoreProvider;
/**
* Integration test for CredentialEnvSeeder — uses real DB, no mocks.
@@ -29,7 +30,7 @@
class CredentialEnvSeederTest {
@Autowired
- private CredentialStoreProvider storeProvider;
+ private SecretsDAO storeProvider;
@Autowired
@Qualifier("credentialJdbc")
@@ -48,10 +49,10 @@ void cleanUp() {
} catch (Exception ignored) {
// Table may not exist yet on the first test run — safe to ignore.
}
- storeProvider.delete("GH_TOKEN");
- storeProvider.delete("GITHUB_TOKEN");
- storeProvider.delete("OPENAI_BASE_URL");
- storeProvider.delete("ANTHROPIC_BASE_URL");
+ storeProvider.deleteSecret("GH_TOKEN");
+ storeProvider.deleteSecret("GITHUB_TOKEN");
+ storeProvider.deleteSecret("OPENAI_BASE_URL");
+ storeProvider.deleteSecret("ANTHROPIC_BASE_URL");
}
@Test
@@ -74,19 +75,19 @@ void seeder_storesCredentialFromEnv_inRealDb() throws Exception {
field.set(realSeeder, "built-in");
// Delete existing credential first so seeder can create it
- storeProvider.delete("ANTHROPIC_API_KEY");
+ storeProvider.deleteSecret("ANTHROPIC_API_KEY");
realSeeder.run(new org.springframework.boot.DefaultApplicationArguments());
// Verify credential was stored in real DB
- String value = storeProvider.get("ANTHROPIC_API_KEY");
+ String value = storeProvider.getSecret("ANTHROPIC_API_KEY");
assertThat(value).isEqualTo("sk-test-seeded-value");
}
@Test
void seeder_skipsExistingCredential_inRealDb() throws Exception {
// Store a credential first
- storeProvider.set("ANTHROPIC_API_KEY", "original-value");
+ storeProvider.putSecret("ANTHROPIC_API_KEY", "original-value");
// Try to seed with a different value
Function envLookup =
@@ -100,14 +101,14 @@ void seeder_skipsExistingCredential_inRealDb() throws Exception {
seeder.run(new org.springframework.boot.DefaultApplicationArguments());
// Value should still be the original
- String value = storeProvider.get("ANTHROPIC_API_KEY");
+ String value = storeProvider.getSecret("ANTHROPIC_API_KEY");
assertThat(value).isEqualTo("original-value");
}
@Test
void seeder_ignoresBlankEnvVars_inRealDb() throws Exception {
// Delete so we can detect if seeder creates it
- storeProvider.delete("ANTHROPIC_API_KEY");
+ storeProvider.deleteSecret("ANTHROPIC_API_KEY");
Function envLookup = name -> "ANTHROPIC_API_KEY".equals(name) ? " " : null;
@@ -119,13 +120,13 @@ void seeder_ignoresBlankEnvVars_inRealDb() throws Exception {
seeder.run(new org.springframework.boot.DefaultApplicationArguments());
// Blank value should NOT be stored
- String value = storeProvider.get("ANTHROPIC_API_KEY");
+ String value = storeProvider.getSecret("ANTHROPIC_API_KEY");
assertThat(value).isNull();
}
@Test
void seeder_skipsWhenStoreIsNotBuiltIn() throws Exception {
- storeProvider.delete("ANTHROPIC_API_KEY");
+ storeProvider.deleteSecret("ANTHROPIC_API_KEY");
Function envLookup = name -> "ANTHROPIC_API_KEY".equals(name) ? "sk-should-not-store" : null;
@@ -136,7 +137,7 @@ void seeder_skipsWhenStoreIsNotBuiltIn() throws Exception {
seeder.run(new org.springframework.boot.DefaultApplicationArguments());
- String value = storeProvider.get("ANTHROPIC_API_KEY");
+ String value = storeProvider.getSecret("ANTHROPIC_API_KEY");
assertThat(value).isNull();
}
@@ -144,7 +145,7 @@ void seeder_skipsWhenStoreIsNotBuiltIn() throws Exception {
void seeder_reseeds_whenDecryptionFailsDueToKeyMismatch() throws Exception {
// Simulate a credential encrypted with an old/rotated master key by writing
// garbage bytes directly into the DB — decryption will throw AEADBadTagException.
- storeProvider.delete("ANTHROPIC_API_KEY");
+ storeProvider.deleteSecret("ANTHROPIC_API_KEY");
String now = java.time.Instant.now().toString();
// 12-byte fake IV + 17 bytes of garbage ciphertext → GCM tag mismatch on decrypt
byte[] staleBytes = new byte[29];
@@ -175,7 +176,7 @@ void seeder_reseeds_whenDecryptionFailsDueToKeyMismatch() throws Exception {
.doesNotThrowAnyException();
// Credential must be re-encrypted with the current key and readable
- String value = storeProvider.get("ANTHROPIC_API_KEY");
+ String value = storeProvider.getSecret("ANTHROPIC_API_KEY");
assertThat(value).isEqualTo("sk-fresh-after-rotation");
}
@@ -183,22 +184,27 @@ void seeder_reseeds_whenDecryptionFailsDueToKeyMismatch() throws Exception {
void seeder_propagates_nonDecryptionExceptions() throws Exception {
// A non-AEADBadTagException from get() must propagate — e.g. a transient DB failure
// should NOT silently delete a valid credential.
- CredentialStoreProvider failingStore = new CredentialStoreProvider() {
+ SecretsDAO failingStore = new SecretsDAO() {
@Override
- public String get(String name) {
+ public String getSecret(String name) {
throw new IllegalStateException("DB connection lost", new RuntimeException("timeout"));
}
@Override
- public void set(String name, String value) {}
-
- @Override
- public void delete(String name) {}
+ public boolean secretExists(String name) {
+ return false;
+ }
@Override
- public java.util.List list() {
+ public java.util.List listSecretNames() {
return java.util.List.of();
}
+
+ @Override
+ public void putSecret(String name, String value) {}
+
+ @Override
+ public void deleteSecret(String name) {}
};
Function envLookup = name -> "ANTHROPIC_API_KEY".equals(name) ? "sk-value" : null;
@@ -229,15 +235,15 @@ void seeder_storesBaseUrlVars_inRealDb() throws Exception {
seeder.run(new org.springframework.boot.DefaultApplicationArguments());
- assertThat(storeProvider.get("OPENAI_BASE_URL")).isEqualTo("https://my-proxy.org/v1");
- assertThat(storeProvider.get("ANTHROPIC_BASE_URL")).isEqualTo("https://anthropic-proxy.internal/v1");
+ assertThat(storeProvider.getSecret("OPENAI_BASE_URL")).isEqualTo("https://my-proxy.org/v1");
+ assertThat(storeProvider.getSecret("ANTHROPIC_BASE_URL")).isEqualTo("https://anthropic-proxy.internal/v1");
}
@Test
void seeder_seedsOllamaBaseUrl_inRealDb() throws Exception {
// OLLAMA_BASE_URL is the documented Ollama variable and what the
// provider resolves from the credential store — it must be seeded.
- storeProvider.delete("OLLAMA_BASE_URL");
+ storeProvider.deleteSecret("OLLAMA_BASE_URL");
Function envLookup = name -> "OLLAMA_BASE_URL".equals(name) ? "http://gpu-box:11434" : null;
@@ -248,7 +254,7 @@ void seeder_seedsOllamaBaseUrl_inRealDb() throws Exception {
seeder.run(new org.springframework.boot.DefaultApplicationArguments());
- assertThat(storeProvider.get("OLLAMA_BASE_URL")).isEqualTo("http://gpu-box:11434");
+ assertThat(storeProvider.getSecret("OLLAMA_BASE_URL")).isEqualTo("http://gpu-box:11434");
}
@Test
@@ -266,7 +272,7 @@ void seeder_storesGitHubCredentials_inRealDb() throws Exception {
seeder.run(new org.springframework.boot.DefaultApplicationArguments());
- assertThat(storeProvider.get("GH_TOKEN")).isEqualTo("ghp-test-gh-token");
- assertThat(storeProvider.get("GITHUB_TOKEN")).isEqualTo("ghp-test-github-token");
+ assertThat(storeProvider.getSecret("GH_TOKEN")).isEqualTo("ghp-test-gh-token");
+ assertThat(storeProvider.getSecret("GITHUB_TOKEN")).isEqualTo("ghp-test-github-token");
}
}
diff --git a/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/credentials/CredentialResolutionServiceTest.java b/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/credentials/CredentialResolutionServiceTest.java
index 2dd376de7..3557ca48e 100644
--- a/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/credentials/CredentialResolutionServiceTest.java
+++ b/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/credentials/CredentialResolutionServiceTest.java
@@ -16,8 +16,9 @@
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
+import com.netflix.conductor.dao.SecretsDAO;
+
import dev.agentspan.runtime.AgentRuntime;
-import dev.agentspan.runtime.spi.CredentialStoreProvider;
/**
* Integration test for CredentialResolutionService — real DB, real services, no mocks.
@@ -30,7 +31,7 @@ class CredentialResolutionServiceTest {
private CredentialResolutionService service;
@Autowired
- private CredentialStoreProvider storeProvider;
+ private SecretsDAO storeProvider;
@Autowired
@Qualifier("credentialJdbc")
@@ -45,7 +46,7 @@ void setUp() {
@Test
void resolve_directLookup_returnsStoredValue() {
- storeProvider.set("GITHUB_TOKEN", "ghp_directlookup");
+ storeProvider.putSecret("GITHUB_TOKEN", "ghp_directlookup");
String value = service.resolve("GITHUB_TOKEN");
@@ -70,10 +71,10 @@ void resolve_notInStore_noEnvFallback() {
@Test
void resolve_afterDelete_returnsNull() {
- storeProvider.set("TEMP_KEY", "temp_value");
+ storeProvider.putSecret("TEMP_KEY", "temp_value");
assertThat(service.resolve("TEMP_KEY")).isEqualTo("temp_value");
- storeProvider.delete("TEMP_KEY");
+ storeProvider.deleteSecret("TEMP_KEY");
assertThat(service.resolve("TEMP_KEY")).isNull();
}
@@ -81,7 +82,7 @@ void resolve_afterDelete_returnsNull() {
@Test
void resolve_dottedPath_extractsTopLevelField() {
- storeProvider.set(
+ storeProvider.putSecret(
"GCP_SVC",
"{\"type\":\"service_account\",\"project_id\":\"my-proj-123\",\"client_email\":\"sa@x.iam\"}");
@@ -91,14 +92,14 @@ void resolve_dottedPath_extractsTopLevelField() {
@Test
void resolve_dottedPath_extractsNestedField() {
- storeProvider.set("BLOB", "{\"auth\":{\"oauth\":{\"client_id\":\"abc123\"}}}");
+ storeProvider.putSecret("BLOB", "{\"auth\":{\"oauth\":{\"client_id\":\"abc123\"}}}");
assertThat(service.resolve("BLOB.auth.oauth.client_id")).isEqualTo("abc123");
}
@Test
void resolve_dottedPath_missingField_returnsNull() {
- storeProvider.set("JSONY", "{\"a\":\"1\",\"b\":\"2\"}");
+ storeProvider.putSecret("JSONY", "{\"a\":\"1\",\"b\":\"2\"}");
assertThat(service.resolve("JSONY.does_not_exist")).isNull();
}
@@ -111,7 +112,7 @@ void resolve_dottedPath_baseCredentialMissing_returnsNull() {
@Test
void resolve_dottedPath_nonJsonBase_returnsNull() {
- storeProvider.set("FLAT_TOKEN", "not-a-json-value-just-text");
+ storeProvider.putSecret("FLAT_TOKEN", "not-a-json-value-just-text");
assertThat(service.resolve("FLAT_TOKEN.field")).isNull();
}
@@ -120,7 +121,7 @@ void resolve_dottedPath_nonJsonBase_returnsNull() {
void resolve_dottedPath_nonStringLeaf_returnsJsonRepresentation() {
// Number/boolean/object leaves serialize to their JSON form so HTTP/MCP
// placeholders can substitute them cleanly.
- storeProvider.set("CFG", "{\"port\":8080,\"enabled\":true,\"nested\":{\"a\":1}}");
+ storeProvider.putSecret("CFG", "{\"port\":8080,\"enabled\":true,\"nested\":{\"a\":1}}");
assertThat(service.resolve("CFG.port")).isEqualTo("8080");
assertThat(service.resolve("CFG.enabled")).isEqualTo("true");
@@ -132,8 +133,8 @@ void resolve_dottedPath_nonStringLeaf_returnsJsonRepresentation() {
void resolve_dottedPath_doesNotFallthroughToFullName() {
// Even if a literal-dotted name happens to be stored, dotted resolution
// ALWAYS treats the first segment as the base. Documented constraint.
- storeProvider.set("LITERAL.NAME", "literally_dotted_value");
- storeProvider.set("LITERAL", "{\"NAME\":\"json_value\"}");
+ storeProvider.putSecret("LITERAL.NAME", "literally_dotted_value");
+ storeProvider.putSecret("LITERAL", "{\"NAME\":\"json_value\"}");
assertThat(service.resolve("LITERAL.NAME")).isEqualTo("json_value");
}
diff --git a/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/credentials/EncryptedDbCredentialStoreProviderTest.java b/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/credentials/EncryptedDbCredentialStoreProviderTest.java
deleted file mode 100644
index 736620f4f..000000000
--- a/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/credentials/EncryptedDbCredentialStoreProviderTest.java
+++ /dev/null
@@ -1,99 +0,0 @@
-/*
- * Copyright (c) 2025 AgentSpan
- * Licensed under the MIT License.
- */
-package dev.agentspan.runtime.credentials;
-
-import static org.assertj.core.api.Assertions.*;
-
-import java.util.List;
-import java.util.Map;
-
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.beans.factory.annotation.Qualifier;
-import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
-import org.springframework.test.context.ActiveProfiles;
-
-import dev.agentspan.runtime.AgentRuntime;
-import dev.agentspan.runtime.model.credentials.CredentialMeta;
-import dev.agentspan.runtime.spi.CredentialStoreProvider;
-
-@SpringBootTest(classes = AgentRuntime.class, webEnvironment = SpringBootTest.WebEnvironment.NONE)
-@ActiveProfiles("test")
-class EncryptedDbCredentialStoreProviderTest {
-
- @Autowired
- private CredentialStoreProvider storeProvider;
-
- @Autowired
- @Qualifier("credentialJdbc")
- private NamedParameterJdbcTemplate jdbc;
-
- private static final String USER_ID = "00000000-0000-0000-0000-000000000000";
-
- @BeforeEach
- void setUp() {
- jdbc.update("DELETE FROM credentials_store WHERE user_id = :uid", Map.of("uid", USER_ID));
- }
-
- @Test
- void set_andGet_roundTripsEncryptedValue() {
- storeProvider.set("GITHUB_TOKEN", "ghp_supersecret");
- String value = storeProvider.get("GITHUB_TOKEN");
- assertThat(value).isEqualTo("ghp_supersecret");
- }
-
- @Test
- void get_returnsNull_whenNotFound() {
- assertThat(storeProvider.get("DOES_NOT_EXIST")).isNull();
- }
-
- @Test
- void delete_removesCredential() {
- storeProvider.set("TO_DELETE", "value");
- storeProvider.delete("TO_DELETE");
- assertThat(storeProvider.get("TO_DELETE")).isNull();
- }
-
- @Test
- void list_returnsPartialValues_notPlaintext() {
- storeProvider.set("OPENAI_KEY", "sk-abcdefghijklmnop");
-
- List list = storeProvider.list();
-
- CredentialMeta meta = list.stream()
- .filter(m -> m.getName().equals("OPENAI_KEY"))
- .findFirst()
- .orElseThrow();
-
- // Partial: first 4 + ... + last 4
- assertThat(meta.getPartial()).isEqualTo("sk-a...mnop");
- assertThat(meta.getUpdatedAt()).isNotNull();
- // Plaintext is NOT in the list response
- assertThat(meta.toString()).doesNotContain("abcdefghijklmnop");
- }
-
- @Test
- void set_updatesExistingCredential() {
- storeProvider.set("MY_KEY", "original");
- storeProvider.set("MY_KEY", "updated");
- assertThat(storeProvider.get("MY_KEY")).isEqualTo("updated");
- }
-
- @Test
- void encryptedValueInDb_isNotPlaintext() {
- storeProvider.set("SECRET", "plaintext_value");
-
- // Read raw bytes from DB
- byte[] raw = jdbc.queryForObject(
- "SELECT encrypted_value FROM credentials_store WHERE user_id=:uid AND name=:n",
- Map.of("uid", USER_ID, "n", "SECRET"),
- byte[].class);
-
- assertThat(raw).isNotNull();
- assertThat(new String(raw)).doesNotContain("plaintext_value");
- }
-}
diff --git a/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/credentials/SchemaMigratorUpgradePathTest.java b/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/credentials/SchemaMigratorUpgradePathTest.java
index dc6034dab..62417ecdb 100644
--- a/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/credentials/SchemaMigratorUpgradePathTest.java
+++ b/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/credentials/SchemaMigratorUpgradePathTest.java
@@ -21,8 +21,9 @@
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
+import com.netflix.conductor.dao.SecretsDAO;
+
import dev.agentspan.runtime.AgentRuntime;
-import dev.agentspan.runtime.spi.CredentialStoreProvider;
/**
* Audit gap E — schema-migration upgrade path, full pipeline.
@@ -54,7 +55,7 @@ class SchemaMigratorUpgradePathTest {
private CredentialSchemaMigrator migrator;
@Autowired
- private CredentialStoreProvider store;
+ private SecretsDAO store;
@Autowired
@Qualifier("credentialJdbc")
@@ -67,15 +68,15 @@ class SchemaMigratorUpgradePathTest {
@BeforeEach
void setUp() {
- store.delete(STAGE_NAME);
- store.delete(MIGRATED_NAME);
+ store.deleteSecret(STAGE_NAME);
+ store.deleteSecret(MIGRATED_NAME);
jdbc.getJdbcOperations().execute("DROP TABLE IF EXISTS secrets_store");
}
@AfterEach
void cleanUp() {
- store.delete(STAGE_NAME);
- store.delete(MIGRATED_NAME);
+ store.deleteSecret(STAGE_NAME);
+ store.deleteSecret(MIGRATED_NAME);
jdbc.getJdbcOperations().execute("DROP TABLE IF EXISTS secrets_store");
}
@@ -83,7 +84,7 @@ void cleanUp() {
void migratedRow_isReadableViaPublicApi() throws Exception {
// 1. Encrypt a plaintext through the live store (credentials_store).
// This guarantees the bytes are in the format the running server reads.
- store.set(STAGE_NAME, PLAINTEXT);
+ store.putSecret(STAGE_NAME, PLAINTEXT);
byte[] encryptedBytes = jdbc.queryForObject(
"SELECT encrypted_value FROM credentials_store WHERE user_id = :u AND name = :n",
Map.of("u", ANON, "n", STAGE_NAME),
@@ -112,7 +113,7 @@ void migratedRow_isReadableViaPublicApi() throws Exception {
encryptedBytes,
"t",
Instant.now().toString()));
- store.delete(STAGE_NAME); // remove the staging row
+ store.deleteSecret(STAGE_NAME); // remove the staging row
// Sanity: the migrated name is NOT yet visible via the public API.
mvc.perform(get("/api/secrets/" + MIGRATED_NAME)).andExpect(status().isNotFound());
diff --git a/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/service/SkillRegistryServiceTest.java b/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/service/SkillRegistryServiceTest.java
index 243042b1a..a162d3003 100644
--- a/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/service/SkillRegistryServiceTest.java
+++ b/server/conductor-agentspan-server/src/test/java/dev/agentspan/runtime/service/SkillRegistryServiceTest.java
@@ -6,23 +6,18 @@
package dev.agentspan.runtime.service;
import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
-import java.time.Instant;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
-import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockMultipartFile;
-import dev.agentspan.runtime.context.RequestContext;
-import dev.agentspan.runtime.context.RequestContextHolder;
import dev.agentspan.runtime.model.skill.SkillDetail;
import dev.agentspan.runtime.service.skill.FileSystemSkillMetadataDAO;
import dev.agentspan.runtime.service.skill.FileSystemSkillPackageStore;
@@ -32,13 +27,8 @@ class SkillRegistryServiceTest {
@TempDir
Path tempDir;
- @AfterEach
- void clearRequestContext() {
- RequestContextHolder.clear();
- }
-
@Test
- void registeredSkillsAreVisibleOnlyToOwner() throws Exception {
+ void registeredSkillsAreVisibleGlobally() throws Exception {
SkillRegistryService service = new SkillRegistryService(
1024 * 1024,
1024 * 1024,
@@ -46,22 +36,12 @@ void registeredSkillsAreVisibleOnlyToOwner() throws Exception {
100,
new FileSystemSkillPackageStore(tempDir.resolve("packages").toString()),
new FileSystemSkillMetadataDAO(tempDir.toString()));
- String skillName = "owned-skill";
+ String skillName = "shared-skill";
String manifest = "{\"name\":\"" + skillName + "\"}";
- asUser("user-a");
- SkillDetail registered = service.register(manifest, packageFile(skillName));
+ service.register(manifest, packageFile(skillName));
- assertThat(registered.getOwnerId()).isEqualTo("user-a");
assertThat(service.get(skillName, null).getName()).isEqualTo(skillName);
-
- asUser("user-b");
- assertThat(service.list(false)).extracting("name").doesNotContain(skillName);
- assertThatThrownBy(() -> service.get(skillName, null))
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("Skill not found");
-
- asUser("user-a");
assertThat(service.list(false)).extracting("name").contains(skillName);
}
@@ -75,7 +55,6 @@ void deletingLatestVersionPromotesPreviousVersion() throws Exception {
new FileSystemSkillPackageStore(tempDir.resolve("packages").toString()),
new FileSystemSkillMetadataDAO(tempDir.toString()));
String skillName = "versioned-skill";
- asUser("user-a");
service.register("{\"name\":\"" + skillName + "\",\"version\":\"v1\"}", packageFile(skillName));
service.register("{\"name\":\"" + skillName + "\",\"version\":\"v2\"}", packageFile(skillName));
@@ -89,7 +68,7 @@ void deletingLatestVersionPromotesPreviousVersion() throws Exception {
}
@Test
- void sameSkillNameUsesPerOwnerLatestAndPackageStorage() throws Exception {
+ void multipleVersionsOfASkillAreSharedGlobally() throws Exception {
SkillRegistryService service = new SkillRegistryService(
1024 * 1024,
1024 * 1024,
@@ -99,33 +78,16 @@ void sameSkillNameUsesPerOwnerLatestAndPackageStorage() throws Exception {
new FileSystemSkillMetadataDAO(tempDir.toString()));
String skillName = "shared-name-skill";
- asUser("user-a");
- SkillDetail userAV1 = service.register(
- "{\"name\":\"" + skillName + "\",\"version\":\"v1\"}", packageFile(skillName, "User A v1"));
+ SkillDetail v1 = service.register(
+ "{\"name\":\"" + skillName + "\",\"version\":\"v1\"}", packageFile(skillName, "V1 body"));
+ SkillDetail v2 = service.register(
+ "{\"name\":\"" + skillName + "\",\"version\":\"v2\"}", packageFile(skillName, "V2 body"));
- asUser("user-b");
- SkillDetail userBV2 = service.register(
- "{\"name\":\"" + skillName + "\",\"version\":\"v2\"}", packageFile(skillName, "User B v2"));
assertThat(service.get(skillName, null).getVersion()).isEqualTo("v2");
assertThat(service.list(false)).extracting("version").containsExactly("v2");
- assertThat(service.packageBytes(skillName, "v2")).isEqualTo(service.packageBytes(skillName, null));
- byte[] userBPackage = service.packageBytes(skillName, "v2");
-
- asUser("user-a");
- assertThat(service.get(skillName, null).getVersion()).isEqualTo("v1");
- assertThat(service.list(false)).extracting("version").containsExactly("v1");
- assertThat(service.packageBytes(skillName, "v1")).isEqualTo(service.packageBytes(skillName, null));
- assertThat(service.packageBytes(skillName, "v1")).isNotEqualTo(userBPackage);
-
- service.delete(skillName, "v1");
- assertThatThrownBy(() -> service.get(skillName, null))
- .isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("Skill not found");
-
- asUser("user-b");
- assertThat(service.get(skillName, null).getVersion()).isEqualTo("v2");
- assertThat(service.packageBytes(skillName, "v2")).isNotEmpty();
- assertThat(userAV1.getPackageFileHandleId()).isNotEqualTo(userBV2.getPackageFileHandleId());
+ assertThat(service.list(true)).extracting("version").containsExactlyInAnyOrder("v1", "v2");
+ assertThat(service.packageBytes(skillName, "v1")).isNotEqualTo(service.packageBytes(skillName, "v2"));
+ assertThat(v1.getPackageFileHandleId()).isNotEqualTo(v2.getPackageFileHandleId());
}
@Test
@@ -138,7 +100,6 @@ void skillRefRawConfigIncludesParamsAndRegisteredCrossSkills() throws Exception
100,
new FileSystemSkillPackageStore(tempDir.resolve("packages").toString()),
new FileSystemSkillMetadataDAO(tempDir.toString()));
- asUser("user-a");
service.register(
"{\"name\":\"child-skill\",\"version\":\"v1\"}", packageFile("child-skill", "Child instructions"));
@@ -182,7 +143,6 @@ void registeredCrossSkillRefsArePinnedAtRegistrationTime() throws Exception {
100,
new FileSystemSkillPackageStore(tempDir.resolve("packages").toString()),
new FileSystemSkillMetadataDAO(tempDir.toString()));
- asUser("user-a");
service.register(
"{\"name\":\"child-skill\",\"version\":\"v1\"}", packageFile("child-skill", "Child version one"));
@@ -206,14 +166,6 @@ void registeredCrossSkillRefsArePinnedAtRegistrationTime() throws Exception {
assertThat((String) child.get("skillMd")).contains("Child version one").doesNotContain("Child version two");
}
- private static void asUser(String userId) {
- RequestContextHolder.set(RequestContext.builder()
- .requestId("request-" + userId)
- .userId(userId)
- .createdAt(Instant.now())
- .build());
- }
-
private static MockMultipartFile packageFile(String skillName) throws Exception {
return packageFile(skillName, "Owned Skill");
}
diff --git a/server/conductor-agentspan-server/src/test/resources/application-test.properties b/server/conductor-agentspan-server/src/test/resources/application-test.properties
index 94d66365d..af5da89b4 100644
--- a/server/conductor-agentspan-server/src/test/resources/application-test.properties
+++ b/server/conductor-agentspan-server/src/test/resources/application-test.properties
@@ -13,8 +13,8 @@ conductor.app.sweeperThreadCount=1
conductor.workflow-execution-lock.type=local_only
-conductor.task-status-listener.type=agent
-conductor.workflow-status-listener.type=agent
+#conductor.task-status-listener.type=agent
+#conductor.workflow-status-listener.type=agent
conductor.app.workflow.name-validation.enabled=false
conductor.app.ownerEmailMandatory=false
diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/context/RequestContextHolder.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/context/RequestContextHolder.java
index d8f9411af..bcc33df5d 100644
--- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/context/RequestContextHolder.java
+++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/context/RequestContextHolder.java
@@ -11,11 +11,10 @@
*
* Set by the host at the start of each request (the standalone server's {@code AuthFilter},
* or an embedding application's security adapter) and cleared in a finally block. Read anywhere
- * in the call stack via {@link #get()} or {@link #getRequiredUserId()}.
*/
public final class RequestContextHolder {
- private static final ThreadLocal HOLDER = new ThreadLocal<>();
+ private static final ThreadLocal HOLDER = new InheritableThreadLocal<>();
private RequestContextHolder() {}
@@ -30,14 +29,4 @@ public static Optional get() {
public static void clear() {
HOLDER.remove();
}
-
- /**
- * Convenience accessor for the current principal id — throws if no context is set.
- * Use in service code where a request context is guaranteed by the host's filter.
- */
- public static String getRequiredUserId() {
- return get().map(RequestContext::getUserId)
- .orElseThrow(() -> new IllegalStateException(
- "No RequestContext on this thread — context filter may not have run"));
- }
}
diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/controller/ProviderController.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/controller/ProviderController.java
index 8c0ad3591..e02d96a7f 100644
--- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/controller/ProviderController.java
+++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/controller/ProviderController.java
@@ -11,7 +11,6 @@
import java.util.List;
import java.util.Map;
-import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.env.Environment;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -73,14 +72,8 @@ public class ProviderController {
private final OkHttpClient conductorAiHttpClient;
private final Environment environment;
- @Value("${agentspan.embedded:false}")
- private boolean embedded;
-
@GetMapping("/status")
public Map status() {
- if (embedded) {
- return Map.of("managedByHost", true, "providers", List.of());
- }
List
*/
@Service
-@ConditionalOnProperty(name = "agentspan.embedded", havingValue = "false", matchIfMissing = true)
+@ConditionalOnProperty(name = "agentspan.embedded", havingValue = "true")
public class CredentialResolutionService {
private static final Logger log = LoggerFactory.getLogger(CredentialResolutionService.class);
- private final CredentialStoreProvider storeProvider;
+ private final SecretsDAO secretsDAO;
private final ObjectMapper mapper = new ObjectMapper();
- public CredentialResolutionService(CredentialStoreProvider storeProvider) {
- this.storeProvider = storeProvider;
+ public CredentialResolutionService(SecretsDAO secretsDAO) {
+ this.secretsDAO = secretsDAO;
}
/**
@@ -61,14 +60,14 @@ public CredentialResolutionService(CredentialStoreProvider storeProvider) {
public String resolve(String name) {
int dot = name.indexOf('.');
if (dot < 0) {
- String value = storeProvider.get(name);
+ String value = secretsDAO.getSecret(name);
if (value == null) log.debug("Credential '{}' not found", name);
return value;
}
String base = name.substring(0, dot);
String path = name.substring(dot + 1);
- String json = storeProvider.get(base);
+ String json = secretsDAO.getSecret(base);
if (json == null) {
log.debug("Base credential '{}' for path '{}' not found", base, path);
return null;
diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/model/skill/SkillDetail.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/model/skill/SkillDetail.java
index eb66b39c9..e75865ebb 100644
--- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/model/skill/SkillDetail.java
+++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/model/skill/SkillDetail.java
@@ -25,7 +25,6 @@ public class SkillDetail {
private String packageFileHandleId;
private String storageType;
private String status;
- private String ownerId;
private Long createdAt;
private Long updatedAt;
private Long packageSize;
diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/model/skill/SkillSummary.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/model/skill/SkillSummary.java
index 00871189b..ff86fd2a9 100644
--- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/model/skill/SkillSummary.java
+++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/model/skill/SkillSummary.java
@@ -20,7 +20,6 @@ public class SkillSummary {
private String description;
private String checksum;
private String status;
- private String ownerId;
private Long createdAt;
private Long updatedAt;
private Long packageSize;
diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/service/AgentHumanTask.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/service/AgentHumanTask.java
index 4d3e0711e..85fbeadb0 100644
--- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/service/AgentHumanTask.java
+++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/service/AgentHumanTask.java
@@ -30,7 +30,13 @@
* for system tasks. This override hooks into {@code start()} to emit the
* SSE event directly.
*
- * Registered as a {@code @Primary} bean via {@link AgentHumanTaskConfig}.
+ * Registered as the {@code HUMAN} system task by {@link AgentHumanTaskConfig},
+ * which is gated on {@code agentspan.embedded=true}. It is intentionally not a
+ * {@code @Component}: two beans named {@code HUMAN} (this one and Conductor's
+ * {@code Human}) would collide during component scanning, and both would land in
+ * {@code SystemTaskRegistry}'s taskType→task map as duplicate keys. The config's
+ * {@code @Bean("HUMAN")} instead overrides Conductor's default so exactly
+ * one {@code HUMAN} bean exists.
*/
public class AgentHumanTask extends WorkflowSystemTask {
diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/service/AgentHumanTaskConfig.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/service/AgentHumanTaskConfig.java
index f30ec12b9..548d69bb3 100644
--- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/service/AgentHumanTaskConfig.java
+++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/service/AgentHumanTaskConfig.java
@@ -13,18 +13,20 @@
import org.springframework.context.annotation.Primary;
/**
- * Registers {@link AgentHumanTask} as the primary HUMAN task implementation,
- * overriding Conductor's default {@code Human} system task.
+ * Registers {@link AgentHumanTask} as the {@code HUMAN} system task when running in
+ * embedded mode ({@code agentspan.embedded=true}).
*
- * Embedded mode: disabled when {@code agentspan.embedded=true} (e.g. when the
- * library is imported into a host such as orkes-conductor). The host provides its own
- * (richer) HUMAN task — overriding it here would collide on the {@code HUMAN} bean name and
- * replace the host's full HITL implementation with this SSE-only shim. The host is expected
- * to emit the {@code WAITING} SSE event from its own HUMAN task. The standalone OSS server
- * leaves this property unset, so the override stays active as before.
+ * The {@code @Bean("HUMAN")} definition overrides Conductor's default
+ * {@code Human} component (enabled via {@code spring.main.allow-bean-definition-overriding=true}),
+ * so exactly one bean named {@code HUMAN} exists. This avoids the component-scan bean-name
+ * collision and the duplicate taskType key in {@code SystemTaskRegistry} that would otherwise
+ * result from having two {@code WorkflowSystemTask}s both reporting the {@code HUMAN} type.
+ *
+ * When {@code agentspan.embedded} is unset (the standalone OSS server), this configuration
+ * is skipped and Conductor's default {@code Human} task remains in effect.
*/
@Configuration
-@ConditionalOnProperty(name = "agentspan.embedded", havingValue = "false", matchIfMissing = true)
+@ConditionalOnProperty(name = "agentspan.embedded", havingValue = "true")
public class AgentHumanTaskConfig {
@Bean(TASK_TYPE_HUMAN)
diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/service/AgentService.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/service/AgentService.java
index b0a01de5d..7e6e9d8c8 100644
--- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/service/AgentService.java
+++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/service/AgentService.java
@@ -15,7 +15,6 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@@ -44,7 +43,6 @@
import dev.agentspan.runtime.compiler.AgentCompiler;
import dev.agentspan.runtime.compiler.MultiAgentCompiler;
-import dev.agentspan.runtime.context.RequestContextHolder;
import dev.agentspan.runtime.model.*;
import dev.agentspan.runtime.normalizer.NormalizerRegistry;
import dev.agentspan.runtime.util.ModelParser;
@@ -54,7 +52,7 @@
import lombok.RequiredArgsConstructor;
@Component
-@RequiredArgsConstructor(onConstructor_ = {@Autowired})
+@RequiredArgsConstructor
public class AgentService {
private static final Logger log = LoggerFactory.getLogger(AgentService.class);
@@ -69,29 +67,9 @@ public class AgentService {
private final AgentStreamRegistry streamRegistry;
private final ExecutionService executionService;
private final ProviderValidator providerValidator;
-
- @Autowired(required = false)
- private SkillRegistryService skillRegistryService;
-
- /**
- * Conductor's configured ID generator. When embedded in a host that uses time-based IDs
- * (e.g. orkes-conductor with {@code conductor.id.generator=time_based}), pre-allocated
- * execution IDs must be v1 time-based UUIDs — the host derives a workflow's createTime from
- * its ID and yields 0 for non-v1 (random) UUIDs. Falls back to a random UUID when unset
- * (standalone tests / no Spring context).
- */
- @Autowired(required = false)
- private IDGenerator idGenerator;
-
- /**
- * Stable metadata service for task-def registration. The low-level {@code MetadataDAO}'s
- * {@code createTaskDef}/{@code updateTaskDef} return types differ across Conductor cores
- * (orkes' vendored oss-core returns {@code void}; 3.30.2 returns {@code TaskDef}), so calling
- * the DAO directly throws {@code NoSuchMethodError} when embedded. {@code MetadataService}'s
- * methods return {@code void} in all cores. Optional so the test constructor still works.
- */
- @Autowired(required = false)
- private MetadataService metadataService;
+ private final SkillRegistryService skillRegistryService;
+ private final IDGenerator idGenerator;
+ private final MetadataService metadataService;
/**
* Compile an agent config into a WorkflowDef and return it.
@@ -262,19 +240,6 @@ public StartResponse start(StartRequest request) {
startReq.setVersion(def.getVersion());
startReq.setWorkflowDef(def);
- // Attribute the execution to the calling principal (the host populates the
- // RequestContext: orkes' principal filter when embedded, the standalone AuthFilter
- // otherwise). Security-enabled hosts key on this standard Conductor field: orkes
- // records workflow.createdBy from it, stamps _createdBy into every scheduled task,
- // impersonates it during decide so sub-workflows inherit attribution, and its
- // workers' poll-time secret substitution REQUIRES it (tasks of workflows without
- // createdBy fail to poll). Stock Conductor simply records the value.
- String principal =
- RequestContextHolder.get().map(ctx -> ctx.getUserId()).orElse(null);
- if (principal != null) {
- startReq.setCreatedBy(principal);
- }
-
Map input = new LinkedHashMap<>();
input.put("prompt", request.getPrompt());
input.put("media", request.getMedia() != null ? request.getMedia() : List.of());
diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/service/SkillRegistryService.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/service/SkillRegistryService.java
index 56356f0dd..b9fe973aa 100644
--- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/service/SkillRegistryService.java
+++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/service/SkillRegistryService.java
@@ -41,7 +41,6 @@
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
-import dev.agentspan.runtime.context.RequestContextHolder;
import dev.agentspan.runtime.model.skill.SkillDetail;
import dev.agentspan.runtime.model.skill.SkillFileContent;
import dev.agentspan.runtime.model.skill.SkillFileEntry;
@@ -121,7 +120,6 @@ public synchronized SkillDetail register(String manifestJson, MultipartFile pack
ParsedSkillPackage parsed = parseSkillPackage(bytes, manifest);
String name = parsed.name();
- String ownerId = currentUserId();
Map rawConfig = parsed.rawConfig();
String manifestName = stringValue(manifest.get("name"));
if (manifestName != null && !manifestName.isBlank() && !manifestName.equals(name)) {
@@ -134,21 +132,19 @@ public synchronized SkillDetail register(String manifestJson, MultipartFile pack
version = checksum.substring(0, 12);
}
validateVersion(version);
- pinRegisteredCrossSkillRefs(ownerId, rawConfig);
+ pinRegisteredCrossSkillRefs(rawConfig);
long now = Instant.now().toEpochMilli();
- String storageName = packageStoreName(ownerId, name);
- Optional existingOpt = metadataDao.find(ownerId, name, version);
+ Optional existingOpt = metadataDao.find(name, version);
if (existingOpt.isPresent()) {
SkillDetail existing = existingOpt.get();
- enforceReadable(existing);
if (!checksum.equals(existing.getChecksum())) {
throw new IllegalArgumentException(
"Skill " + name + " version " + version + " already exists with a different checksum");
}
if (!packageExists(existing)) {
- StoredSkillPackage restored = packageStore.store(storageName, version, checksum, bytes);
+ StoredSkillPackage restored = packageStore.store(name, version, checksum, bytes);
existing.setPackageFileHandleId(restored.handle());
existing.setStorageType(restored.storageType());
existing.setPackageSize(restored.size());
@@ -160,7 +156,7 @@ public synchronized SkillDetail register(String manifestJson, MultipartFile pack
StoredSkillPackage stored = null;
try {
- stored = packageStore.store(storageName, version, checksum, bytes);
+ stored = packageStore.store(name, version, checksum, bytes);
SkillDetail detail = SkillDetail.builder()
.name(name)
@@ -170,7 +166,6 @@ public synchronized SkillDetail register(String manifestJson, MultipartFile pack
.packageFileHandleId(stored.handle())
.storageType(stored.storageType())
.status("READY")
- .ownerId(ownerId)
.createdAt(now)
.updatedAt(now)
.packageSize(stored.size())
@@ -191,22 +186,18 @@ public synchronized SkillDetail register(String manifestJson, MultipartFile pack
public List list(boolean allVersions) {
List summaries = new ArrayList<>();
- for (SkillDetail detail : metadataDao.list(currentUserId(), allVersions)) {
- if (isReadable(detail)) {
- addSummary(summaries, detail);
- }
+ for (SkillDetail detail : metadataDao.list(allVersions)) {
+ addSummary(summaries, detail);
}
summaries.sort(Comparator.comparing(SkillSummary::getName).thenComparing(SkillSummary::getVersion));
return summaries;
}
public SkillDetail get(String name, String version) {
- String ownerId = currentUserId();
- String resolvedVersion = resolveVersion(ownerId, name, version);
+ String resolvedVersion = resolveVersion(name, version);
SkillDetail detail = metadataDao
- .find(ownerId, name, resolvedVersion)
+ .find(name, resolvedVersion)
.orElseThrow(() -> new IllegalArgumentException("Skill not found: " + name + "@" + resolvedVersion));
- enforceReadable(detail);
return detail;
}
@@ -297,13 +288,11 @@ public Map rawConfigForDeploy(
}
public synchronized void delete(String name, String version) {
- String ownerId = currentUserId();
- String resolvedVersion = resolveVersion(ownerId, name, version);
- metadataDao.find(ownerId, name, resolvedVersion).ifPresent(detail -> {
- enforceReadable(detail);
+ String resolvedVersion = resolveVersion(name, version);
+ metadataDao.find(name, resolvedVersion).ifPresent(detail -> {
deletePackage(detail);
});
- metadataDao.delete(ownerId, name, resolvedVersion);
+ metadataDao.delete(name, resolvedVersion);
}
private void addSummary(List summaries, SkillDetail detail) {
@@ -314,7 +303,6 @@ private void addSummary(List summaries, SkillDetail detail) {
.description(detail.getDescription())
.checksum(detail.getChecksum())
.status(detail.getStatus())
- .ownerId(detail.getOwnerId())
.createdAt(detail.getCreatedAt())
.updatedAt(detail.getUpdatedAt())
.packageSize(detail.getPackageSize())
@@ -325,16 +313,16 @@ private void addSummary(List summaries, SkillDetail detail) {
.build());
}
- private String resolveVersion(String ownerId, String name, String version) {
+ private String resolveVersion(String name, String version) {
if ("latest".equals(version)) {
version = null;
}
if (version != null && !version.isBlank()) {
- if (metadataDao.find(ownerId, name, version).isPresent()) {
+ if (metadataDao.find(name, version).isPresent()) {
return version;
}
// Allow a checksum prefix in place of an exact version.
- for (SkillDetail detail : metadataDao.listVersions(ownerId, name)) {
+ for (SkillDetail detail : metadataDao.listVersions(name)) {
if (detail.getChecksum() != null && detail.getChecksum().startsWith(version)) {
return detail.getVersion();
}
@@ -342,7 +330,7 @@ private String resolveVersion(String ownerId, String name, String version) {
return version;
}
return metadataDao
- .latestVersion(ownerId, name)
+ .latestVersion(name)
.orElseThrow(() -> new IllegalArgumentException("Skill not found: " + name));
}
@@ -661,7 +649,7 @@ private Map rawConfigForDetail(SkillDetail detail, Set s
}
}
- private void pinRegisteredCrossSkillRefs(String ownerId, Map rawConfig) {
+ private void pinRegisteredCrossSkillRefs(Map rawConfig) {
Object skillMd = rawConfig.get("skillMd");
if (!(skillMd instanceof String md)) {
rawConfig.put("crossSkillRefsPinned", true);
@@ -676,9 +664,9 @@ private void pinRegisteredCrossSkillRefs(String ownerId, Map raw
}
SkillDetail refDetail;
try {
- String refVersion = resolveVersion(ownerId, refName, null);
+ String refVersion = resolveVersion(refName, null);
refDetail = metadataDao
- .find(ownerId, refName, refVersion)
+ .find(refName, refVersion)
.orElseThrow(() -> new IllegalArgumentException("Skill not found: " + refName));
} catch (IllegalArgumentException e) {
continue;
@@ -832,21 +820,6 @@ private void deletePackage(SkillDetail detail) {
}
}
- private String currentUserId() {
- return RequestContextHolder.get().map(ctx -> ctx.getUserId()).orElse("00000000-0000-0000-0000-000000000000");
- }
-
- private boolean isReadable(SkillDetail detail) {
- String ownerId = detail.getOwnerId();
- return ownerId == null || ownerId.isBlank() || ownerId.equals(currentUserId());
- }
-
- private void enforceReadable(SkillDetail detail) {
- if (!isReadable(detail)) {
- throw new IllegalArgumentException("Skill not found: " + detail.getName() + "@" + detail.getVersion());
- }
- }
-
private String normalizeEntryName(String name) {
String normalized = name.replace('\\', '/');
while (normalized.startsWith("./")) {
@@ -863,10 +836,6 @@ private String normalizeEntryName(String name) {
return normalized;
}
- private String packageStoreName(String ownerId, String name) {
- return ownerId + ":" + name;
- }
-
private void validateSkillName(String name) {
if (!SKILL_NAME_PATTERN.matcher(name).matches()) {
throw new IllegalArgumentException(
diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/spi/CredentialStoreProvider.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/spi/CredentialStoreProvider.java
deleted file mode 100644
index 9d8db7fe3..000000000
--- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/spi/CredentialStoreProvider.java
+++ /dev/null
@@ -1,41 +0,0 @@
-/*
- * Copyright (c) 2025 AgentSpan
- * Licensed under the MIT License.
- */
-package dev.agentspan.runtime.spi;
-
-import java.util.List;
-
-import dev.agentspan.runtime.model.credentials.CredentialMeta;
-
-/**
- * Strategy interface for credential storage backends.
- *
- * The standalone server ships an encrypted-DB implementation; an embedding host
- * (e.g. orkes-conductor) can supply AWS Secrets Manager, HashiCorp Vault, Azure KV,
- * GCP SM, etc. All implementations plug into the same credential-resolution pipeline.
- */
-public interface CredentialStoreProvider {
-
- /**
- * Retrieve the plaintext value for a credential.
- * Returns null if not found.
- */
- String get(String name);
-
- /**
- * Store or update a credential value (encrypted at rest by the implementation).
- */
- void set(String name, String value);
-
- /**
- * Delete a credential. No-op if not found.
- */
- void delete(String name);
-
- /**
- * List credential metadata for the store.
- * Returns name + partial value + timestamps. Never returns plaintext values.
- */
- List list();
-}
diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/spi/CredentialsDAO.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/spi/CredentialsDAO.java
new file mode 100644
index 000000000..b46dc8b5d
--- /dev/null
+++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/spi/CredentialsDAO.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2025 AgentSpan
+ * Licensed under the MIT License.
+ */
+package dev.agentspan.runtime.spi;
+
+import java.util.List;
+
+import com.netflix.conductor.dao.SecretsDAO;
+
+import dev.agentspan.runtime.model.credentials.CredentialMeta;
+
+/**
+ * AgentSpan's secret storage contract. Extends Conductor's {@link SecretsDAO} so every secret
+ * read/write — from the Credentials UI, credential resolution, and env seeding, as well as
+ * Conductor's own workflow-secret substitution — goes through one interface.
+ *
+ * Adds {@link #listWithMeta()}, the one capability {@code SecretsDAO} doesn't carry: metadata
+ * (partial display value + timestamps) for the Credentials UI. {@code SecretsDAO#listSecretNames}
+ * only returns names.
+ *
+ *
The standalone server ships an encrypted-DB implementation ({@code AgentspanSecretsDAO}); an
+ * embedding host (e.g. orkes-conductor) can supply AWS Secrets Manager, HashiCorp Vault, Azure KV,
+ * GCP SM, etc.
+ */
+public interface CredentialsDAO extends SecretsDAO {
+
+ /**
+ * List credential metadata for the store — name + partial display value + timestamps.
+ * Never returns plaintext values.
+ */
+ List listWithMeta();
+}
diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/spi/SecretOutputMasker.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/spi/SecretOutputMasker.java
deleted file mode 100644
index 422ffdf17..000000000
--- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/spi/SecretOutputMasker.java
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
- * Copyright (c) 2025 AgentSpan
- * Licensed under the MIT License.
- */
-package dev.agentspan.runtime.spi;
-
-/**
- * Redacts secret values from execution-read responses before they leave the server.
- *
- * The standalone server ships a no-op implementation (OSS has no per-execution
- * disclosure tracking, so there is nothing to redact against). An embedding host
- * (e.g. orkes-conductor) supplies an implementation that looks up the secrets
- * disclosed during an execution and removes their plaintext from the payload.
- *
- *
Wired into responses by {@code CredentialMaskingResponseAdvice}.
- */
-public interface SecretOutputMasker {
-
- /**
- * Return {@code payload} with any secrets disclosed for {@code executionId}
- * (scoped to {@code userId}) redacted. Implementations must be best-effort and
- * must never throw — return the payload unchanged on any failure.
- */
- String mask(String executionId, String userId, String payload);
-}
diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/spi/SkillMetadataDAO.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/spi/SkillMetadataDAO.java
index 2eef5e92f..3377ae279 100644
--- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/spi/SkillMetadataDAO.java
+++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/spi/SkillMetadataDAO.java
@@ -18,34 +18,33 @@
* an embedding host (e.g. orkes-conductor) supplies a durable/HA implementation (e.g. Postgres)
* so skill listings are consistent across nodes.
*
- * All operations are scoped by {@code ownerId}. Authorization (whether the caller may read a
- * given skill) is the caller's concern, not this DAO's.
+ * Skills are global: there is no per-caller ownership or scoping.
*/
public interface SkillMetadataDAO {
/**
* Persist a skill version's metadata (create or overwrite).
*
- * @param detail metadata to store, keyed by {@code ownerId + name + version}
+ * @param detail metadata to store, keyed by {@code name + version}
* @param makeLatest when {@code true}, mark this version as the skill's latest
*/
void save(SkillDetail detail, boolean makeLatest);
/** Exact-version lookup. */
- Optional find(String ownerId, String name, String version);
+ Optional find(String name, String version);
/** The recorded latest version string for a skill, if any. */
- Optional latestVersion(String ownerId, String name);
+ Optional latestVersion(String name);
/** All recorded versions of a single skill (unordered). */
- List listVersions(String ownerId, String name);
+ List listVersions(String name);
/**
- * All skills for an owner. When {@code allVersions} is {@code false}, returns only each
+ * All registered skills. When {@code allVersions} is {@code false}, returns only each
* skill's latest version; when {@code true}, returns every version of every skill.
*/
- List list(String ownerId, boolean allVersions);
+ List list(boolean allVersions);
/** Remove a single version and recompute the skill's latest pointer if needed. */
- void delete(String ownerId, String name, String version);
+ void delete(String name, String version);
}
diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/tasks/Join.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/tasks/Join.java
deleted file mode 100644
index b1d8f92fe..000000000
--- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/tasks/Join.java
+++ /dev/null
@@ -1,168 +0,0 @@
-package dev.agentspan.runtime.tasks;
-
-import static com.netflix.conductor.common.metadata.tasks.TaskType.TASK_TYPE_JOIN;
-
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Optional;
-import java.util.Set;
-import java.util.stream.Collectors;
-
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
-import org.springframework.stereotype.Component;
-
-import com.netflix.conductor.annotations.VisibleForTesting;
-import com.netflix.conductor.common.metadata.workflow.WorkflowTask;
-import com.netflix.conductor.common.utils.TaskUtils;
-import com.netflix.conductor.core.config.ConductorProperties;
-import com.netflix.conductor.core.execution.WorkflowExecutor;
-import com.netflix.conductor.core.execution.tasks.WorkflowSystemTask;
-import com.netflix.conductor.model.TaskModel;
-import com.netflix.conductor.model.WorkflowModel;
-
-import lombok.extern.slf4j.Slf4j;
-
-@Component(TASK_TYPE_JOIN)
-@ConditionalOnProperty(name = "agentspan.embedded", havingValue = "false", matchIfMissing = true)
-@Slf4j
-public class Join extends WorkflowSystemTask {
-
- /** Keys propagated from fork branch outputs into the JOIN output.
- * Only these fields are copied — full tool results are omitted to keep
- * the JOIN payload small. Downstream consumers:
- *
- * - {@code _state_updates} — read by {@code stateMergeScript()} in ToolCompiler
- * - {@code state} — read by dynamic agent merge in AgentCompiler
- *
- */
- private static final Set PROPAGATED_KEYS = Set.of("_state_updates", "state");
-
- @VisibleForTesting
- static final double EVALUATION_OFFSET_BASE = 1.2;
-
- private final ConductorProperties properties;
-
- public Join(ConductorProperties properties) {
- super(TASK_TYPE_JOIN);
- this.properties = properties;
- log.info("Using agentspan JOIN");
- }
-
- @Override
- @SuppressWarnings("unchecked")
- public boolean execute(WorkflowModel workflow, TaskModel task, WorkflowExecutor workflowExecutor) {
- StringBuilder failureReason = new StringBuilder();
- StringBuilder optionalTaskFailures = new StringBuilder();
- List joinOn = (List) task.getInputData().get("joinOn");
- if (task.isLoopOverTask()) {
- // If join is part of loop over task, wait for specific iteration to get complete
- joinOn = joinOn.stream()
- .map(name -> TaskUtils.appendIteration(name, task.getIteration()))
- .toList();
- }
-
- boolean allTasksTerminal = joinOn.stream()
- .map(workflow::getTaskByRefName)
- .allMatch(t -> t != null && t.getStatus().isTerminal());
-
- for (String joinOnRef : joinOn) {
- TaskModel forkedTask = workflow.getTaskByRefName(joinOnRef);
- if (forkedTask == null) {
- // Continue checking other tasks if a referenced task is not yet scheduled
- continue;
- }
-
- TaskModel.Status taskStatus = forkedTask.getStatus();
-
- // Determine if the join task fails immediately due to a non-optional, non-permissive
- // task failure,
- // or waits for all tasks to be terminal if the failed task is permissive.
- var isJoinFailure = !taskStatus.isSuccessful()
- && !forkedTask.getWorkflowTask().isOptional()
- && (!forkedTask.getWorkflowTask().isPermissive() || allTasksTerminal);
- if (isJoinFailure) {
- final String failureReasons = joinOn.stream()
- .map(workflow::getTaskByRefName)
- .filter(Objects::nonNull)
- .filter(t -> !t.getStatus().isSuccessful())
- .map(TaskModel::getReasonForIncompletion)
- .collect(Collectors.joining(" "));
- failureReason.append(failureReasons);
- task.setReasonForIncompletion(failureReason.toString());
- task.setStatus(TaskModel.Status.FAILED);
- return true;
- }
-
- // check for optional task failures
- if (forkedTask.getWorkflowTask().isOptional() && taskStatus == TaskModel.Status.COMPLETED_WITH_ERRORS) {
- optionalTaskFailures
- .append(String.format("%s/%s", forkedTask.getTaskDefName(), forkedTask.getTaskId()))
- .append(" ");
- }
- }
-
- // Finalize the join task's status based on the outcomes of all referenced tasks.
- if (allTasksTerminal) {
- // Populate compact output: only copy fields needed by downstream consumers
- // (stateMergeScript reads _state_updates, dynamic agent merge reads state).
- // Full fork outputs are NOT copied — the LLM message builder reads them
- // directly from individual tool tasks, so duplicating here is pure waste.
- for (String joinOnRef : joinOn) {
- TaskModel forkedTask = workflow.getTaskByRefName(joinOnRef);
- if (forkedTask == null) continue;
- Map out = forkedTask.getOutputData();
- if (out == null || out.isEmpty()) continue;
- Map compact = new LinkedHashMap<>();
- for (String key : PROPAGATED_KEYS) {
- if (out.containsKey(key)) {
- compact.put(key, out.get(key));
- }
- }
- if (!compact.isEmpty()) {
- task.addOutput(joinOnRef, compact);
- }
- }
-
- if (!optionalTaskFailures.isEmpty()) {
- task.setStatus(TaskModel.Status.COMPLETED_WITH_ERRORS);
- optionalTaskFailures.append("completed with errors");
- task.setReasonForIncompletion(optionalTaskFailures.toString());
- } else {
- task.setStatus(TaskModel.Status.COMPLETED);
- }
- return true;
- }
-
- // Task execution not complete, waiting on more tasks to reach terminal state.
- return false;
- }
-
- @Override
- public Optional getEvaluationOffset(TaskModel taskModel, long maxOffset) {
- // Check if joinMode is set to SYNC — read directly from the workflow task definition
- // rather than from input data so the value is never duplicated into the task's payload.
- WorkflowTask workflowTask = taskModel.getWorkflowTask();
- if (workflowTask != null && WorkflowTask.JoinMode.SYNC == workflowTask.getJoinMode()) {
- // Synchronous mode: evaluate immediately every time (no backoff)
- return Optional.of(0L);
- }
-
- // Asynchronous mode (default): use exponential backoff
- int pollCount = taskModel.getPollCount();
- // Assuming pollInterval = 50ms and evaluationOffsetThreshold = 200 this will cause
- // a JOIN task to be evaluated continuously during the first 10 seconds and the FORK/JOIN
- // will end with minimal delay.
- if (pollCount <= properties.getSystemTaskPostponeThreshold()) {
- return Optional.of(0L);
- }
-
- double exp = pollCount - properties.getSystemTaskPostponeThreshold();
- return Optional.of(Math.min((long) Math.pow(EVALUATION_OFFSET_BASE, exp), maxOffset));
- }
-
- public boolean isAsync() {
- return true;
- }
-}
diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/util/ProviderValidator.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/util/ProviderValidator.java
index 440362e92..c4a38c5fd 100644
--- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/util/ProviderValidator.java
+++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/util/ProviderValidator.java
@@ -26,7 +26,7 @@ public class ProviderValidator {
* When embedded in a host (e.g. orkes-conductor), Conductor is the authority for model
* providers and credentials: conductor-ai integrations resolve providers by name,
* and the host's credential store (AWS SSM / Vault / etc., reached via the
- * {@code CredentialStoreProvider} SPI) supplies raw keys. This standalone pre-flight check
+ * {@code SecretsDAO}/{@code CredentialsDAO} SPI) supplies raw keys. This standalone pre-flight check
* only knows AgentSpan's own provider model, so it would wrongly reject host-configured
* providers. The execution path already delegates to conductor-ai (which resolves or
* rejects the provider), so when embedded we defer to Conductor and skip this check.
diff --git a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/util/WorkflowClassifiers.java b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/util/WorkflowClassifiers.java
index 7f07d14da..3199517f2 100644
--- a/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/util/WorkflowClassifiers.java
+++ b/server/conductor-agentspan/src/main/java/dev/agentspan/runtime/util/WorkflowClassifiers.java
@@ -67,6 +67,9 @@ public static String classifierOf(Map metadata) {
/** Returns {@code true} when the def resolves to the {@link #AGENT} classifier. */
public static boolean isAgent(Map metadata) {
+ if (metadata == null) {
+ return false;
+ }
return AGENT.equalsIgnoreCase(classifierOf(metadata));
}
}
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
index 4efdabed4..29f5797ef 100644
--- 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
@@ -10,7 +10,6 @@
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;
@@ -191,11 +190,10 @@ private static Map registerAllTaskDefs(AgentConfig config) thro
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);
+ mock(dev.agentspan.runtime.util.ProviderValidator.class),
+ mock(dev.agentspan.runtime.service.SkillRegistryService.class),
+ mock(com.netflix.conductor.core.utils.IDGenerator.class),
+ metadataService);
Method m = AgentService.class.getDeclaredMethod("registerTaskDefinitions", AgentConfig.class);
m.setAccessible(true);
@@ -232,11 +230,10 @@ private static TaskDef registerWorkerTaskDef(String toolName, List creds
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);
+ mock(dev.agentspan.runtime.util.ProviderValidator.class),
+ mock(dev.agentspan.runtime.service.SkillRegistryService.class),
+ mock(com.netflix.conductor.core.utils.IDGenerator.class),
+ metadataService);
Method m = AgentService.class.getDeclaredMethod("registerTaskDef", String.class, List.class);
m.setAccessible(true);
diff --git a/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/context/RequestContextHolderTest.java b/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/context/RequestContextHolderTest.java
deleted file mode 100644
index 28dc97b87..000000000
--- a/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/context/RequestContextHolderTest.java
+++ /dev/null
@@ -1,72 +0,0 @@
-/*
- * Copyright (c) 2025 AgentSpan
- * Licensed under the MIT License.
- */
-package dev.agentspan.runtime.context;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-
-import java.time.Instant;
-import java.util.UUID;
-
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.Test;
-
-class RequestContextHolderTest {
-
- @AfterEach
- void tearDown() {
- RequestContextHolder.clear();
- }
-
- @Test
- void getContext_returnsEmpty_whenNotSet() {
- assertThat(RequestContextHolder.get()).isEmpty();
- }
-
- @Test
- void setAndGet_roundTrips() {
- RequestContext ctx = RequestContext.builder()
- .requestId(UUID.randomUUID().toString())
- .userId("alice")
- .createdAt(Instant.now())
- .build();
-
- RequestContextHolder.set(ctx);
-
- assertThat(RequestContextHolder.get()).isPresent();
- assertThat(RequestContextHolder.get().get().getUserId()).isEqualTo("alice");
- }
-
- @Test
- void clear_removesContext() {
- RequestContextHolder.set(RequestContext.builder()
- .requestId("r1")
- .userId("bob")
- .createdAt(Instant.now())
- .build());
-
- RequestContextHolder.clear();
-
- assertThat(RequestContextHolder.get()).isEmpty();
- }
-
- @Test
- void getRequiredUserId_returnsId_whenSet() {
- RequestContextHolder.set(RequestContext.builder()
- .requestId("r1")
- .userId("u1")
- .createdAt(Instant.now())
- .build());
-
- assertThat(RequestContextHolder.getRequiredUserId()).isEqualTo("u1");
- }
-
- @Test
- void getRequiredUserId_throws_whenNotSet() {
- assertThatThrownBy(RequestContextHolder::getRequiredUserId)
- .isInstanceOf(IllegalStateException.class)
- .hasMessageContaining("No RequestContext");
- }
-}
diff --git a/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/controller/ProviderControllerEmbeddedTest.java b/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/controller/ProviderControllerEmbeddedTest.java
deleted file mode 100644
index a869fec3b..000000000
--- a/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/controller/ProviderControllerEmbeddedTest.java
+++ /dev/null
@@ -1,68 +0,0 @@
-/*
- * Copyright (c) 2025 AgentSpan
- * Licensed under the MIT License. See LICENSE file in the project root for details.
- */
-
-package dev.agentspan.runtime.controller;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.Mockito.*;
-
-import java.util.List;
-import java.util.Map;
-
-import org.junit.jupiter.api.Test;
-import org.springframework.core.env.Environment;
-import org.springframework.test.util.ReflectionTestUtils;
-
-import dev.agentspan.runtime.ai.AgentspanAIModelProvider;
-
-import okhttp3.OkHttpClient;
-
-/**
- * Embedded-mode contract of {@code GET /api/providers/status}.
- *
- * When embedded ({@code agentspan.embedded=true}), the host platform (e.g.
- * orkes-conductor) owns provider integrations and credentials, so the endpoint
- * must defer — {@code managedByHost: true}, no per-provider claims, and no
- * probes — rather than report agentspan's own (wrong there) view. Delegating
- * real status to the host is tracked in
- * #310;
- * the wire contract here is forward-compatible with it.
- */
-class ProviderControllerEmbeddedTest {
-
- private ProviderController controller(boolean embedded, AgentspanAIModelProvider modelProvider) {
- Environment env = mock(Environment.class);
- when(env.getProperty(anyString(), anyString())).thenAnswer(i -> i.getArgument(1));
- ProviderController controller = new ProviderController(modelProvider, new OkHttpClient(), env);
- ReflectionTestUtils.setField(controller, "embedded", embedded);
- return controller;
- }
-
- @Test
- void embedded_reportsManagedByHost_withoutQueryingProviderMachinery() {
- AgentspanAIModelProvider modelProvider = mock(AgentspanAIModelProvider.class);
-
- Map body = controller(true, modelProvider).status();
-
- assertThat(body.get("managedByHost")).isEqualTo(true);
- assertThat((List>) body.get("providers")).isEmpty();
- // The host owns provider config — agentspan's own view must not leak in.
- verifyNoInteractions(modelProvider);
- }
-
- @Test
- void standalone_reportsProviders_notManagedByHost() {
- AgentspanAIModelProvider modelProvider = mock(AgentspanAIModelProvider.class);
- when(modelProvider.isProviderConfigured(anyString())).thenReturn(false);
- // Loopback discard port — connection refused instantly, probe returns false.
- when(modelProvider.resolveConfiguredBaseUrl("ollama")).thenReturn("http://127.0.0.1:9");
-
- Map body = controller(false, modelProvider).status();
-
- assertThat(body.get("managedByHost")).isEqualTo(false);
- assertThat((List>) body.get("providers")).isNotEmpty();
- verify(modelProvider, atLeastOnce()).isProviderConfigured(anyString());
- }
-}
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 543cb317d..cbc20c0ef 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
@@ -12,7 +12,7 @@
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
-import dev.agentspan.runtime.spi.CredentialStoreProvider;
+import com.netflix.conductor.dao.SecretsDAO;
/**
* Verifies the native secret mechanism toggles on {@code agentspan.embedded}:
@@ -32,23 +32,23 @@ class NativeSecretGatingTest {
static class NativeBeans {}
private final ApplicationContextRunner runner = new ApplicationContextRunner()
- .withBean(CredentialStoreProvider.class, () -> mock(CredentialStoreProvider.class))
+ .withBean(SecretsDAO.class, () -> mock(SecretsDAO.class))
.withUserConfiguration(NativeBeans.class);
@Test
void nativeBeans_present_whenFlagAbsent() {
- runner.run(ctx -> assertThat(ctx).hasSingleBean(CredentialResolutionService.class));
+ runner.run(ctx -> assertThat(ctx).doesNotHaveBean(CredentialResolutionService.class));
}
@Test
void nativeBeans_present_whenStandalone() {
- runner.withPropertyValues("agentspan.embedded=false")
+ runner.withPropertyValues("agentspan.embedded=true")
.run(ctx -> assertThat(ctx).hasSingleBean(CredentialResolutionService.class));
}
@Test
void nativeBeans_dormant_whenEmbedded() {
- runner.withPropertyValues("agentspan.embedded=true")
+ runner.withPropertyValues("agentspan.embedded=false")
.run(ctx -> assertThat(ctx).doesNotHaveBean(CredentialResolutionService.class));
}
}
diff --git a/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/service/AgentServiceTokenTest.java b/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/service/AgentServiceTokenTest.java
index f45aa962d..20716f400 100644
--- a/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/service/AgentServiceTokenTest.java
+++ b/server/conductor-agentspan/src/test/java/dev/agentspan/runtime/service/AgentServiceTokenTest.java
@@ -52,6 +52,15 @@ class AgentServiceTokenTest {
@Mock
private dev.agentspan.runtime.util.ProviderValidator providerValidator;
+ @Mock
+ private SkillRegistryService skillRegistryService;
+
+ @Mock
+ private com.netflix.conductor.core.utils.IDGenerator idGenerator;
+
+ @Mock
+ private com.netflix.conductor.service.MetadataService metadataService;
+
private AgentService agentService;
@BeforeEach
@@ -65,7 +74,10 @@ void setUp() {
workflowService,
streamRegistry,
executionService,
- providerValidator);
+ providerValidator,
+ skillRegistryService,
+ idGenerator,
+ metadataService);
RequestContextHolder.set(RequestContext.builder()
.requestId("r1")
diff --git a/server/gradle.properties b/server/gradle.properties
index 638c2ac2f..46557c606 100644
--- a/server/gradle.properties
+++ b/server/gradle.properties
@@ -3,4 +3,4 @@ org.gradle.jvmargs=-Xmx4g
org.gradle.caching=true
# Default published version; overridden in CI via -Pversion=X
-version=0.1.0
\ No newline at end of file
+version=0.4.4.rc6
\ No newline at end of file