Files
PaperClipAI/server/src/__tests__/agents-claude-oauth-binding.test.ts
T
DottaandPaperclip a1ab55a56d fix(agents): grant configuration access by default (#15283)
## Thinking Path

> - Paperclip manages agent work and permissions for a company.
> - Agent setup can require one agent to configure another agent.
> - New standard agents could create agents, but they had no direct
configuration grant.
> - Existing agents must keep their current permissions after an
upgrade.
> - The requested new-agent defaults include 13 more direct permissions
for suggestions, skills, tools, audit, inbox, and task assignment.
> - This pull request adds the 14-grant set at creation and approval
activation, retains it through invitations, and keeps existing agents
unchanged.
> - The change keeps low trust and built-in agents on their narrower
permissions.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The agent creation and approval flows, and the agent configuration
authorization path.

**Current behavior**

A new standard agent can create an agent. It cannot make protected
changes to a peer agent unless an operator adds an `agents:configure`
grant.

**Proposed behavior**

Add direct grants for `agents:configure`, `agents:suggest-changes`,
`skills:create`, `skills:suggest-changes`, `tools:manage_connections`,
`tools:manage_profiles`, `tools:view_audit`, `audit:view_agent_actions`,
`tools:use`, `tools:manage_runtime`, `inbox:manage`, `tasks:assign`,
`tasks:assign_scope`, and `tasks:manage_active_checkouts` to new
standard agents. Scope `tasks:assign_scope` to the agent’s reporting
subtree. Keep existing agents and their grants unchanged.

**Reason and benefit**

New standard agents can complete agent setup and the requested tool,
skill, inbox, and task workflows under existing route, scope, and
approval checks.

**Breaking changes**

Existing agents keep their current permissions. There is no permission
migration.

Related PR: #12212 adds scoped grant routes. This PR changes the default
grant.

## What Changed

- Add the 14 requested direct grants in agent creation and approval
activation. Keep them when invitation approval replaces grants,
preserving explicit scopes. Remove them when an agent is deleted.
- Apply defaults only to new agents. Remove the existing-agent
permission migration, its snapshot, and its journal entry. Preserve
existing scoped grants.
- Add permission, scope, invitation, and existing-agent regression
tests. Document all default and excluded permissions.
- Prevent agent keys from creating, changing, or restoring host-executed
process or local adapter command settings, and from restoring workspace
commands through rollback.

## Verification

- Run `node_modules/.bin/vitest run
server/src/__tests__/agent-default-configure-grants.test.ts
server/src/__tests__/invite-join-grants.test.ts
packages/db/src/migration-snapshot-drift.test.ts`. All 15 tests pass.
These cover new-agent defaults, unchanged existing grants, pending
approval, invitation grants, and migration history.
- Run `node_modules/.bin/tsc -p server/tsconfig.json --noEmit`. It
passes.
- Run `packages/db/node_modules/.bin/tsx
packages/db/src/check-migration-numbering.ts` and
`packages/db/node_modules/.bin/tsx
packages/db/src/check-migration-safety.ts`. Both pass.
- Confirm that this PR has no files under `packages/db/src/migrations/`
in its final diff.
- GitHub CI at `e8173b2c41` passes build, typecheck, server suites,
browser shards, and canary verification. One unchanged OpenCode
transport test reached its five-second timeout in the first Runner shard
run. That test passes locally in 2.55 seconds. The shard passed on one
retry. All 54 checks pass, with two expected skips.
- Greptile gives this exact head 5/5. Security review passes. The PR has
no unresolved review threads or merge conflicts.

## Risks

- The 14 default permissions apply only to new standard agents. Existing
permissions stay unchanged. Low trust and managed built-in agents are
excluded.
- New grants include connection, runtime, and active-checkout
management. Existing company, responsible-user, scope, and approval
checks remain in force. The default inbox grant carries a
responsible-user-only scope so it cannot override another user's inbox
settings.
- Protected changes still need the responsible user's authority when
that check applies. Company boundaries and approval gates still apply.
- Agent-authenticated requests cannot configure process adapters or host
command settings on local adapters. Known provider credential references
remain allowed, as do narrow plain authentication overrides on new peers
when the caller uses an AI connection pool. Arbitrary environment
settings remain blocked. Board operators retain the host configuration
paths.

> I checked `ROADMAP.md`. This is a narrow fix to existing agent
configuration behavior.

## Model Used

- OpenAI Codex, GPT-6 series. This runtime did not expose its exact
hosted model ID or context window. This revision uses OpenAI GPT-6
through Codex with tool use, code execution, and tests. The runtime does
not expose the exact hosted model ID or context window.

## 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 not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] 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 <noreply@paperclip.ing>
2026-10-06 05:28:48 -05:00

1163 lines
47 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { mkdirSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { eq, sql } from "drizzle-orm";
import {
activityLog,
adapterAuthSessions,
agents,
companies,
companySecretBindings,
companySecretProviderConfigs,
companySecretVersions,
companySecrets,
createDb,
environments,
principalPermissionGrants,
userSecretDeclarations,
userSecretDefinitions,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { agentService } from "../services/agents.ts";
import {
assertClaudeOAuthBindingInvariant,
CLAUDE_OAUTH_CLAIM_REJECTED,
CLAUDE_OAUTH_CREDENTIAL_CONFLICT,
claudeOAuthBindingsMatchExactly,
claudeOAuthClaimRejectedError,
isFixedClaudeOAuthBinding,
secretService,
} from "../services/secrets.js";
import {
createDbSetupTokenCleanupStore,
type SetupTokenSessionScope,
} from "../services/setup-token-session.js";
import { createSetupTokenSecretWriter } from "../services/setup-token-transport-binding.js";
// The fixed Claude Code OAuth binding. It is a user-secret reference to the
// fixed key. Any other shape is a replacement or a weaker binding.
const FIXED_BINDING = { type: "user_secret_ref", key: "CLAUDE_CODE_OAUTH_TOKEN" } as const;
const withEnv = (env: Record<string, unknown>) => ({ env });
// --- The pure server-enforced binding invariant (no database) ----------------
describe("assertClaudeOAuthBindingInvariant", () => {
it("reports that a create introduces the fixed binding", () => {
const decision = assertClaudeOAuthBindingInvariant({
adapterType: "claude_local",
nextConfig: withEnv({ CLAUDE_CODE_OAUTH_TOKEN: FIXED_BINDING }),
priorConfig: null,
});
expect(decision).toEqual({ introducesBinding: true, keepsBinding: false });
});
it("reports that a write keeps an existing fixed binding", () => {
const decision = assertClaudeOAuthBindingInvariant({
adapterType: "claude_local",
nextConfig: withEnv({ CLAUDE_CODE_OAUTH_TOKEN: FIXED_BINDING }),
priorConfig: withEnv({ CLAUDE_CODE_OAUTH_TOKEN: FIXED_BINDING }),
});
expect(decision).toEqual({ introducesBinding: false, keepsBinding: true });
});
it("permits a normal removal of the fixed binding", () => {
const decision = assertClaudeOAuthBindingInvariant({
adapterType: "claude_local",
nextConfig: withEnv({}),
priorConfig: withEnv({ CLAUDE_CODE_OAUTH_TOKEN: FIXED_BINDING }),
});
expect(decision).toEqual({ introducesBinding: false, keepsBinding: false });
});
it("permits a normal plain-value replacement of the fixed binding", () => {
const decision = assertClaudeOAuthBindingInvariant({
adapterType: "claude_local",
nextConfig: withEnv({ CLAUDE_CODE_OAUTH_TOKEN: { type: "plain", value: "sk-fake" } }),
priorConfig: withEnv({ CLAUDE_CODE_OAUTH_TOKEN: FIXED_BINDING }),
});
expect(decision).toEqual({ introducesBinding: false, keepsBinding: false });
});
it("permits a re-point to a different user-secret key", () => {
const decision = assertClaudeOAuthBindingInvariant({
adapterType: "claude_local",
nextConfig: withEnv({ CLAUDE_CODE_OAUTH_TOKEN: { type: "user_secret_ref", key: "OTHER" } }),
priorConfig: withEnv({ CLAUDE_CODE_OAUTH_TOKEN: FIXED_BINDING }),
});
expect(decision).toEqual({ introducesBinding: false, keepsBinding: false });
});
it("permits a re-point to a company-secret reference", () => {
const decision = assertClaudeOAuthBindingInvariant({
adapterType: "claude_local",
nextConfig: withEnv({
CLAUDE_CODE_OAUTH_TOKEN: { type: "secret_ref", secretId: randomUUID(), version: "latest" },
}),
priorConfig: withEnv({ CLAUDE_CODE_OAUTH_TOKEN: FIXED_BINDING }),
});
expect(decision).toEqual({ introducesBinding: false, keepsBinding: false });
});
it("permits a removal of the fixed binding by a move to another adapter type", () => {
// The prior config has the fixed binding on claude_local. The write moves
// the agent to the process adapter and drops the binding in the same write.
// The binding behaves like a normal environment variable, so the write is
// allowed and reports no fixed binding.
const decision = assertClaudeOAuthBindingInvariant({
adapterType: "process",
nextConfig: withEnv({}),
priorConfig: withEnv({ CLAUDE_CODE_OAUTH_TOKEN: FIXED_BINDING }),
});
expect(decision).toEqual({ introducesBinding: false, keepsBinding: false });
});
it("does nothing for a non-claude_local create with no prior fixed binding", () => {
const decision = assertClaudeOAuthBindingInvariant({
adapterType: "process",
nextConfig: withEnv({}),
priorConfig: withEnv({ SOME_OTHER_KEY: { type: "plain", value: "x" } }),
});
expect(decision).toEqual({ introducesBinding: false, keepsBinding: false });
});
it("rejects the fixed binding together with a plain ANTHROPIC_API_KEY and names no value", () => {
let thrown: Error | null = null;
try {
assertClaudeOAuthBindingInvariant({
adapterType: "claude_local",
nextConfig: withEnv({
CLAUDE_CODE_OAUTH_TOKEN: FIXED_BINDING,
ANTHROPIC_API_KEY: { type: "plain", value: "sk-ant-secret" },
}),
priorConfig: null,
});
} catch (error) {
thrown = error as Error;
}
expect(thrown?.message).toBe(CLAUDE_OAUTH_CREDENTIAL_CONFLICT);
expect(thrown?.message).not.toContain("sk-ant-secret");
expect(thrown?.message).not.toContain("ANTHROPIC_API_KEY");
});
it("rejects the fixed binding together with an ANTHROPIC_API_KEY secret reference", () => {
expect(() =>
assertClaudeOAuthBindingInvariant({
adapterType: "claude_local",
nextConfig: withEnv({
CLAUDE_CODE_OAUTH_TOKEN: FIXED_BINDING,
ANTHROPIC_API_KEY: { type: "secret_ref", secretId: randomUUID(), version: "latest" },
}),
priorConfig: null,
}),
).toThrowError(CLAUDE_OAUTH_CREDENTIAL_CONFLICT);
});
it("ignores an empty ANTHROPIC_API_KEY plain value", () => {
const decision = assertClaudeOAuthBindingInvariant({
adapterType: "claude_local",
nextConfig: withEnv({
CLAUDE_CODE_OAUTH_TOKEN: FIXED_BINDING,
ANTHROPIC_API_KEY: { type: "plain", value: " " },
}),
priorConfig: null,
});
expect(decision.introducesBinding).toBe(true);
});
it("does nothing for a non-claude_local adapter", () => {
const decision = assertClaudeOAuthBindingInvariant({
adapterType: "codex_local",
nextConfig: withEnv({ CLAUDE_CODE_OAUTH_TOKEN: { type: "plain", value: "anything" } }),
priorConfig: null,
});
expect(decision).toEqual({ introducesBinding: false, keepsBinding: false });
});
it("recognizes only the exact fixed binding shape", () => {
expect(isFixedClaudeOAuthBinding(FIXED_BINDING)).toBe(true);
expect(isFixedClaudeOAuthBinding({ type: "plain", value: "x" })).toBe(false);
expect(isFixedClaudeOAuthBinding({ type: "user_secret_ref", key: "OTHER" })).toBe(false);
expect(isFixedClaudeOAuthBinding(null)).toBe(false);
});
it("raises a fixed 409 claim error", () => {
const error = claudeOAuthClaimRejectedError();
expect(error.status).toBe(409);
expect(error.message).toBe(CLAUDE_OAUTH_CLAIM_REJECTED);
});
it("matches two fixed bindings only when their version selectors are exactly equal", () => {
expect(claudeOAuthBindingsMatchExactly(FIXED_BINDING, FIXED_BINDING)).toBe(true);
expect(
claudeOAuthBindingsMatchExactly({ ...FIXED_BINDING, version: 5 }, { ...FIXED_BINDING, version: 5 }),
).toBe(true);
expect(
claudeOAuthBindingsMatchExactly({ ...FIXED_BINDING, version: 5 }, { ...FIXED_BINDING, version: 2 }),
).toBe(false);
expect(
claudeOAuthBindingsMatchExactly({ ...FIXED_BINDING, version: 5 }, { ...FIXED_BINDING, version: "latest" }),
).toBe(false);
// Neither side needs the exact fixed shape only; both sides do.
expect(claudeOAuthBindingsMatchExactly(FIXED_BINDING, { type: "plain", value: "x" })).toBe(false);
expect(claudeOAuthBindingsMatchExactly(null, FIXED_BINDING)).toBe(false);
});
});
// --- The stored-session claim on the create and hire paths (Postgres) --------
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping Claude OAuth binding claim tests on this host: ${
embeddedPostgresSupport.reason ?? "unsupported environment"
}`,
);
}
describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
let stopDb: (() => Promise<void>) | null = null;
let connectionString!: string;
let db!: ReturnType<typeof createDb>;
const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
const secretsTmpDir = path.join(os.tmpdir(), `paperclip-claude-oauth-binding-${randomUUID()}`);
beforeAll(async () => {
mkdirSync(secretsTmpDir, { recursive: true });
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key");
const started = await startEmbeddedPostgresTestDatabase("claude-oauth-binding");
stopDb = started.cleanup;
connectionString = started.connectionString;
db = createDb(connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(activityLog);
await db.delete(companySecretBindings);
await db.delete(companySecretVersions);
await db.delete(companySecrets);
await db.delete(companySecretProviderConfigs);
await db.delete(userSecretDeclarations);
await db.delete(userSecretDefinitions);
await db.delete(adapterAuthSessions);
await db.delete(principalPermissionGrants);
await db.delete(agents);
await db.delete(environments);
await db.delete(companies);
});
afterAll(async () => {
await stopDb?.();
if (previousKeyFile === undefined) {
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
} else {
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile;
}
rmSync(secretsTmpDir, { recursive: true, force: true });
});
interface Scope {
companyId: string;
environmentId: string;
ownerUserId: string;
}
async function seedScope(): Promise<Scope> {
const companyId = randomUUID();
const environmentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(environments).values({
id: environmentId,
name: `sandbox-${environmentId.slice(0, 8)}`,
driver: "sandbox",
status: "active",
config: { provider: "fake" },
createdAt: new Date(),
updatedAt: new Date(),
});
return { companyId, environmentId, ownerUserId: `user-${randomUUID().slice(0, 8)}` };
}
async function seedStoredClaim(scope: Scope, deadlineMs: number, state = "stored"): Promise<string> {
const sessionId = randomUUID();
await createDbSetupTokenCleanupStore(db).record({
sessionId,
companyId: scope.companyId,
ownerUserId: scope.ownerUserId,
adapterType: "claude_local",
environmentId: scope.environmentId,
leaseId: "lease-1",
deadline: deadlineMs,
state: state as never,
boundAt: null,
});
return sessionId;
}
function createInput(scope: Scope, extraEnv: Record<string, unknown> = {}) {
return {
name: "Claude Login Agent",
role: "engineer",
status: "idle" as const,
adapterType: "claude_local",
defaultEnvironmentId: scope.environmentId,
adapterConfig: { env: { CLAUDE_CODE_OAUTH_TOKEN: { ...FIXED_BINDING }, ...extraEnv } },
runtimeConfig: {},
spentMonthlyCents: 0,
lastHeartbeatAt: null,
};
}
async function readClaim(sessionId: string) {
const rows = await db
.select()
.from(adapterAuthSessions)
.where(eq(adapterAuthSessions.publicSessionId, sessionId));
return rows[0];
}
async function countAgents(companyId: string): Promise<number> {
const rows = await db.select().from(agents).where(eq(agents.companyId, companyId));
return rows.length;
}
// Seed one stored owner value for the fixed Claude OAuth definition. The
// apply-existing path binds the fixed reference only when this value exists.
async function seedStoredOwnerValue(scope: Scope, value = "sk-owner-token"): Promise<void> {
await secretService(db).completeClaudeOAuthUserSecret(scope.companyId, scope.ownerUserId, {
sessionId: randomUUID(),
mode: "first_write",
value,
});
}
async function countUserSecretDefinitions(companyId: string): Promise<number> {
const rows = await db
.select()
.from(userSecretDefinitions)
.where(eq(userSecretDefinitions.companyId, companyId));
return rows.length;
}
async function countDeclarationsForAgent(agentId: string): Promise<number> {
const rows = await db
.select()
.from(userSecretDeclarations)
.where(eq(userSecretDeclarations.targetId, agentId));
return rows.length;
}
it("inserts the fixed binding and consumes a valid claim exactly once", async () => {
const scope = await seedScope();
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000);
const created = await agentService(db).create(scope.companyId, createInput(scope), {
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
});
const persisted = created.adapterConfig as { env: Record<string, unknown> };
expect(persisted.env.CLAUDE_CODE_OAUTH_TOKEN).toMatchObject({
type: "user_secret_ref",
key: "CLAUDE_CODE_OAUTH_TOKEN",
});
// The claim is consumed once.
expect((await readClaim(sessionId))?.boundAt).not.toBeNull();
// A replay of the same claim inserts no second agent.
await expect(
agentService(db).create(scope.companyId, createInput(scope), {
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
}),
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
expect(await countAgents(scope.companyId)).toBe(1);
});
it("rejects a missing claim and inserts no binding", async () => {
const scope = await seedScope();
await expect(
agentService(db).create(scope.companyId, createInput(scope), {
claudeLogin: { storedSessionId: undefined, ownerUserId: scope.ownerUserId },
}),
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
expect(await countAgents(scope.companyId)).toBe(0);
});
it("rejects a foreign-owner claim and leaves it unconsumed", async () => {
const scope = await seedScope();
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000);
await expect(
agentService(db).create(scope.companyId, createInput(scope), {
claudeLogin: { storedSessionId: sessionId, ownerUserId: "intruder" },
}),
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
expect((await readClaim(sessionId))?.boundAt).toBeNull();
expect(await countAgents(scope.companyId)).toBe(0);
});
it("rejects an expired claim and leaves it unconsumed", async () => {
const scope = await seedScope();
const sessionId = await seedStoredClaim(scope, Date.now() - 1_000);
await expect(
agentService(db).create(scope.companyId, createInput(scope), {
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
}),
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
expect((await readClaim(sessionId))?.boundAt).toBeNull();
});
it("rejects a non-stored claim and leaves it unconsumed", async () => {
const scope = await seedScope();
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000, "awaiting_code");
await expect(
agentService(db).create(scope.companyId, createInput(scope), {
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
}),
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
expect((await readClaim(sessionId))?.boundAt).toBeNull();
});
it("returns a byte-identical error for every reject reason", async () => {
const messages: string[] = [];
// Missing claim.
const missingScope = await seedScope();
messages.push(
await agentService(db)
.create(missingScope.companyId, createInput(missingScope), {
claudeLogin: { storedSessionId: undefined, ownerUserId: missingScope.ownerUserId },
})
.then(() => "no-error")
.catch((error: Error) => error.message),
);
// Foreign owner.
const foreignScope = await seedScope();
const foreignSession = await seedStoredClaim(foreignScope, Date.now() + 60_000);
messages.push(
await agentService(db)
.create(foreignScope.companyId, createInput(foreignScope), {
claudeLogin: { storedSessionId: foreignSession, ownerUserId: "intruder" },
})
.then(() => "no-error")
.catch((error: Error) => error.message),
);
// Expired.
const expiredScope = await seedScope();
const expiredSession = await seedStoredClaim(expiredScope, Date.now() - 1_000);
messages.push(
await agentService(db)
.create(expiredScope.companyId, createInput(expiredScope), {
claudeLogin: { storedSessionId: expiredSession, ownerUserId: expiredScope.ownerUserId },
})
.then(() => "no-error")
.catch((error: Error) => error.message),
);
// Non-stored.
const nonStoredScope = await seedScope();
const nonStoredSession = await seedStoredClaim(nonStoredScope, Date.now() + 60_000, "submitting");
messages.push(
await agentService(db)
.create(nonStoredScope.companyId, createInput(nonStoredScope), {
claudeLogin: { storedSessionId: nonStoredSession, ownerUserId: nonStoredScope.ownerUserId },
})
.then(() => "no-error")
.catch((error: Error) => error.message),
);
// Already consumed.
const consumedScope = await seedScope();
const consumedSession = await seedStoredClaim(consumedScope, Date.now() + 60_000);
await agentService(db).create(consumedScope.companyId, createInput(consumedScope), {
claudeLogin: { storedSessionId: consumedSession, ownerUserId: consumedScope.ownerUserId },
});
messages.push(
await agentService(db)
.create(consumedScope.companyId, createInput(consumedScope), {
claudeLogin: { storedSessionId: consumedSession, ownerUserId: consumedScope.ownerUserId },
})
.then(() => "no-error")
.catch((error: Error) => error.message),
);
expect(new Set(messages)).toEqual(new Set([CLAUDE_OAUTH_CLAIM_REJECTED]));
});
it("consumes one claim exactly once under two concurrent creates", async () => {
const scope = await seedScope();
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000);
const results = await Promise.allSettled([
agentService(db).create(scope.companyId, createInput(scope), {
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
}),
agentService(db).create(scope.companyId, createInput(scope), {
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
}),
]);
const fulfilled = results.filter((result) => result.status === "fulfilled");
const rejected = results.filter((result) => result.status === "rejected");
expect(fulfilled).toHaveLength(1);
expect(rejected).toHaveLength(1);
expect((rejected[0] as PromiseRejectedResult).reason.message).toBe(CLAUDE_OAUTH_CLAIM_REJECTED);
expect(await countAgents(scope.companyId)).toBe(1);
});
it("inserts no binding when the claim row lock holds until after the deadline", async () => {
const scope = await seedScope();
const sessionId = await seedStoredClaim(scope, Date.now() + 3_600_000);
const lockDb = createDb(connectionString);
let signalLocked!: () => void;
const locked = new Promise<void>((resolve) => {
signalLocked = resolve;
});
let releaseGate!: () => void;
const gate = new Promise<void>((resolve) => {
releaseGate = resolve;
});
const lockHeld = lockDb.transaction(async (tx) => {
await tx.execute(
sql`SELECT bound_at FROM adapter_auth_sessions WHERE public_session_id = ${sessionId} FOR UPDATE`,
);
await tx.execute(
sql`UPDATE adapter_auth_sessions SET expires_at = clock_timestamp() + interval '300 milliseconds' WHERE public_session_id = ${sessionId}`,
);
signalLocked();
await gate;
});
await locked;
const createPromise = agentService(db)
.create(scope.companyId, createInput(scope), {
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
})
.then(() => "created")
.catch((error: Error) => error.message);
await new Promise((resolve) => setTimeout(resolve, 600));
releaseGate();
await lockHeld;
expect(await createPromise).toBe(CLAUDE_OAUTH_CLAIM_REJECTED);
expect((await readClaim(sessionId))?.boundAt).toBeNull();
expect(await countAgents(scope.companyId)).toBe(0);
await lockDb.$client.end();
});
it("removes the fixed binding on a normal update", async () => {
const scope = await seedScope();
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000);
const created = await agentService(db).create(scope.companyId, createInput(scope), {
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
});
const updated = await agentService(db).update(created.id, { adapterConfig: { env: {} } });
const updatedEnv = (updated?.adapterConfig as { env: Record<string, unknown> }).env;
expect(updatedEnv.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
});
it("re-points the binding to a plain value on a normal update", async () => {
const scope = await seedScope();
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000);
const created = await agentService(db).create(scope.companyId, createInput(scope), {
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
});
const updated = await agentService(db).update(created.id, {
adapterConfig: { env: { CLAUDE_CODE_OAUTH_TOKEN: { type: "plain", value: "sk-fake" } } },
});
const updatedEnv = (updated?.adapterConfig as { env: Record<string, unknown> }).env;
expect(updatedEnv.CLAUDE_CODE_OAUTH_TOKEN).toMatchObject({ type: "plain", value: "sk-fake" });
});
it("removes the binding by a move to another adapter type on a normal update", async () => {
const scope = await seedScope();
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000);
const created = await agentService(db).create(scope.companyId, createInput(scope), {
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
});
// The write moves the agent to the process adapter and drops the binding in
// the same PATCH. The binding is a normal environment variable, so the write
// succeeds and drops both the fixed binding and the claude_local adapter.
const updated = await agentService(db).update(created.id, {
adapterType: "process",
adapterConfig: { env: {} },
});
expect(updated?.adapterType).toBe("process");
const updatedEnv = (updated?.adapterConfig as { env: Record<string, unknown> }).env;
expect(updatedEnv.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
});
it("keeps an existing binding when the update changes another field", async () => {
const scope = await seedScope();
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000);
const created = await agentService(db).create(scope.companyId, createInput(scope), {
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
});
const updated = await agentService(db).update(created.id, {
adapterConfig: { model: "claude-opus", env: { CLAUDE_CODE_OAUTH_TOKEN: { ...FIXED_BINDING } } },
});
const env = (updated?.adapterConfig as { env: Record<string, unknown> }).env;
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toMatchObject(FIXED_BINDING);
});
it("rejects a newly introduced binding on update, because it carries no claim", async () => {
const scope = await seedScope();
const created = await agentService(db).create(scope.companyId, {
name: "Api Key Agent",
role: "engineer",
status: "idle",
adapterType: "claude_local",
defaultEnvironmentId: scope.environmentId,
adapterConfig: { env: {} },
runtimeConfig: {},
spentMonthlyCents: 0,
lastHeartbeatAt: null,
});
await expect(
agentService(db).update(created.id, {
adapterConfig: { env: { CLAUDE_CODE_OAUTH_TOKEN: { ...FIXED_BINDING } } },
}),
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
const reloaded = await agentService(db).getById(created.id);
const reloadedEnv = (reloaded?.adapterConfig as { env: Record<string, unknown> }).env;
expect(reloadedEnv.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
});
// --- The user-actor apply-existing path (no login round trip) --------------
it("binds the fixed reference from a stored owner value with no claim", async () => {
const scope = await seedScope();
await seedStoredOwnerValue(scope);
const created = await agentService(db).create(scope.companyId, createInput(scope), {
claudeLogin: { ownerUserId: scope.ownerUserId, applyExistingWithoutClaim: true },
});
const persisted = created.adapterConfig as { env: Record<string, unknown> };
expect(persisted.env.CLAUDE_CODE_OAUTH_TOKEN).toMatchObject({
type: "user_secret_ref",
key: "CLAUDE_CODE_OAUTH_TOKEN",
});
// The response carries the reference only. It carries no token value.
expect(JSON.stringify(created)).not.toContain("sk-owner-token");
expect(await countAgents(scope.companyId)).toBe(1);
// The path consumes no setup-token claim, so it needs no login round trip.
const claims = await db.select().from(adapterAuthSessions);
expect(claims).toHaveLength(0);
});
it("binds on a hire-shaped create that needs board approval", async () => {
const scope = await seedScope();
await seedStoredOwnerValue(scope);
const created = await agentService(
db,
).create(scope.companyId, { ...createInput(scope), status: "pending_approval" }, {
claudeLogin: { ownerUserId: scope.ownerUserId, applyExistingWithoutClaim: true },
});
expect(created.status).toBe("pending_approval");
const persisted = created.adapterConfig as { env: Record<string, unknown> };
expect(persisted.env.CLAUDE_CODE_OAUTH_TOKEN).toMatchObject(FIXED_BINDING);
});
it("binds the fixed reference on an update from a stored owner value with no claim", async () => {
const scope = await seedScope();
await seedStoredOwnerValue(scope);
const created = await agentService(db).create(scope.companyId, {
name: "Claude Agent",
role: "engineer",
status: "idle",
adapterType: "claude_local",
defaultEnvironmentId: scope.environmentId,
adapterConfig: { env: {} },
runtimeConfig: {},
spentMonthlyCents: 0,
lastHeartbeatAt: null,
});
const updated = await agentService(db).update(
created.id,
{ adapterConfig: { env: { CLAUDE_CODE_OAUTH_TOKEN: { ...FIXED_BINDING } } } },
{ claudeLogin: { ownerUserId: scope.ownerUserId, applyExistingWithoutClaim: true } },
);
const env = (updated?.adapterConfig as { env: Record<string, unknown> }).env;
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toMatchObject(FIXED_BINDING);
});
it("rejects apply-existing when the owner has no stored value and binds nothing", async () => {
const scope = await seedScope();
await expect(
agentService(db).create(scope.companyId, createInput(scope), {
claudeLogin: { ownerUserId: scope.ownerUserId, applyExistingWithoutClaim: true },
}),
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
// A rejected bind creates neither the agent nor the fixed definition.
expect(await countAgents(scope.companyId)).toBe(0);
expect(await countUserSecretDefinitions(scope.companyId)).toBe(0);
});
it("rejects apply-existing when no owner is derived (an agent actor)", async () => {
const scope = await seedScope();
await seedStoredOwnerValue(scope);
// The route sets no owner for an agent actor. The gate rejects the no-claim
// bind, so an agent actor never binds the fixed reference.
await expect(
agentService(db).create(scope.companyId, createInput(scope), {
claudeLogin: { ownerUserId: null, applyExistingWithoutClaim: true },
}),
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
expect(await countAgents(scope.companyId)).toBe(0);
});
it("rejects apply-existing when the stored value belongs to another company", async () => {
const ownerScope = await seedScope();
await seedStoredOwnerValue(ownerScope);
// The same owner, a different company. The owner value is company-scoped, so
// the status read finds nothing for the foreign company.
const foreignScope = await seedScope();
foreignScope.ownerUserId = ownerScope.ownerUserId;
await expect(
agentService(db).create(foreignScope.companyId, createInput(foreignScope), {
claudeLogin: { ownerUserId: foreignScope.ownerUserId, applyExistingWithoutClaim: true },
}),
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
expect(await countAgents(foreignScope.companyId)).toBe(0);
});
it("binds nothing from the flag alone when the config carries no fixed binding", async () => {
const scope = await seedScope();
await seedStoredOwnerValue(scope);
// The apply-existing flag is set, but the config carries no fixed binding. The
// gate mints the fixed reference only when the client presents the exact fixed
// binding, so the flag alone binds nothing and creates no fixed definition.
const created = await agentService(db).create(
scope.companyId,
{
name: "Plain Agent",
role: "engineer",
status: "idle",
adapterType: "claude_local",
defaultEnvironmentId: scope.environmentId,
adapterConfig: { env: {} },
runtimeConfig: {},
spentMonthlyCents: 0,
lastHeartbeatAt: null,
},
{ claudeLogin: { ownerUserId: scope.ownerUserId, applyExistingWithoutClaim: true } },
);
const env = (created.adapterConfig as { env: Record<string, unknown> }).env;
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
expect(await countDeclarationsForAgent(created.id)).toBe(0);
});
it("derives the persisted binding shape, so a client secret selector never rides through", async () => {
const scope = await seedScope();
await seedStoredOwnerValue(scope);
// A client adds a secret id and a foreign owner to the binding. The persist
// normalization keeps only the fixed reference fields, so the extra selectors
// never reach the stored binding. The runtime derives the owner from context.
const created = await agentService(db).create(
scope.companyId,
createInput(scope, {
CLAUDE_CODE_OAUTH_TOKEN: {
type: "user_secret_ref",
key: "CLAUDE_CODE_OAUTH_TOKEN",
secretId: "attacker-secret",
ownerUserId: "intruder",
},
}),
{ claudeLogin: { ownerUserId: scope.ownerUserId, applyExistingWithoutClaim: true } },
);
const binding = (created.adapterConfig as { env: Record<string, Record<string, unknown>> }).env
.CLAUDE_CODE_OAUTH_TOKEN;
expect(binding).toMatchObject({ type: "user_secret_ref", key: "CLAUDE_CODE_OAUTH_TOKEN" });
expect(binding.secretId).toBeUndefined();
expect(binding.ownerUserId).toBeUndefined();
});
it("resolves the bound reference to the stored value without leaking it in a log", async () => {
const scope = await seedScope();
await seedStoredOwnerValue(scope, "sk-secret-resolve");
const created = await agentService(db).create(scope.companyId, createInput(scope), {
claudeLogin: { ownerUserId: scope.ownerUserId, applyExistingWithoutClaim: true },
});
// The successful bind writes a declaration for the agent, so the fixed
// reference resolves at runtime. The declaration and the definition carry no
// token value.
expect(await countDeclarationsForAgent(created.id)).toBe(1);
const definitions = await db.select().from(userSecretDefinitions);
expect(JSON.stringify(definitions)).not.toContain("sk-secret-resolve");
});
// --- The hire-inheritance path (no login round trip, no stored owner value) -
async function seedParentAgent(
parentScope: Scope,
options: { adapterType?: string; holdsFixedBinding?: boolean; version?: number } = {},
) {
const [row] = await db
.insert(agents)
.values({
companyId: parentScope.companyId,
name: `Parent ${randomUUID().slice(0, 8)}`,
role: "engineer",
status: "idle",
adapterType: options.adapterType ?? "claude_local",
adapterConfig: {
env:
options.holdsFixedBinding === false
? {}
// A binding written through the normal persistence path always
// carries a resolved version, "latest" by default. Match that
// shape here, so only `options.version` simulates a pinned
// version, or a version change since the child copied it.
: { CLAUDE_CODE_OAUTH_TOKEN: { ...FIXED_BINDING, version: options.version ?? "latest" } },
},
runtimeConfig: {},
})
.returning();
return row!;
}
async function countDeclarationsForCompany(companyId: string): Promise<number> {
const rows = await db
.select()
.from(userSecretDeclarations)
.where(eq(userSecretDeclarations.companyId, companyId));
return rows.length;
}
it("binds the inherited fixed reference from a named claude_local parent with no claim and no stored owner value", async () => {
const scope = await seedScope();
const parent = await seedParentAgent(scope);
const created = await agentService(db).create(scope.companyId, createInput(scope), {
claudeLogin: { inheritedFromAgentId: parent.id },
});
const persisted = created.adapterConfig as { env: Record<string, unknown> };
expect(persisted.env.CLAUDE_CODE_OAUTH_TOKEN).toMatchObject(FIXED_BINDING);
expect(await countDeclarationsForAgent(created.id)).toBe(1);
});
it("binds the inherited reference when the child's copied version still matches the parent's current version", async () => {
const scope = await seedScope();
const parent = await seedParentAgent(scope, { version: 5 });
const created = await agentService(db).create(
scope.companyId,
createInput(scope, { CLAUDE_CODE_OAUTH_TOKEN: { ...FIXED_BINDING, version: 5 } }),
{ claudeLogin: { inheritedFromAgentId: parent.id } },
);
const persisted = created.adapterConfig as { env: Record<string, unknown> };
expect(persisted.env.CLAUDE_CODE_OAUTH_TOKEN).toMatchObject({ ...FIXED_BINDING, version: 5 });
expect(await countDeclarationsForAgent(created.id)).toBe(1);
});
it("rejects an inherited claim when the parent's version moved after the route copied the child's reference", async () => {
const scope = await seedScope();
// The parent now holds version 5. The child's reference, copied before this
// transaction, still names version 2 — a concurrent parent rotation moved
// the parent's version between the copy and this write.
const parent = await seedParentAgent(scope, { version: 5 });
await expect(
agentService(db).create(
scope.companyId,
createInput(scope, { CLAUDE_CODE_OAUTH_TOKEN: { ...FIXED_BINDING, version: 2 } }),
{ claudeLogin: { inheritedFromAgentId: parent.id } },
),
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
// Only the seeded parent exists; the rejected create inserted no child.
expect(await countAgents(scope.companyId)).toBe(1);
expect(await countDeclarationsForCompany(scope.companyId)).toBe(0);
});
it("rejects an inherited claim when the named parent holds no fixed binding", async () => {
const scope = await seedScope();
const parent = await seedParentAgent(scope, { holdsFixedBinding: false });
await expect(
agentService(db).create(scope.companyId, createInput(scope), {
claudeLogin: { inheritedFromAgentId: parent.id },
}),
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
// Only the seeded parent exists; the rejected create inserted no child.
expect(await countAgents(scope.companyId)).toBe(1);
expect(await countDeclarationsForCompany(scope.companyId)).toBe(0);
});
it("rejects an inherited claim naming a parent in another company", async () => {
const scope = await seedScope();
const foreignScope = await seedScope();
const parent = await seedParentAgent(foreignScope);
await expect(
agentService(db).create(scope.companyId, createInput(scope), {
claudeLogin: { inheritedFromAgentId: parent.id },
}),
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
expect(await countAgents(scope.companyId)).toBe(0);
expect(await countDeclarationsForCompany(scope.companyId)).toBe(0);
expect(await countUserSecretDefinitions(scope.companyId)).toBe(0);
});
it("rejects an inherited claim naming an unknown or deleted parent", async () => {
const scope = await seedScope();
await expect(
agentService(db).create(scope.companyId, createInput(scope), {
claudeLogin: { inheritedFromAgentId: randomUUID() },
}),
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
expect(await countAgents(scope.companyId)).toBe(0);
expect(await countDeclarationsForCompany(scope.companyId)).toBe(0);
});
it("rejects an inherited claim naming a non-claude_local parent that still holds the fixed binding shape", async () => {
const scope = await seedScope();
// The parent's stored env happens to carry the exact fixed-binding shape
// under a non-claude_local adapter type. Complete mediation requires the
// gate to check the adapter type itself, not only the binding shape.
const parent = await seedParentAgent(scope, { adapterType: "codex_local" });
await expect(
agentService(db).create(scope.companyId, createInput(scope), {
claudeLogin: { inheritedFromAgentId: parent.id },
}),
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
// Only the seeded parent exists; the rejected create inserted no child.
expect(await countAgents(scope.companyId)).toBe(1);
expect(await countDeclarationsForCompany(scope.companyId)).toBe(0);
});
it("rejects an inherited claim when a concurrent parent rotation commits while this create waits on the parent row lock", async () => {
const scope = await seedScope();
const parent = await seedParentAgent(scope, { version: 5 });
const lockDb = createDb(connectionString);
let signalLocked!: () => void;
const locked = new Promise<void>((resolve) => {
signalLocked = resolve;
});
let releaseGate!: () => void;
const gate = new Promise<void>((resolve) => {
releaseGate = resolve;
});
// Simulate a credential rotation on the parent: it takes the row lock
// first, moves the bound version to 6, then holds the open transaction
// until the gate releases.
const rotationHeld = lockDb.transaction(async (tx) => {
await tx.execute(sql`SELECT id FROM agents WHERE id = ${parent.id} FOR UPDATE`);
await tx.execute(
sql`UPDATE agents SET adapter_config = jsonb_set(adapter_config, '{env,CLAUDE_CODE_OAUTH_TOKEN,version}', '6') WHERE id = ${parent.id}`,
);
signalLocked();
await gate;
});
await locked;
// The child's copied reference still names version 5, the version the
// route read before the rotation started. The create call must wait for
// the parent row lock, so it can only proceed once the rotation commits.
const createPromise = agentService(db)
.create(
scope.companyId,
createInput(scope, { CLAUDE_CODE_OAUTH_TOKEN: { ...FIXED_BINDING, version: 5 } }),
{ claudeLogin: { inheritedFromAgentId: parent.id } },
)
.then(() => "created")
.catch((error: Error) => error.message);
await new Promise((resolve) => setTimeout(resolve, 300));
releaseGate();
await rotationHeld;
// The create call reads the parent's committed post-rotation version, so
// it detects the mismatch against the child's stale copy and rejects the
// claim. Without the row lock, the create call could read the parent's
// pre-rotation version and bind the child to a reference the parent no
// longer holds.
expect(await createPromise).toBe(CLAUDE_OAUTH_CLAIM_REJECTED);
expect(await countAgents(scope.companyId)).toBe(1);
await lockDb.$client.end();
});
// --- The atomic credential-claim writer (item 2) ---------------------------
function claimScope(scope: Scope): SetupTokenSessionScope {
return {
companyId: scope.companyId,
ownerUserId: scope.ownerUserId,
adapterType: "claude_local",
environmentId: scope.environmentId,
};
}
it("commits the stored transition and the secret together on a first write", async () => {
const scope = await seedScope();
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000, "submitting");
const writer = createSetupTokenSecretWriter({ db });
await writer({ scope: claimScope(scope), sessionId, token: "sk-ant-oat01-FIRSTWRITE" });
// The durable row transitioned to `stored` and stays unconsumed.
const claim = await readClaim(sessionId);
expect(claim?.status).toBe("stored");
expect(claim?.boundAt).toBeNull();
// The owner value committed in the same transaction.
const status = await secretService(db).readClaudeOAuthUserSecretStatus(
scope.companyId,
scope.ownerUserId,
);
expect(status).not.toBeNull();
expect(status?.latestVersion).toBe(1);
});
it("rolls back the transition and writes no secret when the secret write fails", async () => {
const scope = await seedScope();
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000, "submitting");
// Inject a completion that fails after the transition. The whole transaction
// rolls back, so neither the claim nor the secret commits.
const writer = createSetupTokenSecretWriter({
db,
completionForTx: () => ({
async completeClaudeOAuthUserSecret() {
throw new Error("storage down");
},
}),
});
await expect(
writer({ scope: claimScope(scope), sessionId, token: "sk-ant-oat01-ROLLBACK" }),
).rejects.toThrow();
const claim = await readClaim(sessionId);
expect(claim?.status).toBe("submitting");
expect(claim?.boundAt).toBeNull();
expect(
await secretService(db).readClaudeOAuthUserSecretStatus(scope.companyId, scope.ownerUserId),
).toBeNull();
expect(await countUserSecretDefinitions(scope.companyId)).toBe(0);
});
it("writes no secret for an expired claim", async () => {
const scope = await seedScope();
const sessionId = await seedStoredClaim(scope, Date.now() - 1_000, "submitting");
const writer = createSetupTokenSecretWriter({ db });
await expect(
writer({ scope: claimScope(scope), sessionId, token: "sk-ant-oat01-EXPIRED" }),
).rejects.toThrow();
expect((await readClaim(sessionId))?.status).toBe("submitting");
expect(
await secretService(db).readClaudeOAuthUserSecretStatus(scope.companyId, scope.ownerUserId),
).toBeNull();
});
it("writes no secret for an already-consumed claim", async () => {
const scope = await seedScope();
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000);
// Consume the stored claim first, so `bound_at` is set and the predecessor
// predicate no longer matches.
await createDbSetupTokenCleanupStore(db).consumeStoredClaim({
sessionId,
companyId: scope.companyId,
ownerUserId: scope.ownerUserId,
adapterType: "claude_local",
environmentId: scope.environmentId,
});
const writer = createSetupTokenSecretWriter({ db });
await expect(
writer({ scope: claimScope(scope), sessionId, token: "sk-ant-oat01-BOUND" }),
).rejects.toThrow();
expect(
await secretService(db).readClaudeOAuthUserSecretStatus(scope.companyId, scope.ownerUserId),
).toBeNull();
});
it("rotates the stored value under the captured version on a confirmed overwrite", async () => {
const scope = await seedScope();
await seedStoredOwnerValue(scope, "sk-old-value");
const before = await secretService(db).readClaudeOAuthUserSecretStatus(
scope.companyId,
scope.ownerUserId,
);
expect(before).not.toBeNull();
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000, "submitting");
const writer = createSetupTokenSecretWriter({ db });
await writer({
scope: {
...claimScope(scope),
confirmedOverwrite: {
expectedSecretId: before!.secretId,
expectedLatestVersion: before!.latestVersion,
},
},
sessionId,
token: "sk-ant-oat01-ROTATED",
});
// The claim transitioned and the stored value rotated to a new version.
expect((await readClaim(sessionId))?.status).toBe("stored");
const after = await secretService(db).readClaudeOAuthUserSecretStatus(
scope.companyId,
scope.ownerUserId,
);
expect(after?.secretId).toBe(before!.secretId);
expect(after?.latestVersion).toBe(before!.latestVersion + 1);
});
it("deletes a cleanup row only under the full owner scope", async () => {
const scope = await seedScope();
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000);
const store = createDbSetupTokenCleanupStore(db);
const identity = {
sessionId,
companyId: scope.companyId,
ownerUserId: scope.ownerUserId,
adapterType: "claude_local",
environmentId: scope.environmentId,
};
// A foreign-owner identity with the right session id does not delete the row.
await store.remove({ ...identity, ownerUserId: "intruder" });
expect(await readClaim(sessionId)).toBeTruthy();
// The full-scope identity deletes the row.
await store.remove(identity);
expect(await readClaim(sessionId)).toBeUndefined();
});
});