Files
PaperClipAI/server/src/__tests__/agent-hire-auth-inheritance-routes.test.ts
T
DottaandPaperclip 7944ed3d97 fix(runner): preserve hire runtime safety and first-activity timing (#13852)
## Thinking Path

> - Paperclip is the open source control plane for companies of AI
agents.
> - Native runner agents need governed tools, durable runtime state, and
useful execution evidence.
> - A first activity trace waited 53.467 seconds even though tool
activity took 6.274 seconds; provider input arrived before the server
API call executed.
> - Native agents also need a safe way to hire teammates without asking
the model to rebuild runtime configuration.
> - This pull request separates the observed ACP input-stream window
from the actual server `tool.execute` span and adds a server-owned
native hire contract.
> - The benefit is clearer latency evidence and safer native teammates
with existing approval, auth, and company boundaries preserved.

## Linked Issues or Issue Description

Related Daytona provenance work is in
[#13814](https://github.com/paperclipai/paperclip/pull/13814). No
duplicate public PR was found for this combined timing and native-hire
change.

**What existing behavior does this improve?**

Native runner agents can use governed tools and request hires. The
server did not expose a safe native hire operation that reused the
caller's validated runtime settings. First-activity traces also mixed
provider input timing with server tool execution timing.

**Current behavior**

A native hire must construct a separate runner configuration. Full
configuration copying could expose paths, instructions, secrets, or
sessions. Timing evidence could make a provider or MCP identity join
appear proven when the trace did not contain that join.

**Proposed behavior**

The native `hire_agent` operation accepts identity and persona inputs.
The server sends `adapterType: "paperclip_runner"` with
`inheritRuntimeFrom: "caller"`, then copies only validated provider,
model, permission, lifecycle, and bounded execution settings. It
inherits and validates the default environment, derives the managed AI
binding through existing normalization, preserves approval and
permissions, and creates fresh child instructions. Caller secrets,
paths, prompts, and sessions are excluded.

Provider events now include the optional boolean `inputUpdated`, with
Rust forwarding support. Timing evidence separately records the ACP
input-stream window and the actual server `tool.execute` activity. It
does not claim a provider or MCP join without matching evidence.

**Reason and benefit**

Native agents can hire teammates that start with the caller's approved
execution policy. Operators retain company boundaries, auth rules,
approval gates, and requalification. Reviewers can distinguish provider
streaming time from server API execution time when diagnosing
first-activity delays.

**Breaking changes**

None for existing hires or tool calls. `inheritRuntimeFrom` is optional
and only applies to same-company native agent callers. Conflicting
explicit runtime settings are rejected. The provider event field is
optional for existing producers.

## What Changed

- Added the native `hire_agent` protocol action, catalog entry, API
contract, and runner authority checks.
- Added `inheritRuntimeFrom: "caller"` validation and a closed native
runtime inheritance allowlist.
- Preserved managed AI binding normalization, default-environment
validation, approval snapshots, permissions, requalification, and fresh
child instructions.
- Added provider `inputUpdated` schema support and Rust forwarding.
- Added first-activity and server tool timing evidence with conservative
identity-join handling.
- Added route, authority, provider-event, sidecar, API, catalog, and
Rust-focused tests.
- Kept private Honeycomb links, raw traces, and local result paths out
of this description.

## Verification

Focused checks passed:

- 458 timing/session checks.
- 61 native hire inheritance checks.
- 20 hire authority checks.
- 1,741 API checks.
- 106 catalog checks.
- 54 provider sidecar checks.
- 12 Rust provider checks.

Live R2 and R3 each passed 45 checks across 6 runs (361,135 ms for R2).
R1 stopped at missing Docker image setup. The final trace is available
at
https://ui.honeycomb.io/paperclip/environments/test/datasets/paperclip/result/BiMypLNvmiB?tab=traces.
Latest-head CI passed all required build, typecheck, Rust, static,
Vitest, serialized-server, workspace, chat, and E2E jobs. The focused
local checks listed above passed; the broad local suite was not run
before the live evaluation, while CI provides the full repository
verification.

## Risks

- Timing fields describe separate observed windows. They do not prove a
provider or MCP owner without a valid trace join.
- The inheritance allowlist must stay synchronized with native runner
configuration fields.
- Approval snapshots include resolved safe inherited settings and should
be reviewed when native configuration fields change.
- The focused local suite is narrower than the full repository suite;
latest-head CI covers the broader repository checks.

> Roadmap review: `ROADMAP.md` places this work within Paperclip's
bring-your-own-agent direction. It extends existing native runner hiring
and observability behavior.

## Model Used

OpenAI GPT-6 (exact serving model ID is not exposed), with extended
reasoning and repository tool use; GPT-5.6 Luna assisted with focused
implementation and verification work.

## 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 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-09-22 23:16:11 -05:00

590 lines
23 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { mkdirSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import express from "express";
import request from "supertest";
import { and, eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agents,
companies,
companyMemberships,
companySecretBindings,
companySecretProviderConfigs,
companySecretVersions,
companySecrets,
createDb,
environments,
approvals,
principalPermissionGrants,
userSecretDeclarations,
userSecretDefinitions,
} from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { errorHandler } from "../middleware/index.js";
import { agentRoutes } from "../routes/agents.js";
import { secretService } from "../services/secrets.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping agent-hire credential inheritance route tests on this host: ${
embeddedPostgresSupport.reason ?? "unsupported environment"
}`,
);
}
type Db = ReturnType<typeof createDb>;
// 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_CLAUDE_OAUTH_BINDING = { type: "user_secret_ref", key: "CLAUDE_CODE_OAUTH_TOKEN" } as const;
function agentActor(companyId: string, agentId: string): Express.Request["actor"] {
return { type: "agent", agentId, companyId, source: "agent_jwt" };
}
// The local implicit board actor always passes authorization, so it stands in
// for a user actor without needing a seeded company membership.
function userActor(): Express.Request["actor"] {
return { type: "board", userId: "local-board", source: "local_implicit" };
}
function createApp(db: Db, actor: Express.Request["actor"]) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = actor;
next();
});
app.use("/api", agentRoutes(db));
app.use(errorHandler);
return app;
}
describeEmbeddedPostgres("hired agent provider credential inheritance", () => {
let db!: Db;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
const secretsTmpDir = path.join(os.tmpdir(), `paperclip-agent-hire-auth-inheritance-${randomUUID()}`);
beforeAll(async () => {
mkdirSync(secretsTmpDir, { recursive: true });
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key");
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-agent-hire-auth-inheritance-");
db = createDb(tempDb.connectionString);
}, 60_000);
afterEach(async () => {
await db.delete(approvals);
await db.delete(activityLog);
await db.delete(userSecretDeclarations);
await db.delete(userSecretDefinitions);
await db.delete(companySecretBindings);
await db.delete(companySecretVersions);
await db.delete(companySecrets);
await db.delete(companySecretProviderConfigs);
await db.delete(principalPermissionGrants);
await db.delete(companyMemberships);
await db.delete(agents);
await db.delete(environments);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
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 });
});
async function seedCompany() {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: `Auth Inheritance Co ${companyId.slice(0, 8)}`,
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
return companyId;
}
async function seedParentAgent(companyId: string, adapterType: string, env: Record<string, unknown>) {
const [row] = await db
.insert(agents)
.values({
companyId,
name: `Parent ${randomUUID().slice(0, 8)}`,
role: "engineer",
adapterType,
adapterConfig: { env },
runtimeConfig: {},
permissions: { canCreateAgents: true },
})
.returning();
return row!;
}
async function createCompanySecret(companyId: string, value: string) {
return secretService(db).create(companyId, {
name: `cred-${randomUUID()}`,
provider: "local_encrypted",
value,
});
}
function secretRef(secretId: string, extra: Record<string, unknown> = {}) {
return { type: "secret_ref" as const, secretId, ...extra };
}
function hire(actor: Express.Request["actor"], companyId: string, payload: Record<string, unknown>) {
const app = createApp(db, actor);
return request(app).post(`/api/companies/${companyId}/agent-hires`).send(payload);
}
function childEnvOf(res: request.Response): Record<string, unknown> {
return ((res.body.agent?.adapterConfig as { env?: Record<string, unknown> } | undefined)?.env) ?? {};
}
it("inherits the parent secret_ref for a codex_local hire with no env, and derives the child binding", async () => {
const companyId = await seedCompany();
const secret = await createCompanySecret(companyId, "sk-openai-parent");
const parent = await seedParentAgent(companyId, "codex_local", {
OPENAI_API_KEY: secretRef(secret.id),
});
const res = await hire(agentActor(companyId, parent.id), companyId, {
name: "Codex Child",
role: "engineer",
adapterType: "codex_local",
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(childEnvOf(res).OPENAI_API_KEY).toMatchObject({ type: "secret_ref", secretId: secret.id });
const bindings = await db
.select()
.from(companySecretBindings)
.where(
and(
eq(companySecretBindings.companyId, companyId),
eq(companySecretBindings.targetType, "agent"),
eq(companySecretBindings.targetId, res.body.agent.id),
),
);
expect(bindings).toHaveLength(1);
expect(bindings[0]).toMatchObject({
configPath: "env.OPENAI_API_KEY",
secretId: secret.id,
versionSelector: "latest",
});
});
it("keeps a pinned version selector on the copied reference and its derived binding", async () => {
const companyId = await seedCompany();
const secret = await createCompanySecret(companyId, "sk-openai-pinned");
const parent = await seedParentAgent(companyId, "codex_local", {
OPENAI_API_KEY: secretRef(secret.id, { version: 3 }),
});
const res = await hire(agentActor(companyId, parent.id), companyId, {
name: "Codex Pinned Child",
role: "engineer",
adapterType: "codex_local",
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(childEnvOf(res).OPENAI_API_KEY).toMatchObject({ type: "secret_ref", secretId: secret.id, version: 3 });
const bindings = await db
.select()
.from(companySecretBindings)
.where(
and(
eq(companySecretBindings.companyId, companyId),
eq(companySecretBindings.targetId, res.body.agent.id),
),
);
expect(bindings[0]?.versionSelector).toBe("3");
});
it("retains version, required, and allowMissingOverride on a copied user_secret_ref", async () => {
const companyId = await seedCompany();
await secretService(db).createUserSecretDefinition(companyId, {
key: "openai_shared_key",
name: "Shared OpenAI key",
provider: "local_encrypted",
});
const parent = await seedParentAgent(companyId, "codex_local", {
OPENAI_API_KEY: {
type: "user_secret_ref",
key: "openai_shared_key",
version: 2,
required: false,
allowMissingOverride: true,
},
});
const res = await hire(agentActor(companyId, parent.id), companyId, {
name: "Codex User Secret Child",
role: "engineer",
adapterType: "codex_local",
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(childEnvOf(res).OPENAI_API_KEY).toMatchObject({
type: "user_secret_ref",
key: "openai_shared_key",
version: 2,
required: false,
allowMissingOverride: true,
});
});
it("inherits a grok_local parent's XAI_API_KEY reference", async () => {
const companyId = await seedCompany();
const secret = await createCompanySecret(companyId, "xai-parent-key");
const parent = await seedParentAgent(companyId, "grok_local", {
XAI_API_KEY: secretRef(secret.id),
});
const res = await hire(agentActor(companyId, parent.id), companyId, {
name: "Grok Child",
role: "engineer",
adapterType: "grok_local",
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(childEnvOf(res).XAI_API_KEY).toMatchObject({ type: "secret_ref", secretId: secret.id });
});
it("inherits a claude_local parent's ANTHROPIC_API_KEY reference", async () => {
const companyId = await seedCompany();
const secret = await createCompanySecret(companyId, "ant-parent-key");
const parent = await seedParentAgent(companyId, "claude_local", {
ANTHROPIC_API_KEY: secretRef(secret.id),
});
const res = await hire(agentActor(companyId, parent.id), companyId, {
name: "Claude Child",
role: "engineer",
adapterType: "claude_local",
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(childEnvOf(res).ANTHROPIC_API_KEY).toMatchObject({ type: "secret_ref", secretId: secret.id });
});
it("keeps the child-supplied ANTHROPIC_API_KEY and inherits no Claude credential at all", async () => {
const companyId = await seedCompany();
const childSecret = await createCompanySecret(companyId, "ant-child-key");
// The parent holds the fixed Claude OAuth binding, which is normally
// inheritable, but the child already supplies its own Claude credential.
const parent = await seedParentAgent(companyId, "claude_local", {
CLAUDE_CODE_OAUTH_TOKEN: { ...FIXED_CLAUDE_OAUTH_BINDING },
});
const res = await hire(agentActor(companyId, parent.id), companyId, {
name: "Claude Child With Own Key",
role: "engineer",
adapterType: "claude_local",
adapterConfig: { env: { ANTHROPIC_API_KEY: secretRef(childSecret.id) } },
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
const childEnv = childEnvOf(res);
expect(childEnv.ANTHROPIC_API_KEY).toMatchObject({ type: "secret_ref", secretId: childSecret.id });
expect(childEnv.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
});
it("does not inherit a plain environment value", async () => {
const companyId = await seedCompany();
const parent = await seedParentAgent(companyId, "grok_local", {
XAI_API_KEY: "xai-plain-value",
});
const res = await hire(agentActor(companyId, parent.id), companyId, {
name: "Grok Plain Value Child",
role: "engineer",
adapterType: "grok_local",
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(childEnvOf(res).XAI_API_KEY).toBeUndefined();
});
it("does not inherit across a mismatched adapter type", async () => {
const companyId = await seedCompany();
const secret = await createCompanySecret(companyId, "sk-openai-mismatch");
const parent = await seedParentAgent(companyId, "codex_local", {
OPENAI_API_KEY: secretRef(secret.id),
});
const res = await hire(agentActor(companyId, parent.id), companyId, {
name: "Claude Child From Codex Parent",
role: "engineer",
adapterType: "claude_local",
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(Object.keys(childEnvOf(res))).toHaveLength(0);
});
it("does not inherit a codex_local credential from a hiring agent in another company", async () => {
const parentCompanyId = await seedCompany();
const targetCompanyId = await seedCompany();
const secret = await createCompanySecret(parentCompanyId, "sk-openai-other-company");
const parent = await seedParentAgent(parentCompanyId, "codex_local", {
OPENAI_API_KEY: secretRef(secret.id),
});
// The actor claims the target company, but the named hiring agent belongs
// to a different company. The route must reject the request and must
// never copy the other company's credential reference.
const res = await hire(agentActor(targetCompanyId, parent.id), targetCompanyId, {
name: "Codex Cross-Company Child",
role: "engineer",
adapterType: "codex_local",
});
expect(res.status).toBe(403);
const children = await db.select().from(agents).where(eq(agents.companyId, targetCompanyId));
expect(children).toHaveLength(0);
});
it("does not inherit a grok_local credential from a hiring agent in another company", async () => {
const parentCompanyId = await seedCompany();
const targetCompanyId = await seedCompany();
const secret = await createCompanySecret(parentCompanyId, "xai-other-company");
const parent = await seedParentAgent(parentCompanyId, "grok_local", {
XAI_API_KEY: secretRef(secret.id),
});
const res = await hire(agentActor(targetCompanyId, parent.id), targetCompanyId, {
name: "Grok Cross-Company Child",
role: "engineer",
adapterType: "grok_local",
});
expect(res.status).toBe(403);
const children = await db.select().from(agents).where(eq(agents.companyId, targetCompanyId));
expect(children).toHaveLength(0);
});
it("does not inherit a claude_local credential, including the fixed OAuth binding, from a hiring agent in another company", async () => {
const parentCompanyId = await seedCompany();
const targetCompanyId = await seedCompany();
const parent = await seedParentAgent(parentCompanyId, "claude_local", {
ANTHROPIC_API_KEY: secretRef((await createCompanySecret(parentCompanyId, "ant-other-company")).id),
CLAUDE_CODE_OAUTH_TOKEN: { ...FIXED_CLAUDE_OAUTH_BINDING, version: 1 },
});
const res = await hire(agentActor(targetCompanyId, parent.id), targetCompanyId, {
name: "Claude Cross-Company Child",
role: "engineer",
adapterType: "claude_local",
});
expect(res.status).toBe(403);
const children = await db.select().from(agents).where(eq(agents.companyId, targetCompanyId));
expect(children).toHaveLength(0);
});
it("carves out a per-agent CODEX_HOME when the child inherits OPENAI_API_KEY", async () => {
const companyId = await seedCompany();
const secret = await createCompanySecret(companyId, "sk-openai-isolated");
const parent = await seedParentAgent(companyId, "codex_local", {
OPENAI_API_KEY: secretRef(secret.id),
});
const res = await hire(agentActor(companyId, parent.id), companyId, {
name: "Codex Isolated Child",
role: "engineer",
adapterType: "codex_local",
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
const childEnv = childEnvOf(res);
// A bare environment string persists as a `plain` binding.
const codexHome = childEnv.CODEX_HOME as { type: string; value: string } | undefined;
expect(codexHome?.type).toBe("plain");
expect(codexHome?.value.length ?? 0).toBeGreaterThan(0);
});
it("keeps the shared company Codex home when the child inherits no key", async () => {
const companyId = await seedCompany();
const parent = await seedParentAgent(companyId, "codex_local", {});
const res = await hire(agentActor(companyId, parent.id), companyId, {
name: "Codex Shared Home Child",
role: "engineer",
adapterType: "codex_local",
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(childEnvOf(res).CODEX_HOME).toBeUndefined();
});
it("adds no home override for a grok_local child with no inherited key", async () => {
const companyId = await seedCompany();
const parent = await seedParentAgent(companyId, "grok_local", {});
const res = await hire(agentActor(companyId, parent.id), companyId, {
name: "Grok No Key Child",
role: "engineer",
adapterType: "grok_local",
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
const childEnv = childEnvOf(res);
expect(childEnv.GROK_HOME).toBeUndefined();
expect(childEnv.XAI_API_KEY).toBeUndefined();
});
it("inherits nothing for a user actor hire", async () => {
const companyId = await seedCompany();
// No hiring-agent context exists for a user actor. The merge must not run
// at all, so an otherwise-inheritable key is simply never considered.
const res = await hire(userActor(), companyId, {
name: "User Hired Child",
role: "engineer",
adapterType: "claude_local",
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(Object.keys(childEnvOf(res))).toHaveLength(0);
});
it("rejects native runtime inheritance for a board actor", async () => {
const companyId = await seedCompany();
const parent = await seedParentAgent(companyId, "paperclip_runner", {});
const res = await hire(userActor(), companyId, {
name: "Board Inheritance Attempt", role: "engineer", adapterType: "paperclip_runner", inheritRuntimeFrom: "caller",
});
expect(res.status).toBe(403);
expect(parent.adapterType).toBe("paperclip_runner");
});
it("rejects inheritance from a legacy caller", async () => {
const companyId = await seedCompany();
const parent = await seedParentAgent(companyId, "codex_local", {});
const res = await hire(agentActor(companyId, parent.id), companyId, {
name: "Legacy Inheritance Attempt", role: "engineer", adapterType: "paperclip_runner", inheritRuntimeFrom: "caller",
});
expect(res.status).toBe(422);
expect(res.body.error).toContain("paperclip_runner adapter");
});
it("rejects native runtime inheritance across companies", async () => {
const parentCompanyId = await seedCompany();
const targetCompanyId = await seedCompany();
const parent = await seedParentAgent(parentCompanyId, "paperclip_runner", { provider: "codex", model: "gpt-5.6-sol" });
const res = await hire(agentActor(targetCompanyId, parent.id), targetCompanyId, {
name: "Cross Company Inheritance Attempt", role: "engineer", adapterType: "paperclip_runner", inheritRuntimeFrom: "caller",
});
expect(res.status).toBe(403);
expect(await db.select().from(agents).where(eq(agents.companyId, targetCompanyId))).toHaveLength(0);
});
it("records only inherited safe settings in an approval snapshot", async () => {
const companyId = await seedCompany();
await db.update(companies).set({ requireBoardApprovalForNewAgents: true }).where(eq(companies.id, companyId));
const parent = await seedParentAgent(companyId, "paperclip_runner", {});
await db.update(agents).set({ adapterConfig: {
provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5", acpxPermissionMode: "approve-all",
env: { ANTHROPIC_API_KEY: secretRef("parent-secret") }, cwd: "/private/parent",
invocationLimits: { maxIterations: 3, credentials: "never-copy" },
} }).where(eq(agents.id, parent.id));
const res = await hire(agentActor(companyId, parent.id), companyId, {
name: "Approval Snapshot Child", role: "engineer", adapterType: "paperclip_runner", inheritRuntimeFrom: "caller",
});
expect(res.status).toBe(201);
expect(res.body.agent.status).toBe("pending_approval");
expect(res.body.approval.payload.inheritRuntimeFrom).toBeUndefined();
expect(res.body.approval.payload.adapterConfig).toMatchObject({ provider: "acpx", acpxAgent: "claude", acpxPermissionMode: "approve-all" });
expect(res.body.approval.payload.adapterConfig.env).toBeUndefined();
expect(res.body.approval.payload.adapterConfig.cwd).toBeUndefined();
expect(res.body.approval.payload.adapterConfig.invocationLimits).toEqual({ maxIterations: 3 });
});
it.each([
["claude_managed", "managedProfileId", "managedAgentsRetentionAcknowledged", "Managed Agent"],
["aws_agentcore", "agentCoreProfileId", "agentCoreRetentionAcknowledged", "Remote Agent"],
])("revalidates the inherited %s company profile before creating a hire", async (provider, profileKey, retentionKey, label) => {
const companyId = await seedCompany();
const parent = await seedParentAgent(companyId, "paperclip_runner", {});
await db.update(agents).set({ adapterConfig: {
provider, [profileKey]: randomUUID(), [retentionKey]: true,
} }).where(eq(agents.id, parent.id));
const res = await hire(agentActor(companyId, parent.id), companyId, {
name: "Missing Profile Child", role: "engineer", adapterType: "paperclip_runner", inheritRuntimeFrom: "caller",
});
expect(res.status, JSON.stringify(res.body)).toBe(404);
expect(res.body.error).toBe(`${label} profile not found`);
expect(await db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(1);
});
it("validates and preserves an inherited caller default environment", async () => {
const companyId = await seedCompany();
const parent = await seedParentAgent(companyId, "paperclip_runner", {});
const environmentId = randomUUID();
await db.insert(environments).values({ id: environmentId, name: `Local ${environmentId}`, driver: "local" });
await db.update(agents).set({ defaultEnvironmentId: environmentId }).where(eq(agents.id, parent.id));
const res = await hire(agentActor(companyId, parent.id), companyId, {
name: "Invalid Environment Child", role: "engineer", adapterType: "paperclip_runner", inheritRuntimeFrom: "caller",
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(res.body.agent.defaultEnvironmentId).toBe(environmentId);
});
it("inherits the fixed Claude OAuth binding, keeps its version, and leaks no token value", async () => {
const companyId = await seedCompany();
const parent = await seedParentAgent(companyId, "claude_local", {
CLAUDE_CODE_OAUTH_TOKEN: { ...FIXED_CLAUDE_OAUTH_BINDING, version: 3 },
});
const res = await hire(agentActor(companyId, parent.id), companyId, {
name: "Claude OAuth Child",
role: "engineer",
adapterType: "claude_local",
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
const childAgentId = res.body.agent.id as string;
expect(childEnvOf(res).CLAUDE_CODE_OAUTH_TOKEN).toMatchObject({
type: "user_secret_ref",
key: "CLAUDE_CODE_OAUTH_TOKEN",
version: 3,
});
// The response body carries the reference only, never a token value.
expect(JSON.stringify(res.body)).not.toContain("sk-");
const declarations = await db
.select()
.from(userSecretDeclarations)
.where(eq(userSecretDeclarations.targetId, childAgentId));
expect(declarations).toHaveLength(1);
expect(declarations[0]).toMatchObject({
envKey: "CLAUDE_CODE_OAUTH_TOKEN",
configPath: "env.CLAUDE_CODE_OAUTH_TOKEN",
versionSelector: "3",
});
const hireActivity = await db
.select()
.from(activityLog)
.where(and(eq(activityLog.companyId, companyId), eq(activityLog.action, "agent.hire_created")));
expect(hireActivity).toHaveLength(1);
const detailsText = JSON.stringify(hireActivity[0]!.details);
expect(detailsText).not.toContain("adapterConfig");
expect(detailsText).not.toContain("CLAUDE_CODE_OAUTH_TOKEN");
});
});