Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ jobs:
- 'package-lock.json'
- 'packages/messaging/**'
- 'tools/kubectl-readonly/**'
github-tool:
- 'package.json'
- 'package-lock.json'
- 'packages/messaging/**'
- 'tools/github/**'
core-controller:
- 'controllers/core-controller/**'
localtool-executor:
Expand All @@ -112,6 +117,7 @@ jobs:
WEB_SEARCH: ${{ steps.filter.outputs.web-search }}
WEB_FETCH: ${{ steps.filter.outputs.web-fetch }}
KUBECTL_READONLY: ${{ steps.filter.outputs.kubectl-readonly }}
GITHUB_TOOL: ${{ steps.filter.outputs.github-tool }}
CORE_CONTROLLER: ${{ steps.filter.outputs.core-controller }}
LOCALTOOL_EXECUTOR: ${{ steps.filter.outputs.localtool-executor }}
run: |
Expand All @@ -126,6 +132,7 @@ jobs:
{"image":"web-search","changed_key":"WEB_SEARCH","dockerfile":"tools/web-search/Dockerfile","context":"."},
{"image":"web-fetch","changed_key":"WEB_FETCH","dockerfile":"tools/web-fetch/Dockerfile","context":"."},
{"image":"kubectl-readonly","changed_key":"KUBECTL_READONLY","dockerfile":"tools/kubectl-readonly/Dockerfile","context":"."},
{"image":"github","changed_key":"GITHUB_TOOL","dockerfile":"tools/github/Dockerfile","context":"."},
{"image":"core-controller","changed_key":"CORE_CONTROLLER","dockerfile":"controllers/core-controller/Dockerfile","context":"controllers/core-controller"},
{"image":"localtool-executor-node","changed_key":"LOCALTOOL_EXECUTOR","dockerfile":"sidecars/localtool-executor/Dockerfile","context":"sidecars/localtool-executor","build_args":"BASE_IMAGE=node:24-bookworm-slim\nRUNTIME=node"},
{"image":"localtool-executor-python","changed_key":"LOCALTOOL_EXECUTOR","dockerfile":"sidecars/localtool-executor/Dockerfile","context":"sidecars/localtool-executor","build_args":"BASE_IMAGE=python:3.12-slim-bookworm\nRUNTIME=python"},
Expand All @@ -150,9 +157,10 @@ jobs:
--arg WEB_SEARCH "$WEB_SEARCH" \
--arg WEB_FETCH "$WEB_FETCH" \
--arg KUBECTL_READONLY "$KUBECTL_READONLY" \
--arg GITHUB_TOOL "$GITHUB_TOOL" \
--arg CORE_CONTROLLER "$CORE_CONTROLLER" \
--arg LOCALTOOL_EXECUTOR "$LOCALTOOL_EXECUTOR" \
'{AGENT_ORCHESTRATOR:$AGENT_ORCHESTRATOR,OPENCODE_SWE_AGENT:$OPENCODE_SWE_AGENT,CLAUDE_CODE_SWE_AGENT:$CLAUDE_CODE_SWE_AGENT,INTEGRATION_GATEWAY:$INTEGRATION_GATEWAY,RECIPE_SCRAPER:$RECIPE_SCRAPER,RECIPE_PUBLISHER:$RECIPE_PUBLISHER,WEB_SEARCH:$WEB_SEARCH,WEB_FETCH:$WEB_FETCH,KUBECTL_READONLY:$KUBECTL_READONLY,CORE_CONTROLLER:$CORE_CONTROLLER,LOCALTOOL_EXECUTOR:$LOCALTOOL_EXECUTOR}')
'{AGENT_ORCHESTRATOR:$AGENT_ORCHESTRATOR,OPENCODE_SWE_AGENT:$OPENCODE_SWE_AGENT,CLAUDE_CODE_SWE_AGENT:$CLAUDE_CODE_SWE_AGENT,INTEGRATION_GATEWAY:$INTEGRATION_GATEWAY,RECIPE_SCRAPER:$RECIPE_SCRAPER,RECIPE_PUBLISHER:$RECIPE_PUBLISHER,WEB_SEARCH:$WEB_SEARCH,WEB_FETCH:$WEB_FETCH,KUBECTL_READONLY:$KUBECTL_READONLY,GITHUB_TOOL:$GITHUB_TOOL,CORE_CONTROLLER:$CORE_CONTROLLER,LOCALTOOL_EXECUTOR:$LOCALTOOL_EXECUTOR}')
MATRIX=$(jq -c --argjson flags "$FLAGS" 'map(select($flags[.changed_key] == "true"))' all.json)
fi

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ orchestrator service, and `controllers/` holds the Go controller.
│ └── github-app-auth/ # @controller-agent/github-app-auth — GitHub App JWT/token auth
├── tools/ # on-demand tool containers (example implementations)
│ ├── recipe-scraper/ # URL → recipe Markdown
│ └── recipe-publisher/ # recipe Markdown → Mealie instance
│ ├── recipe-publisher/ # recipe Markdown → Mealie instance
│ └── github/ # gh CLI command → GitHub, as the calling user's own identity
├── apps/
│ ├── agent-orchestrator/ # RAG skill selection + ToolRun/AgentRun creator
│ └── integration-gateway/ # GitHub Issues → agent-orchestrator webhook adapter
Expand Down Expand Up @@ -137,6 +138,7 @@ identity, selects a Skill via RAG, plans an action, and creates a `ToolRun` or
| ---- | ----- | ------ | ---- |
| **recipe-scraper** | any recipe URL (web page, video, or image) | recipe Markdown | [tools/recipe-scraper/README.md](tools/recipe-scraper/README.md) |
| **recipe-publisher** | recipe Markdown | published/updated recipe in a Mealie instance | [tools/recipe-publisher/README.md](tools/recipe-publisher/README.md) |
| **github** | a single `gh` CLI command line | `gh`'s own output, authenticated as the calling user's own linked GitHub identity | [tools/github/README.md](tools/github/README.md) |

## Shared standards

Expand Down
134 changes: 134 additions & 0 deletions apps/agent-orchestrator/src/agent/graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1334,6 +1334,140 @@ describe("buildAgentGraph agent-backed Tool (runTool via AgentRun)", () => {
});
});

describe("buildAgentGraph identity-gated container Tool (ADR 0032, e.g. the github Tool)", () => {
const githubTool: ToolDescriptor = {
id: "github",
name: "github",
description: "Runs a single gh CLI command against GitHub, authenticated as the calling user",
allowedRoles: ["writer"],
jobTemplate: { image: "example.com/github:latest", namespace: "default", serviceAccountName: "github-tool" },
identityProviders: ["github"],
};

const githubToolSkill: SkillDescriptor = {
id: "github-cli-skill",
name: "GitHub CLI",
description: "Runs gh CLI commands against GitHub",
markdown: "# instructions",
toolIds: ["github"],
agentIds: [],
};

function githubToolDeps(overrides: Partial<AgentGraphDeps> = {}) {
const skillStore: SkillStore = {
upsert: vi.fn(),
delete: vi.fn(),
query: vi.fn().mockResolvedValue([{ skill: githubToolSkill, score: 0.9 }]),
getByIds: vi.fn().mockResolvedValue([githubToolSkill]),
};
const vectorStore: VectorStore = {
upsert: vi.fn(),
delete: vi.fn(),
query: vi.fn().mockResolvedValue([]),
getByIds: vi.fn().mockResolvedValue([{ tool: githubTool, score: 1 }]),
};
const actionPlanner: ActionPlanner = {
plan: vi.fn().mockResolvedValue({
action: "call_tool",
toolId: "github",
toolArgs: "issue view 86 --repo imaustink/agent-controller",
} satisfies PlannedAction),
};
return baseDeps({ skillStore, vectorStore, actionPlanner, ...overrides });
}

it("injects the caller's already-linked token as secretEnv instead of erroring", async () => {
const identityLinkGateway: IdentityLinkPort = {
start: vi.fn(),
poll: vi.fn(),
getToken: vi.fn().mockResolvedValue({ token: "gho_alice-token" }),
};
const deps = githubToolDeps({ identityLinkGateway });
const graph = buildAgentGraph(deps);

const final = await graph.invoke({ request: "what does issue 86 say", authToken: "tok" });

expect(final.error).toBeUndefined();
expect(identityLinkGateway.getToken).toHaveBeenCalledWith("github", "alice");
expect(deps.containerToolLauncher.launch).toHaveBeenCalledWith(
githubTool.jobTemplate,
expect.objectContaining({ secretEnv: [{ name: "GITHUB_TOKEN", value: "gho_alice-token" }] }),
);
});

it("errors (without launching) instead of silently running credential-less when the caller has no linked token", async () => {
const identityLinkGateway: IdentityLinkPort = {
start: vi.fn(),
poll: vi.fn(),
getToken: vi.fn().mockResolvedValue(undefined),
};
const deps = githubToolDeps({ identityLinkGateway });
const graph = buildAgentGraph(deps);

const final = await graph.invoke({ request: "what does issue 86 say", authToken: "tok" });

expect(final.error).toMatch(/requires linking your github account/);
expect(deps.containerToolLauncher.launch).not.toHaveBeenCalled();
});

it("errors when identity providers are declared but no identity-link gateway is configured", async () => {
const deps = githubToolDeps({ identityLinkGateway: undefined });
const graph = buildAgentGraph(deps);

const final = await graph.invoke({ request: "what does issue 86 say", authToken: "tok" });

expect(final.error).toMatch(/no identity-link gateway is configured/);
expect(deps.containerToolLauncher.launch).not.toHaveBeenCalled();
});

it("resolves a non-github provider through the provider-aware gateway, not the GitHub-only one", async () => {
// A container Tool declaring identityProviders:["claude"] must have its
// token read from claudeAuthGateway (via identityGatewayFor/AuthorizationService),
// NOT the GitHub-only identityLinkGateway -- guarding the bypass that
// hard-coding deps.identityLinkGateway would have re-introduced.
const claudeTool: ToolDescriptor = { ...githubTool, identityProviders: ["claude"] };
const claudeToolSkill: SkillDescriptor = { ...githubToolSkill, toolIds: ["github"] };
const claudeAuthGateway: IdentityLinkPort = {
start: vi.fn(),
poll: vi.fn(),
getToken: vi.fn().mockResolvedValue({ token: "claude-oauth-alice" }),
};
const identityLinkGateway: IdentityLinkPort = {
start: vi.fn(),
poll: vi.fn(),
getToken: vi.fn().mockResolvedValue({ token: "gho_should-not-be-used" }),
};
const vectorStore: VectorStore = {
upsert: vi.fn(),
delete: vi.fn(),
query: vi.fn().mockResolvedValue([]),
getByIds: vi.fn().mockResolvedValue([{ tool: claudeTool, score: 1 }]),
};
const skillStore: SkillStore = {
upsert: vi.fn(),
delete: vi.fn(),
query: vi.fn().mockResolvedValue([{ skill: claudeToolSkill, score: 0.9 }]),
getByIds: vi.fn().mockResolvedValue([claudeToolSkill]),
};
const deps = githubToolDeps({ skillStore, vectorStore, claudeAuthGateway, identityLinkGateway });
const graph = buildAgentGraph(deps);

const final = await graph.invoke({ request: "run a claude command", authToken: "tok" });

expect(final.error).toBeUndefined();
expect(claudeAuthGateway.getToken).toHaveBeenCalledWith("claude", "alice");
// The GitHub-only gateway may be consulted for principal establishment, but
// must never be asked to resolve the "claude" provider's credential.
expect(identityLinkGateway.getToken).not.toHaveBeenCalledWith("claude", expect.anything());
// Definitive proof of correct routing: the launched token is the one from
// claudeAuthGateway, not identityLinkGateway's "gho_should-not-be-used".
expect(deps.containerToolLauncher.launch).toHaveBeenCalledWith(
claudeTool.jobTemplate,
expect.objectContaining({ secretEnv: [{ name: "CLAUDE_CODE_OAUTH_TOKEN", value: "claude-oauth-alice" }] }),
);
});
});

describe("buildAgentGraph Skill.agentRefs (ADR 0021, no Tool wrapper)", () => {
const opencodeAgent: AgentDescriptor = {
id: "opencode-swe-agent",
Expand Down
112 changes: 84 additions & 28 deletions apps/agent-orchestrator/src/agent/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { makeSubAgentToolCallHandler, type ToolCatalog } from "./dispatch-tool.j
import {
ACTOR_LOGIN_ENV,
AuthorizationService,
type CredentialEnvEntry,
CROSS_ENTRY_POINT_PROVIDERS,
linkPromptText,
PROVIDER_LABEL,
Expand Down Expand Up @@ -625,6 +626,54 @@ function identityGatewayFor(provider: string, deps: AgentGraphDeps): IdentityLin
return deps.identityLinkGateway;
}

/**
* The per-caller identity gate shared by `runTool`'s two tool-launch branches
* (agent-backed and container). Resolves the caller's linked credentials for
* `tool.identityProviders` through the SAME {@link AuthorizationService} the
* peer-level `delegateToAgent` path uses (ADR 0022/0030/0032), so gateway
* selection is provider-aware (e.g. `claude` -> claudeAuthGateway, not the
* GitHub-only `identityLinkGateway`) and the credential subject is keyed
* identically (canonical principal for cross-entry-point providers).
*
* Returns `{ secretEnv }` (possibly `undefined`, when the tool declares no
* providers) on success, or `{ error }` on any gate failure — the four
* `resolveLinkedCredentials` failure kinds mapped to a message that names the
* TOOL (which the service deliberately doesn't know). Like the paths it
* replaces, this never STARTS a fresh device-flow/authcode link: there is no
* session slot analogous to `pendingIdentityLink` for a paused TOOL call, only
* for a paused agent delegation (a documented v1 scope cut). A caller must have
* linked once via direct chat delegation first; `linkHint(provider)` completes
* the not-linked message with who to talk to to do so.
*/
async function resolveToolIdentitySecretEnv(
authorization: AuthorizationService,
tool: { id: string; identityProviders?: string[] },
identity: Identity | undefined,
linkHint: (provider: string) => string,
): Promise<{ secretEnv?: CredentialEnvEntry[] } | { error: string }> {
if (!identity) {
return { error: `tool ${tool.id} requires identity providers but no caller identity was resolved` };
}
const credentials = await authorization.resolveLinkedCredentials({
identity,
identityProviders: tool.identityProviders,
});
switch (credentials.kind) {
case "gateway-missing":
return {
error: `tool ${tool.id} requires identity providers (${tool.identityProviders!.join(", ")}) but no identity-link gateway is configured for "${credentials.provider}"`,
};
case "not-linked":
return {
error: `tool ${tool.id} requires linking your ${credentials.provider} account first -- start a direct conversation with ${linkHint(credentials.provider)} to link it, then retry`,
};
case "unsupported-provider":
return { error: `tool ${tool.id} declares unsupported identity provider "${credentials.provider}"` };
case "resolved":
return { secretEnv: credentials.secretEnv };
}
}

/**
* Caps how many tool calls a skill's planAction<->runTool loop may chain in a
* single turn (docs/adr/0008 update: multi-step tool use) -- generous enough
Expand Down Expand Up @@ -1477,35 +1526,15 @@ export function buildAgentGraph(deps: AgentGraphDeps) {
// required here too now that an identity-gated Agent's static secretEnv
// is stripped regardless of which path reaches it -- and the SAME owner
// (docs/adr/0030 §1), so the keying can never drift between the two
// paths.
//
// The read-only entry point, deliberately: v1 scope cut is that unlike
// delegateToAgent, this path never STARTS a fresh device-flow/authcode
// link -- there's no session slot analogous to pendingIdentityLink for a
// paused TOOL call, only for a paused agent delegation. A caller must
// link once via direct chat delegation to the same agent before a Skill
// can reach it here.
if (!state.identity) {
return { error: `tool ${tool.id} requires identity providers but no caller identity was resolved` };
// paths. Shared with the container-tool branch below via
// `resolveToolIdentitySecretEnv`, which documents the read-only,
// never-starts-a-fresh-link v1 scope cut. The not-linked hint points at
// THIS backing agent, which the caller reaches by direct chat.
const gate = await resolveToolIdentitySecretEnv(authorization, tool, state.identity, () => "this agent");
if ("error" in gate) {
return { error: gate.error };
}
const credentials = await authorization.resolveLinkedCredentials({
identity: state.identity,
identityProviders: tool.identityProviders,
});
if (credentials.kind === "gateway-missing") {
return {
error: `tool ${tool.id} requires identity providers (${tool.identityProviders!.join(", ")}) but no identity-link gateway is configured for "${credentials.provider}"`,
};
}
if (credentials.kind === "not-linked") {
return {
error: `tool ${tool.id} requires linking your ${credentials.provider} account first -- start a direct conversation with this agent to link it, then retry`,
};
}
if (credentials.kind === "unsupported-provider") {
return { error: `tool ${tool.id} declares unsupported identity provider "${credentials.provider}"` };
}
const identitySecretEnv = credentials.secretEnv;
const identitySecretEnv = gate.secretEnv;
const runId = randomUUID();
const callbackUrl = `${deps.callbackBaseUrl}/callback/${randomUUID()}`;
try {
Expand Down Expand Up @@ -1566,6 +1595,31 @@ export function buildAgentGraph(deps: AgentGraphDeps) {
// Container tool (ADR 0010): create a ToolRun CR — the Go
// core-controller reconciles it into a hardened Job. The orchestrator
// itself never creates a Job.

// Same per-caller identity gate as delegateToAgent/the agent-backed
// tool branch above (ADR 0022/0032) — e.g. the `github` Tool, which
// needs the calling user's own linked GitHub token rather than a
// shared credential. Shares `resolveToolIdentitySecretEnv` with the
// agent-backed branch: provider-aware gateway selection and identical
// subject keying, never hard-coded to `deps.identityLinkGateway`. A
// container Tool has no backing agent to chat, so the not-linked hint
// names any agent that links the same provider. Unlike the agent-backed
// branch, a container Tool with no declared providers needs no identity
// at all, so the gate is skipped entirely rather than run for nothing.
let identitySecretEnv: CredentialEnvEntry[] | undefined;
if (tool.identityProviders && tool.identityProviders.length > 0) {
const gate = await resolveToolIdentitySecretEnv(
authorization,
tool,
state.identity,
(provider) => `an agent that uses ${provider} identity linking`,
);
if ("error" in gate) {
return { error: gate.error };
}
identitySecretEnv = gate.secretEnv;
}

jobId = randomUUID();
const awaitResult = deps.jobResultReceiver.awaitJob(jobId);

Expand All @@ -1584,6 +1638,7 @@ export function buildAgentGraph(deps: AgentGraphDeps) {
natsUrl: deps.natsUrl,
natsSubject: `callbacks.${jobId}`,
...(state.sessionId ? { sessionId: state.sessionId } : {}),
...(identitySecretEnv ? { secretEnv: identitySecretEnv } : {}),
});
} else {
// HTTP callback mode (backward-compatible default).
Expand All @@ -1593,6 +1648,7 @@ export function buildAgentGraph(deps: AgentGraphDeps) {
callbackUrl,
callbackSecret: deps.callbackSecret!,
...(state.sessionId ? { sessionId: state.sessionId } : {}),
...(identitySecretEnv ? { secretEnv: identitySecretEnv } : {}),
});
}

Expand Down
4 changes: 3 additions & 1 deletion apps/agent-orchestrator/src/k8s/agentrun-launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ export interface AgentRunLauncherPort {
* identity `secretEnv` (a caller's own linked GitHub token, never the shared
* static credential) — kept small and mockable, same narrowing discipline as
* {@link CustomObjectsApiLike}. Optional on the constructor: only required
* when a caller ever launches with `options.secretEnv` non-empty.
* when a caller ever launches with `options.secretEnv` non-empty. Shared
* with {@link ../k8s/toolrun-launcher.ts!ToolRunLauncher}, which backs the
* same per-invocation `secretEnv` mechanism for container Tools (ADR 0032).
*/
export interface SecretApiLike {
createNamespacedSecret(request: { namespace: string; body: unknown }): Promise<{ metadata?: { name?: string } }>;
Expand Down
Loading
Loading