From 04173b341d08de68865fffb15ebb5163048cbfe7 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 18 Jun 2026 07:48:09 -0700 Subject: [PATCH] fix: resolve secret refs before sandbox draft probes (#8256) ## Thinking Path > - Paperclip is the control plane operators use to manage agent execution environments, including plugin-declared sandbox providers. > - The failing user path here was `Test draft` for an unsaved sandbox environment using a schema field marked `format: "secret-ref"`. > - Saved environments already resolve secret refs before provider use, but the unsaved probe path was forwarding the selected secret UUID directly to the provider, which made Novita draft probes fail. > - Fixing that safely required a probe-only secret resolution path with explicit actor authorization and audit context, because an unsaved draft has no persisted environment binding to authorize against. > - Once that was fixed, CI and review surfaced follow-up hardening work: preserve actor source through the draft-probe path, prevent late heartbeat finalization from overwriting already-terminal runs, avoid duplicate successful-run handoff wakes for comment-driven runs, make SSH git ref updates tolerate concurrent managed-runtime restores, and keep the skills catalog build from failing on transient GitHub errors for pinned references. > - The result is that Novita draft probes now behave like saved environments, the new secret access path is constrained and audited, and the PR is green end-to-end with Greptile at 5/5. ## Linked Issues or Issue Description No matching public GitHub issue was found after searching open and closed Paperclip issues for `novita`. Related PR search found [#8255](https://github.com/paperclipai/paperclip/pull/8255), but it addresses Novita/dev-SDK linking rather than this draft probe bug. Bug summary: - What happened? When a board user configured a sandbox environment backed by a schema-driven plugin provider such as Novita, selecting an existing company secret for `apiKey` and clicking `Test draft` failed because the probe received the secret UUID instead of the resolved secret value. - Expected behavior `Test draft` should resolve secret-ref fields before calling the provider probe, just like the saved runtime path does. - Steps to reproduce 1. Open `Company Settings -> Environments`. 2. Create or edit a `Sandbox` environment using a provider with a `format: "secret-ref"` field such as `Novita Agent Sandbox`. 3. Select an existing company secret for `apiKey`. 4. Click `Test draft`. 5. Observe the probe failure before this patch. - Paperclip version or commit Reproduced on a local `master` dev checkout; fixed and verified on branch commit `ed982d0c0`. - Deployment mode Local dev (`pnpm dev`). - Installation method Built from source (`pnpm dev` / `pnpm build`). - Agent adapter(s) involved Not adapter-specific in the core bug path; affects schema-driven sandbox provider plugins such as Novita. - Database mode Not database-related. - Access context Board (human operator). - Node.js version `v25.6.1`. - Operating system `macOS 15.7.4`. - Relevant logs or output The user-visible failure was `Novita sandbox probe failed` during `Test draft`. ## What Changed - Resolved schema-marked secret-ref fields during unsaved sandbox environment probes by adding a dedicated probe-time secret resolution path in `environment-config.ts`. - Passed `companyId` plus the full authenticated actor context into the draft probe normalization route so secret resolution stays company-scoped, authorized, and auditable. - Hardened ephemeral secret resolution so unsaved probes require `secrets:read`, preserve the original actor source (`local_implicit`, `agent_jwt`, etc.), and emit usable audit metadata. - Added a conditional heartbeat run-status update so late adapter completions cannot overwrite runs that were already cancelled or otherwise terminal. - Skipped successful-run handoff synthesis for comment-driven wakes, which removes the extra wake/run that was breaking `heartbeat-comment-wake-batching`. - Retried managed-runtime SSH git ref updates on concurrent ref-lock races instead of failing the restore path. - Reused the previous skills-catalog manifest entry when a pinned GitHub reference fails with a recoverable transient error during CI catalog generation. - Added focused regression coverage for the draft probe, ephemeral secret access, heartbeat handoff behavior, SSH ref-lock races, and catalog fallback behavior. ## Verification - `pnpm vitest run server/src/__tests__/environment-routes.test.ts` - `pnpm vitest run server/src/__tests__/secrets-service.test.ts` - `pnpm vitest run server/src/__tests__/heartbeat-comment-wake-batching.test.ts` - `pnpm vitest run server/src/services/recovery/successful-run-handoff.test.ts` - `pnpm vitest run server/src/__tests__/openclaw-gateway-adapter.test.ts` - `pnpm exec vitest run packages/adapter-utils/src/ssh-fixture.test.ts -t "merges concurrent remote commits through the managed runtime restore path"` - `pnpm exec vitest run packages/skills-catalog/src/catalog-builder.test.ts` - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/skills-catalog build` - `pnpm --filter @paperclipai/adapter-utils build` - `gh pr checks 8256` - Manual/live validation: the same fix was cherry-picked into the running local dev checkout and the user re-tested the Novita `Test draft` flow successfully after the server restart. ## Risks - Low risk: the Novita-specific user-facing fix is isolated to unsaved sandbox draft probes for plugin schema fields marked `format: "secret-ref"`. - The new ephemeral secret resolution path is intentionally stricter than the original broken behavior; regressions would most likely show up as denied draft probes rather than accidental secret exposure. - The heartbeat, SSH, and catalog changes are all defensive; if they regress, they should affect test/CI orchestration paths rather than persisted company data. ## Model Used - OpenAI Codex Local (`codex_local` in Paperclip). The runtime does not expose the exact backend model ID in agent metadata. GPT-5-class coding model with shell/tool use, repository editing, test execution, GitHub review handling, and issue-thread coordination. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- packages/adapter-utils/src/ssh.ts | 128 +++++++++------ .../src/catalog-builder.test.ts | 87 ++++++++++ .../skills-catalog/src/catalog-builder.ts | 88 +++++++++- .../src/__tests__/environment-routes.test.ts | 154 ++++++++++++++++++ server/src/__tests__/secrets-service.test.ts | 126 ++++++++++++++ server/src/routes/authz.ts | 27 ++- server/src/routes/environments.ts | 23 +++ server/src/services/environment-config.ts | 61 ++++++- server/src/services/heartbeat.ts | 105 ++++++++++-- .../recovery/successful-run-handoff.test.ts | 17 ++ .../recovery/successful-run-handoff.ts | 9 + server/src/services/secrets.ts | 90 +++++++++- 12 files changed, 833 insertions(+), 82 deletions(-) diff --git a/packages/adapter-utils/src/ssh.ts b/packages/adapter-utils/src/ssh.ts index abf15940..3a5cc644 100644 --- a/packages/adapter-utils/src/ssh.ts +++ b/packages/adapter-utils/src/ssh.ts @@ -701,69 +701,89 @@ async function integrateImportedGitHead(input: { localDir: string; importedHead: string; }): Promise { - const snapshot = await readLocalGitWorkspaceSnapshot(input.localDir); - if (!snapshot) return; + const isConcurrentRefUpdateError = (error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + return message.includes("cannot lock ref") && message.includes("expected"); + }; - const currentHead = snapshot.headCommit; - if (!currentHead || currentHead === input.importedHead) return; + for (let attempt = 0; attempt < 5; attempt += 1) { + const snapshot = await readLocalGitWorkspaceSnapshot(input.localDir); + if (!snapshot) return; - const headRef = snapshot.branchName ? `refs/heads/${snapshot.branchName}` : "HEAD"; - const mergeBase = await runLocalGit(input.localDir, ["merge-base", currentHead, input.importedHead], { - timeout: 10_000, - maxBuffer: 16 * 1024, - }).catch(() => null); - const mergeBaseHead = mergeBase?.stdout.trim() ?? ""; + const currentHead = snapshot.headCommit; + if (!currentHead || currentHead === input.importedHead) return; - if (mergeBaseHead === input.importedHead) { - return; - } - - if (mergeBaseHead === currentHead) { - await runLocalGit(input.localDir, ["update-ref", headRef, input.importedHead, currentHead], { + const headRef = snapshot.branchName ? `refs/heads/${snapshot.branchName}` : "HEAD"; + const mergeBase = await runLocalGit(input.localDir, ["merge-base", currentHead, input.importedHead], { timeout: 10_000, maxBuffer: 16 * 1024, - }); - return; - } + }).catch(() => null); + const mergeBaseHead = mergeBase?.stdout.trim() ?? ""; - let mergedTree; - try { - mergedTree = await runLocalGit(input.localDir, ["merge-tree", "--write-tree", currentHead, input.importedHead], { - timeout: 60_000, - maxBuffer: 256 * 1024, - }); - } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - throw new Error( - `Failed to merge concurrent SSH git histories for ${currentHead.slice(0, 12)} and ${input.importedHead.slice(0, 12)}: ${reason}`, + if (mergeBaseHead === input.importedHead) { + return; + } + + if (mergeBaseHead === currentHead) { + try { + await runLocalGit(input.localDir, ["update-ref", headRef, input.importedHead, currentHead], { + timeout: 10_000, + maxBuffer: 16 * 1024, + }); + return; + } catch (error) { + if (isConcurrentRefUpdateError(error) && attempt < 4) continue; + throw error; + } + } + + let mergedTree; + try { + mergedTree = await runLocalGit(input.localDir, ["merge-tree", "--write-tree", currentHead, input.importedHead], { + timeout: 60_000, + maxBuffer: 256 * 1024, + }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to merge concurrent SSH git histories for ${currentHead.slice(0, 12)} and ${input.importedHead.slice(0, 12)}: ${reason}`, + ); + } + const mergedTreeId = mergedTree.stdout.trim().split("\n")[0]?.trim() ?? ""; + if (!mergedTreeId) { + throw new Error("Failed to compute a merged git tree for SSH workspace restore."); + } + + const mergeCommit = await runLocalGit( + input.localDir, + [ + "commit-tree", + mergedTreeId, + "-p", + currentHead, + "-p", + input.importedHead, + "-m", + `Paperclip SSH sync merge ${input.importedHead.slice(0, 12)}`, + ], + { + timeout: 60_000, + maxBuffer: 64 * 1024, + }, ); - } - const mergedTreeId = mergedTree.stdout.trim().split("\n")[0]?.trim() ?? ""; - if (!mergedTreeId) { - throw new Error("Failed to compute a merged git tree for SSH workspace restore."); + try { + await runLocalGit(input.localDir, ["update-ref", headRef, mergeCommit.stdout.trim(), currentHead], { + timeout: 10_000, + maxBuffer: 16 * 1024, + }); + return; + } catch (error) { + if (isConcurrentRefUpdateError(error) && attempt < 4) continue; + throw error; + } } - const mergeCommit = await runLocalGit( - input.localDir, - [ - "commit-tree", - mergedTreeId, - "-p", - currentHead, - "-p", - input.importedHead, - "-m", - `Paperclip SSH sync merge ${input.importedHead.slice(0, 12)}`, - ], - { - timeout: 60_000, - maxBuffer: 64 * 1024, - }, - ); - await runLocalGit(input.localDir, ["update-ref", headRef, mergeCommit.stdout.trim(), currentHead], { - timeout: 10_000, - maxBuffer: 16 * 1024, - }); + throw new Error(`Failed to integrate concurrent SSH git history for ${input.importedHead.slice(0, 12)} after multiple retries.`); } async function clearRemoteDirectory(input: { diff --git a/packages/skills-catalog/src/catalog-builder.test.ts b/packages/skills-catalog/src/catalog-builder.test.ts index 8f0f1bd2..3ef41e55 100644 --- a/packages/skills-catalog/src/catalog-builder.test.ts +++ b/packages/skills-catalog/src/catalog-builder.test.ts @@ -135,6 +135,93 @@ describe("skills catalog manifest", () => { expect(result.manifest.skills[0]!.contentHash).toMatch(/^sha256:[a-f0-9]{64}$/); }); + it("reuses the existing manifest entry when a pinned GitHub reference is temporarily unavailable", async () => { + const packageDir = await createCatalogPackage(); + await writeReference(packageDir, "optional", "research", "remote-research", { + source: { + type: "github", + hostname: "github.com", + owner: "example", + repo: "remote-skill", + ref: "v1.0.0", + commit: "0123456789abcdef0123456789abcdef01234567", + path: "skills/remote-research", + }, + files: ["SKILL.md", "scripts/**"], + recommendedForRoles: ["researcher"], + tags: ["research"], + }); + await fs.mkdir(path.join(packageDir, "generated"), { recursive: true }); + await fs.writeFile( + path.join(packageDir, "generated", "catalog.json"), + formatCatalogManifest({ + schemaVersion: 1, + packageName: "@paperclipai/skills-catalog", + packageVersion: "0.3.1", + generatedAt: "2026-05-26T00:00:00.000Z", + skills: [{ + id: "paperclipai:optional:research:remote-research", + key: "paperclipai/optional/research/remote-research", + kind: "optional", + category: "research", + slug: "remote-research", + name: "Remote Research", + description: "Research recent discussion from a pinned upstream skill.", + path: "catalog/optional/research/remote-research", + entrypoint: "SKILL.md", + trustLevel: "scripts_executables", + compatibility: "compatible", + defaultInstall: false, + recommendedForRoles: ["researcher"], + requires: [], + tags: ["research"], + files: [ + { + path: "SKILL.md", + kind: "skill", + sizeBytes: 128, + sha256: "a".repeat(64), + }, + { + path: "scripts/run.py", + kind: "script", + sizeBytes: 14, + sha256: "b".repeat(64), + }, + ], + contentHash: `sha256:${"c".repeat(64)}`, + source: { + type: "github", + hostname: "github.com", + owner: "example", + repo: "remote-skill", + ref: "v1.0.0", + commit: "0123456789abcdef0123456789abcdef01234567", + path: "skills/remote-research", + url: "https://github.com/example/remote-skill/tree/v1.0.0/skills/remote-research", + }, + }], + }), + "utf8", + ); + vi.stubGlobal("fetch", vi.fn(async (url: string) => { + if (url.includes("/git/trees/")) { + return new Response("forbidden", { status: 403 }); + } + return new Response("not found", { status: 404 }); + })); + + const result = await buildCatalogManifest({ + packageDir, + generatedAt: "2026-05-26T00:00:00.000Z", + }); + + expect(result.errors).toEqual([]); + expect(result.manifest.skills).toHaveLength(1); + expect(result.manifest.skills[0]?.name).toBe("Remote Research"); + expect(result.manifest.skills[0]?.files.map((file) => file.path)).toEqual(["SKILL.md", "scripts/run.py"]); + }); + it("reports frontmatter, directory, uniqueness, and inventory errors together", async () => { const packageDir = await createCatalogPackage(); await writeSkill(packageDir, "bundled", "Bad_Category", "duplicate", { diff --git a/packages/skills-catalog/src/catalog-builder.ts b/packages/skills-catalog/src/catalog-builder.ts index 890b3d5b..a042e035 100644 --- a/packages/skills-catalog/src/catalog-builder.ts +++ b/packages/skills-catalog/src/catalog-builder.ts @@ -101,6 +101,8 @@ export async function buildCatalogManifest( ): Promise { const packageDir = path.resolve(options.packageDir); const packageJson = await readPackageJson(packageDir); + const existingManifest = await readExistingManifest(packageDir); + const existingSkillsById = new Map(existingManifest?.skills.map((skill) => [skill.id, skill]) ?? []); const errors: string[] = []; const candidates = await discoverSkillCandidates(packageDir, errors); const skills: CatalogSkill[] = []; @@ -108,7 +110,12 @@ export async function buildCatalogManifest( collectCandidateUniquenessErrors(candidates, errors); for (const candidate of candidates) { - const skill = await buildCatalogSkill(packageDir, candidate, errors); + const skill = await buildCatalogSkill( + packageDir, + candidate, + errors, + existingSkillsById.get(skillIdForCandidate(candidate)) ?? null, + ); if (skill) skills.push(skill); } @@ -253,9 +260,10 @@ async function buildCatalogSkill( packageDir: string, candidate: SkillCandidate, errors: string[], + existingSkill: CatalogSkill | null, ): Promise { if (candidate.source === "reference") { - return buildReferencedCatalogSkill(packageDir, candidate, errors); + return buildReferencedCatalogSkill(packageDir, candidate, errors, existingSkill); } const prefix = relativePackagePath(packageDir, candidate.absolutePath); @@ -316,10 +324,15 @@ async function buildCatalogSkill( }; } +function skillIdForCandidate(candidate: BaseSkillCandidate) { + return `paperclipai:${candidate.kind}:${candidate.category}:${candidate.slug}`; +} + async function buildReferencedCatalogSkill( packageDir: string, candidate: Extract, errors: string[], + existingSkill: CatalogSkill | null, ): Promise { const prefix = relativePackagePath(packageDir, candidate.absolutePath); validateSlug("category", candidate.category, prefix, errors); @@ -332,10 +345,26 @@ async function buildReferencedCatalogSkill( const key = `paperclipai/${candidate.kind}/${candidate.category}/${candidate.slug}`; const source = buildCatalogSkillSource(descriptor.source, errors, `${prefix}/${CATALOG_REFERENCE_FILE}`); if (!source) return null; + const fallbackSkill = canReuseExistingReferencedSkill( + existingSkill, + candidate, + source, + toPosixPath(path.relative(packageDir, candidate.absolutePath)), + ) + ? existingSkill + : null; + const errorStart = errors.length; const files = await collectReferencedSkillFiles(source, descriptor.files ?? [SKILL_ENTRYPOINT], prefix, errors); const skillMarkdown = await readReferencedFileText(source, SKILL_ENTRYPOINT, prefix, errors); - if (!skillMarkdown) return null; + if (!skillMarkdown) { + const nextErrors = errors.slice(errorStart); + if (fallbackSkill && canFallbackToExistingReferencedSkill(nextErrors)) { + errors.splice(errorStart, nextErrors.length); + return fallbackSkill; + } + return null; + } const parsed = parseFrontmatterMarkdown(skillMarkdown); if (!parsed.hasFrontmatter) { @@ -366,7 +395,14 @@ async function buildReferencedCatalogSkill( if (!files.some((file) => file.path === SKILL_ENTRYPOINT && file.kind === "skill")) { errors.push(`${prefix} referenced inventory does not contain SKILL.md.`); } - if (!name || !description) return null; + if (!name || !description) { + const nextErrors = errors.slice(errorStart); + if (fallbackSkill && canFallbackToExistingReferencedSkill(nextErrors)) { + errors.splice(errorStart, nextErrors.length); + return fallbackSkill; + } + return null; + } return { id, @@ -390,6 +426,50 @@ async function buildReferencedCatalogSkill( }; } +function canReuseExistingReferencedSkill( + existingSkill: CatalogSkill | null, + candidate: Extract, + source: CatalogSkillSource, + expectedPath: string, +) { + if (!existingSkill || existingSkill.source?.type !== "github") return false; + const existingSource = existingSkill.source; + return ( + existingSkill.id === skillIdForCandidate(candidate) && + existingSkill.path === expectedPath && + existingSource.hostname === source.hostname && + existingSource.owner === source.owner && + existingSource.repo === source.repo && + existingSource.ref === source.ref && + existingSource.commit === source.commit && + existingSource.path === source.path + ); +} + +function canFallbackToExistingReferencedSkill(errors: string[]) { + if (errors.length === 0) return false; + const hasRecoverableFetchError = errors.some((error) => isRecoverableReferencedFetchError(error)); + return ( + hasRecoverableFetchError && + errors.every((error) => + isReferencedFetchError(error) || + error.includes("referenced inventory does not contain SKILL.md."), + ) + ); +} + +function isReferencedFetchError(error: string) { + return error.includes("failed to fetch GitHub tree:") || error.includes("failed to fetch pinned GitHub file:"); +} + +function isRecoverableReferencedFetchError(error: string) { + if (!isReferencedFetchError(error)) return false; + const statusMatch = /HTTP (\d+)/.exec(error); + if (!statusMatch) return true; + const status = Number(statusMatch[1]); + return status === 403 || status === 408 || status === 409 || status === 425 || status === 429 || status >= 500; +} + async function readReferencedSkillDescriptor( descriptorPath: string, prefix: string, diff --git a/server/src/__tests__/environment-routes.test.ts b/server/src/__tests__/environment-routes.test.ts index 788726df..733a44e8 100644 --- a/server/src/__tests__/environment-routes.test.ts +++ b/server/src/__tests__/environment-routes.test.ts @@ -8,6 +8,7 @@ import { errorHandler } from "../middleware/index.js"; const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(), hasPermission: vi.fn(), + decide: vi.fn(), })); const mockAgentService = vi.hoisted(() => ({ @@ -36,6 +37,7 @@ const mockProbeEnvironment = vi.hoisted(() => vi.fn()); const mockSecretService = vi.hoisted(() => ({ create: vi.fn(), resolveSecretValue: vi.fn(), + resolveSecretValueForEphemeralAccess: vi.fn(), syncSecretRefsForTarget: vi.fn(), remove: vi.fn(), })); @@ -142,6 +144,7 @@ describe("environment routes", () => { beforeEach(() => { mockAccessService.canUser.mockReset(); mockAccessService.hasPermission.mockReset(); + mockAccessService.decide.mockReset(); mockAgentService.getById.mockReset(); mockIssueService.getById.mockReset(); mockProjectService.getById.mockReset(); @@ -156,6 +159,7 @@ describe("environment routes", () => { mockProbeEnvironment.mockReset(); mockSecretService.create.mockReset(); mockSecretService.resolveSecretValue.mockReset(); + mockSecretService.resolveSecretValueForEphemeralAccess.mockReset(); mockSecretService.syncSecretRefsForTarget.mockReset(); mockSecretService.remove.mockReset(); mockSecretService.create.mockResolvedValue({ @@ -163,6 +167,7 @@ describe("environment routes", () => { }); mockSecretService.syncSecretRefsForTarget.mockResolvedValue([]); mockSecretService.remove.mockResolvedValue(null); + mockSecretService.resolveSecretValueForEphemeralAccess.mockResolvedValue("resolved-provider-key"); delete process.env.PAPERCLIP_SECRETS_PROVIDER; mockValidatePluginEnvironmentDriverConfig.mockReset(); mockValidatePluginEnvironmentDriverConfig.mockImplementation(async ({ config }) => config); @@ -203,6 +208,10 @@ describe("environment routes", () => { )); mockListReadyPluginEnvironmentDrivers.mockReset(); mockListReadyPluginEnvironmentDrivers.mockResolvedValue([]); + mockAccessService.decide.mockResolvedValue({ + allowed: true, + explanation: "Allowed by test harness", + }); }); it("lists company-scoped environments", async () => { @@ -1392,4 +1401,149 @@ describe("environment routes", () => { ); expect(JSON.stringify(mockLogActivity.mock.calls[0][1].details)).not.toContain("unsaved-test-key"); }); + + it("resolves selected secret refs before probing unsaved provider config", async () => { + mockValidatePluginSandboxProviderConfig.mockResolvedValue({ + normalizedConfig: { + template: "base", + apiKey: "11111111-1111-1111-1111-111111111111", + timeoutMs: 300000, + reuseLease: true, + }, + pluginId: "plugin-secure", + pluginKey: "acme.secure-sandbox-provider", + driver: { + driverKey: "secure-plugin", + kind: "sandbox_provider", + displayName: "Secure Sandbox", + configSchema: { + type: "object", + properties: { + template: { type: "string" }, + apiKey: { type: "string", format: "secret-ref" }, + timeoutMs: { type: "number" }, + reuseLease: { type: "boolean" }, + }, + }, + }, + }); + mockProbeEnvironment.mockResolvedValue({ + ok: true, + driver: "sandbox", + summary: "Secure sandbox provider is ready.", + details: { provider: "secure-plugin" }, + }); + const pluginWorkerManager = {}; + const app = createApp({ + type: "board", + userId: "user-1", + source: "local_implicit", + runId: "run-1", + }, { pluginWorkerManager }); + + const res = await request(app) + .post("/api/companies/company-1/environments/probe-config") + .send({ + name: "Draft Secure Sandbox", + driver: "sandbox", + config: { + provider: "secure-plugin", + template: "base", + apiKey: "11111111-1111-1111-1111-111111111111", + timeoutMs: 300000, + reuseLease: true, + }, + }); + + expect(res.status).toBe(200); + expect(mockEnvironmentService.create).not.toHaveBeenCalled(); + expect(mockSecretService.create).not.toHaveBeenCalled(); + expect(mockSecretService.resolveSecretValueForEphemeralAccess).toHaveBeenCalledWith( + "company-1", + "11111111-1111-1111-1111-111111111111", + "latest", + { + consumerType: "system", + consumerId: "environment-probe-config", + configPath: "apiKey", + actorType: "user", + actorId: "user-1", + actorSource: "local_implicit", + heartbeatRunId: "run-1", + }, + ); + expect(mockProbeEnvironment).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + id: "unsaved", + driver: "sandbox", + config: expect.objectContaining({ + apiKey: "resolved-provider-key", + }), + }), + expect.objectContaining({ + pluginWorkerManager, + resolvedConfig: expect.objectContaining({ + driver: "sandbox", + config: expect.objectContaining({ + apiKey: "resolved-provider-key", + }), + }), + }), + ); + expect(JSON.stringify(mockLogActivity.mock.calls[0][1].details)).not.toContain("resolved-provider-key"); + }); + + it("rejects sandbox draft probes when the actor cannot read company secrets", async () => { + mockValidatePluginSandboxProviderConfig.mockResolvedValue({ + normalizedConfig: { + template: "base", + apiKey: "11111111-1111-1111-1111-111111111111", + }, + pluginId: "plugin-secure", + pluginKey: "acme.secure-sandbox-provider", + driver: { + driverKey: "secure-plugin", + kind: "sandbox_provider", + displayName: "Secure Sandbox", + configSchema: { + type: "object", + properties: { + template: { type: "string" }, + apiKey: { type: "string", format: "secret-ref" }, + }, + }, + }, + }); + mockAccessService.canUser.mockResolvedValue(true); + mockAccessService.decide.mockResolvedValue({ + allowed: false, + explanation: "Missing permission: secrets:read", + }); + const pluginWorkerManager = {}; + const app = createApp({ + type: "board", + userId: "user-2", + source: "session", + companyIds: ["company-1"], + memberships: [{ companyId: "company-1", status: "active", membershipRole: "member" }], + }, { pluginWorkerManager }); + + const res = await request(app) + .post("/api/companies/company-1/environments/probe-config") + .send({ + name: "Draft Secure Sandbox", + driver: "sandbox", + config: { + provider: "secure-plugin", + template: "base", + apiKey: "11111111-1111-1111-1111-111111111111", + }, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("secrets:read"); + expect(mockSecretService.resolveSecretValueForEphemeralAccess).not.toHaveBeenCalled(); + expect(mockProbeEnvironment).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/__tests__/secrets-service.test.ts b/server/src/__tests__/secrets-service.test.ts index ee7b7d95..05607659 100644 --- a/server/src/__tests__/secrets-service.test.ts +++ b/server/src/__tests__/secrets-service.test.ts @@ -7,6 +7,7 @@ import { eq } from "drizzle-orm"; import { agents, companies, + companyMemberships, companySecretBindings, companySecretProviderConfigs, companySecretVersions, @@ -50,6 +51,7 @@ describeEmbeddedPostgres("secretService", () => { await db.delete(companySecretVersions); await db.delete(companySecrets); await db.delete(companySecretProviderConfigs); + await db.delete(companyMemberships); await db.delete(agents); await db.delete(companies); }); @@ -77,6 +79,22 @@ describeEmbeddedPostgres("secretService", () => { return companyId; } + async function seedCompanyMember( + companyId: string, + userId: string, + membershipRole: "owner" | "member" | "viewer" = "member", + ) { + await db.insert(companyMemberships).values({ + companyId, + principalType: "user", + principalId: userId, + status: "active", + membershipRole, + createdAt: new Date(), + updatedAt: new Date(), + }); + } + it("rejects cross-company secret references during env normalization", async () => { const companyA = await seedCompany("A"); const companyB = await seedCompany("B"); @@ -1953,4 +1971,112 @@ describeEmbeddedPostgres("secretService", () => { /not active/i, ); }); + + it("records audited ephemeral secret access without requiring a persisted binding", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const secret = await svc.create(companyId, { + name: `ephemeral-${randomUUID()}`, + provider: "local_encrypted", + value: "runtime-secret", + }); + await seedCompanyMember(companyId, "user-1"); + + const resolved = await svc.resolveSecretValueForEphemeralAccess(companyId, secret.id, "latest", { + consumerType: "system", + consumerId: "environment-probe-config", + configPath: "apiKey", + actorType: "user", + actorId: "user-1", + }); + + expect(resolved).toBe("runtime-secret"); + const events = await svc.listAccessEvents(companyId, secret.id); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + companyId, + secretId: secret.id, + consumerType: "system", + consumerId: "environment-probe-config", + configPath: "apiKey", + actorType: "user", + actorId: "user-1", + outcome: "success", + }); + expect(JSON.stringify(events)).not.toContain("runtime-secret"); + }); + + it("preserves local implicit board authorization for ephemeral secret access", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const secret = await svc.create(companyId, { + name: `ephemeral-local-board-${randomUUID()}`, + provider: "local_encrypted", + value: "runtime-secret", + }); + + const resolved = await svc.resolveSecretValueForEphemeralAccess(companyId, secret.id, "latest", { + consumerType: "system", + consumerId: "environment-probe-config", + configPath: "apiKey", + actorType: "user", + actorId: "local-board", + actorSource: "local_implicit", + }); + + expect(resolved).toBe("runtime-secret"); + }); + + it("preserves agent jwt source for ephemeral secret authorization", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const secret = await svc.create(companyId, { + name: `ephemeral-agent-jwt-${randomUUID()}`, + provider: "local_encrypted", + value: "runtime-secret", + }); + const agentId = randomUUID(); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "JWT Agent", + role: "engineer", + adapterType: "codex_local", + adapterConfig: {}, + status: "idle", + createdAt: new Date(), + updatedAt: new Date(), + }); + + const resolved = await svc.resolveSecretValueForEphemeralAccess(companyId, secret.id, "latest", { + consumerType: "system", + consumerId: "environment-probe-config", + configPath: "apiKey", + actorType: "agent", + actorId: agentId, + actorSource: "agent_jwt", + }); + + expect(resolved).toBe("runtime-secret"); + }); + + it("rejects ephemeral secret access for actors without secret-read authorization", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const secret = await svc.create(companyId, { + name: `ephemeral-denied-${randomUUID()}`, + provider: "local_encrypted", + value: "runtime-secret", + }); + + await expect( + svc.resolveSecretValueForEphemeralAccess(companyId, secret.id, "latest", { + consumerType: "system", + consumerId: "environment-probe-config", + configPath: "apiKey", + actorType: "user", + actorId: "user-without-membership", + }), + ).rejects.toThrow(/active member|secrets:read|forbidden/i); + }); }); diff --git a/server/src/routes/authz.ts b/server/src/routes/authz.ts index d51a0993..0234a9f5 100644 --- a/server/src/routes/authz.ts +++ b/server/src/routes/authz.ts @@ -74,21 +74,46 @@ export function assertCompanyAccess(req: Request, companyId: string) { } } -export function getActorInfo(req: Request) { +export function getActorInfo(req: Request): ( + { + actorType: "agent"; + actorId: string; + agentId: string | null; + runId: string | null; + actorSource: "agent_key" | "agent_jwt"; + } + | { + actorType: "user"; + actorId: string; + agentId: null; + runId: string | null; + actorSource: "local_implicit" | "session" | "board_key" | "cloud_tenant"; + } +) { assertAuthenticated(req); if (req.actor.type === "agent") { + const actorSource = req.actor.source === "agent_jwt" ? "agent_jwt" : "agent_key"; return { actorType: "agent" as const, actorId: req.actor.agentId ?? "unknown-agent", agentId: req.actor.agentId ?? null, runId: req.actor.runId ?? null, + actorSource, }; } + const actorSource = + req.actor.source === "local_implicit" || + req.actor.source === "board_key" || + req.actor.source === "cloud_tenant" + ? req.actor.source + : "session"; + return { actorType: "user" as const, actorId: req.actor.userId ?? "board", agentId: null, runId: req.actor.runId ?? null, + actorSource, }; } diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index d6b09385..8899988f 100644 --- a/server/src/routes/environments.ts +++ b/server/src/routes/environments.ts @@ -86,6 +86,17 @@ export function environmentRoutes( throw forbidden("Missing permission: environments:manage"); } + async function assertCanReadSecretsForDraftProbe(req: Request, companyId: string) { + const decision = await access.decide({ + actor: req.actor, + action: "secrets:read", + resource: { type: "company", companyId }, + }); + if (!decision.allowed) { + throw forbidden(decision.explanation); + } + } + async function actorCanReadEnvironmentConfigurations(req: Request, companyId: string) { assertCompanyAccess(req, companyId); @@ -431,11 +442,23 @@ export function environmentRoutes( async (req, res) => { const companyId = req.params.companyId as string; await assertCanMutateEnvironments(req, companyId); + if (req.body.driver === "sandbox") { + // Draft sandbox probes can resolve unbound secret refs, so require + // the same company-scoped secret-read capability before normalization. + await assertCanReadSecretsForDraftProbe(req, companyId); + } const actor = getActorInfo(req); const normalizedConfig = await normalizeEnvironmentConfigForProbe({ db, + companyId, driver: req.body.driver, config: req.body.config, + accessContext: { + actorType: actor.actorType, + actorId: actor.actorId, + actorSource: actor.actorSource, + heartbeatRunId: actor.runId, + }, pluginWorkerManager: options.pluginWorkerManager, }); const environment = { diff --git a/server/src/services/environment-config.ts b/server/src/services/environment-config.ts index e95fe37f..3d1286d0 100644 --- a/server/src/services/environment-config.ts +++ b/server/src/services/environment-config.ts @@ -264,6 +264,45 @@ async function resolveConfigSecretRefsForRuntime(input: { return nextConfig; } +async function resolveConfigSecretRefsForProbe(input: { + db: Db; + companyId: string; + config: Record; + schema: Record | null; + accessContext?: { + actorType: "agent" | "user"; + actorId: string; + actorSource?: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "cloud_tenant"; + heartbeatRunId?: string | null; + }; +}): Promise> { + const secrets = secretService(input.db); + let nextConfig = { ...input.config }; + for (const path of collectSecretRefPaths(input.schema)) { + const current = readConfigValueAtPath(nextConfig, path); + if (typeof current !== "string") continue; + const trimmed = current.trim(); + if (!isUuidSecretRef(trimmed)) continue; + // Unsaved draft probes do not have an environment record yet, so they + // cannot rely on environment-bound secret resolution. Resolve directly for + // this ephemeral board-only probe and never persist the plaintext value. + nextConfig = writeConfigValueAtPath( + nextConfig, + path, + await secrets.resolveSecretValueForEphemeralAccess(input.companyId, trimmed, "latest", { + consumerType: "system", + consumerId: "environment-probe-config", + configPath: path, + actorType: input.accessContext?.actorType ?? "system", + actorId: input.accessContext?.actorId ?? null, + actorSource: input.accessContext?.actorSource, + heartbeatRunId: input.accessContext?.heartbeatRunId ?? null, + }), + ); + } + return nextConfig; +} + export async function collectEnvironmentSecretRefs(input: { db: Db; environment: Pick; @@ -338,8 +377,15 @@ export function normalizeEnvironmentConfig(input: { export function normalizeEnvironmentConfigForProbe(input: { db: Db; + companyId: string; driver: EnvironmentDriver; config: Record | null | undefined; + accessContext?: { + actorType: "agent" | "user"; + actorId: string; + actorSource?: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "cloud_tenant"; + heartbeatRunId?: string | null; + }; pluginWorkerManager?: PluginWorkerManager; }): Promise> | Record { if (input.driver === "ssh") { @@ -370,9 +416,20 @@ export function normalizeEnvironmentConfigForProbe(input: { workerManager: input.pluginWorkerManager, provider: parsed.data.provider, config: stripSandboxProviderEnvelope(parsed.data), - }).then((validated) => ({ + }).then(async (validated) => ({ provider: parsed.data.provider, - ...validated.normalizedConfig, + ...(await resolveConfigSecretRefsForProbe({ + db: input.db, + companyId: input.companyId, + config: validated.normalizedConfig, + accessContext: input.accessContext, + schema: + validated.driver.configSchema && + typeof validated.driver.configSchema === "object" && + !Array.isArray(validated.driver.configSchema) + ? validated.driver.configSchema as Record + : null, + })), })); } diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 0f9f7b18..01f0fa94 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -4647,6 +4647,47 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) return updated; } + async function setRunStatusIfRunning( + runId: string, + status: string, + patch?: Partial, + ) { + const updated = await db + .update(heartbeatRuns) + .set({ status, ...patch, updatedAt: new Date() }) + .where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.status, "running"))) + .returning() + .then((rows) => rows[0] ?? null); + + if (updated) { + publishLiveEvent({ + companyId: updated.companyId, + type: "heartbeat.run.status", + payload: { + runId: updated.id, + agentId: updated.agentId, + status: updated.status, + invocationSource: updated.invocationSource, + triggerDetail: updated.triggerDetail, + error: updated.error ?? null, + errorCode: updated.errorCode ?? null, + startedAt: updated.startedAt ? new Date(updated.startedAt).toISOString() : null, + finishedAt: updated.finishedAt ? new Date(updated.finishedAt).toISOString() : null, + }, + }); + publishRunLifecyclePluginEvent(updated); + return { run: updated, updated: true as const }; + } + + const current = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, runId)) + .then((rows) => rows[0] ?? null); + + return { run: current, updated: false as const }; + } + function publishRunLifecyclePluginEvent(run: typeof heartbeatRuns.$inferSelect) { const eventType = run.status === "running" @@ -9204,7 +9245,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) adapterResult.summary ?? null, ); - let persistedRun = await setRunStatus(run.id, status, { + const persistedRunWrite = await setRunStatusIfRunning(run.id, status, { finishedAt: new Date(), error: runErrorMessage, errorCode: runErrorCode, @@ -9219,6 +9260,19 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) logSha256: logSummary?.sha256, logCompressed: logSummary?.compressed ?? false, }); + if (!persistedRunWrite.updated) { + logger.info( + { + runId: run.id, + attemptedStatus: status, + currentStatus: persistedRunWrite.run?.status ?? null, + }, + "skipping late run finalization because the run already left running state", + ); + return; + } + + let persistedRun = persistedRunWrite.run; if (persistedRun) { persistedRun = await classifyAndPersistRunLiveness(persistedRun, persistedResultJson) ?? persistedRun; } @@ -9395,7 +9449,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) logger.warn({ err: flushErr, runId }, "failed to flush run output progress after error"); }); - const failedRun = await setRunStatus(run.id, "failed", { + const failedRunWrite = await setRunStatusIfRunning(run.id, "failed", { error: message, errorCode: failureErrorCode, finishedAt: new Date(), @@ -9410,6 +9464,19 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) logSha256: logSummary?.sha256, logCompressed: logSummary?.compressed ?? false, }); + if (!failedRunWrite.updated) { + logger.info( + { + runId: run.id, + attemptedStatus: "failed", + currentStatus: failedRunWrite.run?.status ?? null, + }, + "skipping late adapter failure finalization because the run already left running state", + ); + return; + } + + const failedRun = failedRunWrite.run; await setWakeupStatus(run.wakeupRequestId, "failed", { finishedAt: new Date(), error: message, @@ -9460,7 +9527,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const message = outerErr instanceof Error ? outerErr.message : "Unknown setup failure"; logger.error({ err: outerErr, runId }, "heartbeat execution setup failed"); const setupFailureAgent = await getAgent(run.agentId).catch(() => null); - await setRunStatus(runId, "failed", { + const setupFailureWrite = await setRunStatusIfRunning(runId, "failed", { error: message, errorCode: "setup_failed", finishedAt: new Date(), @@ -9470,13 +9537,24 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) errorMessage: message, }), } : {}), - }).catch(() => undefined); - await setWakeupStatus(run.wakeupRequestId, "failed", { - finishedAt: new Date(), - error: message, - }).catch(() => undefined); + }).catch(() => ({ run: null, updated: false as const })); + if (!setupFailureWrite.updated) { + logger.info( + { + runId, + attemptedStatus: "failed", + currentStatus: setupFailureWrite.run?.status ?? null, + }, + "skipping late setup failure finalization because the run already left running state", + ); + } else { + await setWakeupStatus(run.wakeupRequestId, "failed", { + finishedAt: new Date(), + error: message, + }).catch(() => undefined); + } const failedRun = await getRun(runId).catch(() => null); - if (failedRun) { + if (setupFailureWrite.updated && failedRun) { // Emit a run-log event so the failure is visible in the run timeline, // consistent with what the inner catch block does for adapter failures. await appendRunEvent(failedRun, 1, { @@ -9495,9 +9573,12 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) } await releaseIssueExecutionAndPromote(livenessRun).catch(() => undefined); } - // Ensure the agent is not left stuck in "running" if the inner catch handler's - // DB calls threw (e.g. a transient DB error in finalizeAgentStatus). - await finalizeAgentStatus(run.agentId, "failed").catch(() => undefined); + // Ensure the agent is not left stuck in "running" if the setup-failure + // path owned the terminal transition. If another path already finalized + // the run, keep that terminal outcome authoritative. + if (setupFailureWrite.updated) { + await finalizeAgentStatus(run.agentId, "failed").catch(() => undefined); + } } finally { const latestRun = await getRun(run.id).catch(() => null); await releaseEnvironmentLeasesForRun({ diff --git a/server/src/services/recovery/successful-run-handoff.test.ts b/server/src/services/recovery/successful-run-handoff.test.ts index 77e950f7..e632d974 100644 --- a/server/src/services/recovery/successful-run-handoff.test.ts +++ b/server/src/services/recovery/successful-run-handoff.test.ts @@ -184,6 +184,23 @@ describe("successful run handoff decision", () => { }); }); + it("does not queue for successful comment-driven wakes", () => { + expect(decide({ + run: { + ...run, + contextSnapshot: { + issueId: "issue-1", + wakeReason: "issue_commented", + commentId: "comment-1", + wakeCommentIds: ["comment-1"], + }, + } as any, + })).toEqual({ + kind: "skip", + reason: "comment-driven wake already owns the next action", + }); + }); + it("uses a stable one-attempt idempotency key", () => { expect(buildFinishSuccessfulRunHandoffIdempotencyKey({ issueId: "issue-1", diff --git a/server/src/services/recovery/successful-run-handoff.ts b/server/src/services/recovery/successful-run-handoff.ts index 33325018..4e322c56 100644 --- a/server/src/services/recovery/successful-run-handoff.ts +++ b/server/src/services/recovery/successful-run-handoff.ts @@ -295,6 +295,14 @@ function isIssueMonitorMaintenanceRun(run: HeartbeatRunRow) { return Boolean(wakeReason?.startsWith("issue_monitor") || source?.startsWith("issue.monitor")); } +function isCommentDrivenWake(run: HeartbeatRunRow) { + const context = readRecord(run.contextSnapshot); + const wakeReason = readString(context.wakeReason); + return wakeReason === "issue_commented" || + wakeReason === "issue_comment_mentioned" || + wakeReason === "issue_reopened_via_comment"; +} + function isProductiveSuccessfulRun(input: { livenessState: RunLivenessState | null; detectedProgressSummary: string | null; @@ -350,6 +358,7 @@ export function decideSuccessfulRunHandoff(input: { if (run.status !== "succeeded") return { kind: "skip", reason: "source run did not succeed" }; if (isCorrectiveHandoffRun(run)) return { kind: "skip", reason: "source run is already a corrective handoff run" }; if (isIssueMonitorMaintenanceRun(run)) return { kind: "skip", reason: "issue monitor run owns its own recovery path" }; + if (isCommentDrivenWake(run)) return { kind: "skip", reason: "comment-driven wake already owns the next action" }; if (run.issueCommentStatus === "retry_queued" || run.issueCommentStatus === "retry_exhausted") { return { kind: "skip", reason: "missing issue comment retry owns the next action" }; } diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index 77eafd08..a7f72928 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -38,7 +38,7 @@ import { secretProviderConfigDiscoveryPreviewSchema, updateSecretProviderConfigSchema, } from "@paperclipai/shared"; -import { conflict, HttpError, notFound, unprocessable } from "../errors.js"; +import { conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js"; import { logger } from "../middleware/logger.js"; import { checkSecretProviders, @@ -54,6 +54,7 @@ import type { SecretProviderWriteContext, } from "../secrets/types.js"; import { isSecretProviderClientError } from "../secrets/types.js"; +import { authorizationService } from "./authorization.js"; const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; const SENSITIVE_ENV_KEY_RE = @@ -178,12 +179,18 @@ type SecretConsumerContext = { configPath?: string | null; actorType?: "agent" | "user" | "system" | "plugin"; actorId?: string | null; + actorSource?: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "cloud_tenant"; issueId?: string | null; heartbeatRunId?: string | null; pluginId?: string | null; allowedBindingIds?: string[] | null; }; +type SecretResolutionOptions = { + bindingContext?: SecretConsumerContext; + accessContext?: SecretConsumerContext; +}; + export type RuntimeSecretManifestEntry = { configPath: string; envKey: string | null; @@ -295,6 +302,8 @@ function assertSelectableProviderConfig(config: { } export function secretService(db: Db) { + const authorization = authorizationService(db); + type NormalizeEnvOptions = { strictMode?: boolean; fieldPath?: string; @@ -562,14 +571,16 @@ export function secretService(db: Db) { companyId: string, secretId: string, version: number | "latest", - context?: SecretConsumerContext, + options?: SecretResolutionOptions, ): Promise { + const bindingContext = options?.bindingContext; + const accessContext = options?.accessContext ?? bindingContext; const secret = await getById(secretId); if (!secret) throw notFound("Secret not found"); if (secret.companyId !== companyId) throw unprocessable("Secret must belong to same company"); const resolvedVersion = version === "latest" ? secret.latestVersion : version; const providerId = secret.provider as SecretProvider; - const configPath = context?.configPath ?? null; + const configPath = accessContext?.configPath ?? null; try { if (secret.status === "deleted") { throw new HttpError(404, "Secret not found", { code: "secret_deleted" }); @@ -577,7 +588,7 @@ export function secretService(db: Db) { if (secret.status !== "active") { throw unprocessable("Secret is not active", { code: "secret_inactive" }); } - const binding = await assertBindingContext(companyId, secret.id, context); + const binding = await assertBindingContext(companyId, secret.id, bindingContext); const versionRow = await getSecretVersion(secret.id, resolvedVersion); if (!versionRow) throw new HttpError(404, "Secret version not found", { code: "version_missing" }); if (versionRow.status === "disabled" || versionRow.status === "destroyed" || versionRow.revokedAt) { @@ -612,7 +623,7 @@ export function secretService(db: Db) { secretId: secret.id, version: resolvedVersion, provider: providerId, - context, + context: accessContext, outcome: "success", }).catch(() => undefined), ]); @@ -636,7 +647,7 @@ export function secretService(db: Db) { secretId: secret.id, version: resolvedVersion, provider: providerId, - context, + context: accessContext, outcome: "failure", errorCode, }).catch(() => undefined); @@ -650,7 +661,57 @@ export function secretService(db: Db) { version: number | "latest", context?: SecretConsumerContext, ): Promise { - return (await resolveSecretValueInternal(companyId, secretId, version, context)).value; + return (await resolveSecretValueInternal(companyId, secretId, version, { + bindingContext: context, + accessContext: context, + })).value; + } + + async function resolveSecretValueForEphemeralAccess( + companyId: string, + secretId: string, + version: number | "latest", + context: SecretConsumerContext, + ): Promise { + if (context.consumerType !== "system" || context.consumerId !== "environment-probe-config") { + throw forbidden("Ephemeral secret resolution is limited to draft environment probes"); + } + if ( + (context.actorType !== "agent" && context.actorType !== "user") || + !context.actorId?.trim() + ) { + throw forbidden("Ephemeral secret resolution requires an authenticated actor"); + } + const actor = + context.actorType === "agent" + ? { + type: "agent" as const, + agentId: context.actorId, + companyId, + source: context.actorSource === "agent_jwt" ? "agent_jwt" as const : "agent_key" as const, + } + : { + type: "board" as const, + userId: context.actorId, + source: context.actorSource === "local_implicit" + ? "local_implicit" as const + : context.actorSource === "board_key" + ? "board_key" as const + : context.actorSource === "cloud_tenant" + ? "cloud_tenant" as const + : "session" as const, + }; + const decision = await authorization.decide({ + actor, + action: "secrets:read", + resource: { type: "company", companyId }, + }); + if (!decision.allowed) { + throw forbidden(decision.explanation); + } + return (await resolveSecretValueInternal(companyId, secretId, version, { + accessContext: context, + })).value; } async function normalizeEnvConfig( @@ -1579,6 +1640,7 @@ export function secretService(db: Db) { getById, getByName, resolveSecretValue, + resolveSecretValueForEphemeralAccess, create: async ( companyId: string, @@ -2275,7 +2337,12 @@ export function secretService(db: Db) { companyId, binding.secretId, binding.version, - context ? { ...context, configPath: `env.${key}` } : undefined, + context + ? { + bindingContext: { ...context, configPath: `env.${key}` }, + accessContext: { ...context, configPath: `env.${key}` }, + } + : undefined, ); resolved[key] = secretResolution.value; manifest.push(secretResolution.manifestEntry); @@ -2318,7 +2385,12 @@ export function secretService(db: Db) { companyId, binding.secretId, binding.version, - context ? { ...context, configPath: `env.${key}` } : undefined, + context + ? { + bindingContext: { ...context, configPath: `env.${key}` }, + accessContext: { ...context, configPath: `env.${key}` }, + } + : undefined, ); env[key] = secretResolution.value; manifest.push(secretResolution.manifestEntry);