improve agent CI matrix, token permissions, and waitlist follower backfill (#313)

* add workflows permission to git token and waitlist improvements

- add `workflows` to `InstallationTokenPermissions` type in both action and API token routes
- include `workflows: write` in the git token so agents can push workflow file changes
- add `githubFollowers` field to WaitlistSignup schema with migration
- add script to populate waitlist followers from GitHub API
- add frog-green-square-border logo asset

Co-authored-by: Cursor <cursoragent@cursor.com>

* improve CI, agent logging, token permissions, and delegation guardrails

- add format check and build step to root CI job
- standardize agent model/effort log lines across all agents
- fix GitHub App permissions types to match OpenAPI schema (workflows is write-only)
- improve delegation error message to prevent subagent recursion
- demote noisy OpenCode stderr to debug level
- add subagent delegation rules to resolved instructions

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix graphql partial error handling, update delegation message, add workflow_run fixtures

Co-authored-by: Cursor <cursoragent@cursor.com>

* remove module-level env var throws that break CI build

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix logging bug and type hole from PR review

- use batch-local notFound counter so per-batch log doesn't undercount
- add workflows to WorkflowTokenPermissions so wire type matches what action sends

Co-authored-by: Cursor <cursoragent@cursor.com>

* lazy-init appOctokit to fix next build without env vars

Co-authored-by: Cursor <cursoragent@cursor.com>

* drop pnpm build from CI test workflow

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix delegate-effort test regex to match actual log format, disable fail-fast for agnostic tests

the test was matching `running \w+ with effort=auto` but the actual log
line from shared.ts is `» effort:  auto`. also temporarily set
fail-fast: false on action-agnostic so all failures surface at once.

Co-authored-by: Cursor <cursoragent@cursor.com>

* disable fail-fast in action workflow too, relax ci.test.ts to match

both workflow files now use fail-fast: false for agnostic tests so all
matrix jobs run to completion. the ci consistency test now checks that
the two workflows agree rather than requiring true.

Co-authored-by: Cursor <cursoragent@cursor.com>

* restore fail-fast: true now that all agnostic tests pass

Co-authored-by: Cursor <cursoragent@cursor.com>

* skip agent tests in CI when agent harness file didn't change

adds action/test/changed-agents.sh which reads the PR diff (via
dorny/paths-filter) and outputs only agents whose harness file was
modified. the action-agents matrix now uses this dynamic list instead
of a hardcoded array, so e.g. a PR touching only cursor.ts runs 6
jobs instead of 30.

Co-authored-by: Cursor <cursoragent@cursor.com>

* update ci.test.ts to validate dynamic agent matrix

the test now checks that the matrix references the changes job output
and that changed-agents.sh correctly discovers all agents.

Co-authored-by: Cursor <cursoragent@cursor.com>

* parallelize action jobs and use claude canary fallback for shared changes

runs action-agents in parallel with action-agnostic after root/changes, and updates changed-agents logic so shared or non-harness action runtime changes run only claude while harness-specific edits run only those changed agents.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Colin McDonnell
2026-02-16 22:36:29 +00:00
committed by pullfrog[bot]
parent 37dcea86b9
commit b6658ddbc1
15 changed files with 199 additions and 109 deletions
+1 -1
View File
@@ -86,7 +86,7 @@ export const claude = agent({
// select model and effort level
const model = claudeEffortModels[ctx.payload.effort];
const effortLevel = claudeEffortLevels[ctx.payload.effort];
log.info(`» using model: ${model}${effortLevel ? ` (effort: ${effortLevel})` : ""}`);
log.info(`» model: ${model}${effortLevel ? ` (effort: ${effortLevel})` : ""}`);
// build disallowedTools based on tool permissions
const disallowedTools = buildDisallowedTools(ctx);
+3 -4
View File
@@ -155,10 +155,9 @@ export const codex = agent({
// get model and reasoning effort based on effort level
const effortConfig = getCodexEffortConfig(model)[ctx.payload.effort];
log.info(`» using model: ${effortConfig.model} (effort: ${ctx.payload.effort})`);
if (effortConfig.reasoningEffort) {
log.info(`» using modelReasoningEffort: ${effortConfig.reasoningEffort}`);
}
log.info(
`» model: ${effortConfig.model}${effortConfig.reasoningEffort ? ` (reasoningEffort: ${effortConfig.reasoningEffort})` : ""}`
);
// determine sandbox mode based on push permission
// push: "disabled" → read-only sandbox, otherwise workspace-write.
+5 -4
View File
@@ -134,7 +134,7 @@ export const cursor = agent({
try {
const projectConfig = JSON.parse(readFileSync(projectCliConfigPath, "utf-8"));
if (projectConfig.model) {
log.info(`» using model from project .cursor/cli.json: ${projectConfig.model}`);
log.info(`» model: ${projectConfig.model} (from .cursor/cli.json)`);
} else {
modelOverride = cursorEffortModels[ctx.payload.effort];
}
@@ -146,9 +146,9 @@ export const cursor = agent({
}
if (modelOverride) {
log.info(`» using model: ${modelOverride}, effort=${ctx.payload.effort}`);
log.info(`» model: ${modelOverride}`);
} else if (!existsSync(projectCliConfigPath)) {
log.info(`» using default model, effort=${ctx.payload.effort}`);
log.info(`» model: default`);
}
// track logged model_call_ids to avoid duplicates
@@ -439,5 +439,6 @@ function configureCursorTools(ctx: AgentRunContext): void {
}
writeFileSync(cliConfigPath, JSON.stringify(config, null, 2), "utf-8");
log.info(`» CLI config written to ${cliConfigPath}`, JSON.stringify(config, null, 2));
log.info(`» CLI config written to ${cliConfigPath}`);
log.debug(`» CLI config contents: ${JSON.stringify(config, null, 2)}`);
}
+1 -1
View File
@@ -345,7 +345,7 @@ function configureGeminiSettings(ctx: AgentRunContext): string {
// allow env var override for tests (e.g., to avoid flash RPD quota limits)
const model = process.env.GEMINI_MODEL ?? effortConfig.model;
const thinkingLevel = effortConfig.thinkingLevel;
log.info(`» using model: ${model}, thinkingLevel: ${thinkingLevel}`);
log.info(`» model: ${model} (thinkingLevel: ${thinkingLevel})`);
const realHome = homedir();
const geminiConfigDir = join(realHome, ".gemini");
+25 -25
View File
@@ -72,7 +72,9 @@ export const opencode = agent({
const modelOverride = process.env.OPENCODE_MODEL;
if (modelOverride) {
args.push("--model", modelOverride);
log.info(`» using model override: ${modelOverride}`);
log.info(`» model: ${modelOverride} (override)`);
} else {
log.info(`» model: auto-selected by OpenCode`);
}
process.env.HOME = tempHome;
@@ -186,13 +188,10 @@ export const opencode = agent({
lastProviderError = providerError;
log.error(`» provider error detected (${providerError}): ${trimmed.substring(0, 500)}`);
} else {
// try to parse as JSON for structured logging, fall back to warning
try {
const parsed = JSON.parse(trimmed);
log.debug(JSON.stringify(parsed, null, 2));
} catch {
log.warning(trimmed);
}
// OpenCode's --print-logs output goes to stderr. demote internal
// INFO/DEBUG bus traffic to debug so it doesn't drown out tool
// call logs in the GitHub Actions step output.
log.debug(trimmed);
}
},
});
@@ -325,7 +324,7 @@ function configureOpenCode(ctx: AgentRunContext): void {
}
log.info(`» OpenCode config written to ${configPath}`);
log.info(
log.debug(
`» OpenCode permissions: edit=${permission.edit}, bash=${permission.bash}, webfetch=${permission.webfetch}`
);
log.debug(`OpenCode config contents:\n${configJson}`);
@@ -558,27 +557,28 @@ const messageHandlers = {
const status = event.part?.state?.status;
const output = event.part?.state?.output;
// debug log all tool_use events to diagnose missing bash/MCP tool calls
if (!toolName || !toolId) {
log.debug(`» tool_use event missing toolName or toolId: ${JSON.stringify(event)}`);
// surface dropped tool_use events visibly so missing tool calls are diagnosable
log.info(
`» tool_use event missing toolName or toolId: ${JSON.stringify(event).substring(0, 500)}`
);
return;
}
if (toolName && toolId) {
// track tool call in current step
if (stepHistory.length > 0) {
stepHistory[stepHistory.length - 1].toolCalls.push(toolName);
}
// track tool call in current step
if (stepHistory.length > 0) {
stepHistory[stepHistory.length - 1].toolCalls.push(toolName);
}
thinkingTimer.markToolCall();
log.toolCall({
toolName,
input: parameters || {},
});
thinkingTimer.markToolCall();
log.toolCall({
toolName,
input: parameters || {},
});
// if tool already completed (status in same event), log output
if (status === "completed" && output) {
log.debug(` output: ${output}`);
}
// if tool already completed (status in same event), log output
if (status === "completed" && output) {
log.debug(` output: ${output}`);
}
},
tool_result: (event: OpenCodeToolResultEvent, thinkingTimer: ThinkingTimer) => {
+10 -8
View File
@@ -28,13 +28,15 @@ export const agent = <const input extends AgentInput>(input: input): defineAgent
return {
...input,
run: async (ctx: AgentRunContext): Promise<AgentResult> => {
const bash = ctx.payload.bash;
const web = ctx.payload.web;
const search = ctx.payload.search;
const push = ctx.payload.push;
log.info(
`» running ${input.name} with effort=${ctx.payload.effort}, timeout=${ctx.payload.timeout}...`
);
log.info(`» agent: ${input.name}`);
log.info(`» effort: ${ctx.payload.effort}`);
if (ctx.payload.timeout) log.info(`» timeout: ${ctx.payload.timeout}`);
log.info(`» web: ${ctx.payload.web}`);
log.info(`» search: ${ctx.payload.search}`);
log.info(`» push: ${ctx.payload.push}`);
log.info(`» bash: ${ctx.payload.bash}`);
log.debug(`» payload: ${JSON.stringify(ctx.payload, null, 2)}`);
// build log box content: eventInstructions (if any) + user request (if any) + event data
const logParts = [
ctx.instructions.eventInstructions
@@ -46,7 +48,7 @@ export const agent = <const input extends AgentInput>(input: input): defineAgent
log.box(logParts.join("\n\n---\n\n"), {
title: "Instructions",
});
log.info(`» tool permissions: web=${web}, search=${search}, push=${push}, bash=${bash}`);
return input.run(ctx);
},
...agentsManifest[input.name],
+50 -44
View File
@@ -139709,12 +139709,14 @@ async function resolveTokens(params) {
}
};
}
const gitContents = params.push === "disabled" ? "read" : "write";
const gitToken = await acquireNewToken({ permissions: { contents: gitContents } });
const gitPermissions = params.push === "disabled" ? { contents: "read" } : { contents: "write", workflows: "write" };
const gitToken = await acquireNewToken({ permissions: gitPermissions });
if (isGitHubActions) {
core3.setSecret(gitToken);
}
log.info(`\xBB acquired git token (contents:${gitContents})`);
log.info(
`\xBB acquired git token (${Object.entries(gitPermissions).map((e) => e.join(":")).join(", ")})`
);
const mcpToken = await acquireNewToken();
if (isGitHubActions) {
core3.setSecret(mcpToken);
@@ -141892,7 +141894,13 @@ ${ctx.modes.map((m) => `- "${m.name}": ${m.description}`).join("\n")}`;
}
function resolveSubagentInstructions(ctx) {
const inputs = buildCommonInputs(ctx);
const subagentTaskSection = `You are operating in **${ctx.mode.name}** mode.
const subagentTaskSection = `You are operating in **${ctx.mode.name}** mode as a delegated subagent. An orchestrator spawned you and will read your final output to decide what to do next.
### Delegation rules
- The \`delegate\` tool is NOT available to you \u2014 complete your task directly using the available tools.
- When you finish, end with a clear, concise summary: what you did, what succeeded, what failed, and any blockers or next steps. The orchestrator uses this to decide whether to delegate again or report final results.
- If you encounter an error you cannot resolve, report it clearly \u2014 do not attempt to delegate or re-run yourself.
${ctx.mode.prompt}`;
const system = buildSystemPrompt({
@@ -141956,7 +141964,7 @@ function DelegateTool(ctx) {
execute: execute(async (params) => {
if (ctx.toolState.delegationActive) {
return {
error: "delegation is not available inside a delegated subagent"
error: "delegation is not available inside a subagent. you are already running as a delegated subagent. complete the task directly using the available tools. do not attempt to delegate further. your last agent message will be passed to your supervisor and it will decide what to do next."
};
}
const selectedMode = resolveMode(ctx.modes, params.mode);
@@ -144812,13 +144820,14 @@ var agent = (input) => {
return {
...input,
run: async (ctx) => {
const bash = ctx.payload.bash;
const web = ctx.payload.web;
const search2 = ctx.payload.search;
const push = ctx.payload.push;
log.info(
`\xBB running ${input.name} with effort=${ctx.payload.effort}, timeout=${ctx.payload.timeout}...`
);
log.info(`\xBB agent: ${input.name}`);
log.info(`\xBB effort: ${ctx.payload.effort}`);
if (ctx.payload.timeout) log.info(`\xBB timeout: ${ctx.payload.timeout}`);
log.info(`\xBB web: ${ctx.payload.web}`);
log.info(`\xBB search: ${ctx.payload.search}`);
log.info(`\xBB push: ${ctx.payload.push}`);
log.info(`\xBB bash: ${ctx.payload.bash}`);
log.debug(`\xBB payload: ${JSON.stringify(ctx.payload, null, 2)}`);
const logParts = [
ctx.instructions.eventInstructions ? `EVENT-LEVEL INSTRUCTIONS:
${ctx.instructions.eventInstructions}` : null,
@@ -144829,7 +144838,6 @@ ${ctx.instructions.user}` : null,
log.box(logParts.join("\n\n---\n\n"), {
title: "Instructions"
});
log.info(`\xBB tool permissions: web=${web}, search=${search2}, push=${push}, bash=${bash}`);
return input.run(ctx);
},
...agentsManifest[input.name]
@@ -144885,7 +144893,7 @@ var claude = agent({
const cliPath = await installClaude();
const model = claudeEffortModels[ctx.payload.effort];
const effortLevel = claudeEffortLevels[ctx.payload.effort];
log.info(`\xBB using model: ${model}${effortLevel ? ` (effort: ${effortLevel})` : ""}`);
log.info(`\xBB model: ${model}${effortLevel ? ` (effort: ${effortLevel})` : ""}`);
const disallowedTools = buildDisallowedTools(ctx);
if (disallowedTools.length > 0) {
log.info(`\xBB disallowed tools: ${disallowedTools.join(", ")}`);
@@ -145160,10 +145168,9 @@ var codex = agent({
const [cliPath, model] = await Promise.all([installCodex(), resolveModel(apiKey)]);
const codexDir = writeCodexConfig(ctx);
const effortConfig = getCodexEffortConfig(model)[ctx.payload.effort];
log.info(`\xBB using model: ${effortConfig.model} (effort: ${ctx.payload.effort})`);
if (effortConfig.reasoningEffort) {
log.info(`\xBB using modelReasoningEffort: ${effortConfig.reasoningEffort}`);
}
log.info(
`\xBB model: ${effortConfig.model}${effortConfig.reasoningEffort ? ` (reasoningEffort: ${effortConfig.reasoningEffort})` : ""}`
);
const sandboxMode = ctx.payload.push === "disabled" ? "read-only" : "workspace-write";
const networkAccessEnabled = ctx.payload.web !== "disabled";
const webSearchEnabled = ctx.payload.search !== "disabled";
@@ -145381,7 +145388,7 @@ var cursor = agent({
try {
const projectConfig = JSON.parse(readFileSync5(projectCliConfigPath, "utf-8"));
if (projectConfig.model) {
log.info(`\xBB using model from project .cursor/cli.json: ${projectConfig.model}`);
log.info(`\xBB model: ${projectConfig.model} (from .cursor/cli.json)`);
} else {
modelOverride = cursorEffortModels[ctx.payload.effort];
}
@@ -145392,9 +145399,9 @@ var cursor = agent({
modelOverride = cursorEffortModels[ctx.payload.effort];
}
if (modelOverride) {
log.info(`\xBB using model: ${modelOverride}, effort=${ctx.payload.effort}`);
log.info(`\xBB model: ${modelOverride}`);
} else if (!existsSync6(projectCliConfigPath)) {
log.info(`\xBB using default model, effort=${ctx.payload.effort}`);
log.info(`\xBB model: default`);
}
const loggedModelCallIds = /* @__PURE__ */ new Set();
const thinkingTimer = new ThinkingTimer();
@@ -145594,7 +145601,8 @@ function configureCursorTools(ctx) {
};
}
writeFileSync9(cliConfigPath, JSON.stringify(config3, null, 2), "utf-8");
log.info(`\xBB CLI config written to ${cliConfigPath}`, JSON.stringify(config3, null, 2));
log.info(`\xBB CLI config written to ${cliConfigPath}`);
log.debug(`\xBB CLI config contents: ${JSON.stringify(config3, null, 2)}`);
}
// agents/gemini.ts
@@ -145813,7 +145821,7 @@ function configureGeminiSettings(ctx) {
const effortConfig = geminiEffortConfig[ctx.payload.effort];
const model = process.env.GEMINI_MODEL ?? effortConfig.model;
const thinkingLevel = effortConfig.thinkingLevel;
log.info(`\xBB using model: ${model}, thinkingLevel: ${thinkingLevel}`);
log.info(`\xBB model: ${model} (thinkingLevel: ${thinkingLevel})`);
const realHome = homedir2();
const geminiConfigDir = join12(realHome, ".gemini");
const settingsPath = join12(geminiConfigDir, "settings.json");
@@ -145904,7 +145912,9 @@ var opencode = agent({
const modelOverride = process.env.OPENCODE_MODEL;
if (modelOverride) {
args2.push("--model", modelOverride);
log.info(`\xBB using model override: ${modelOverride}`);
log.info(`\xBB model: ${modelOverride} (override)`);
} else {
log.info(`\xBB model: auto-selected by OpenCode`);
}
process.env.HOME = tempHome;
const env2 = {
@@ -145987,12 +145997,7 @@ var opencode = agent({
lastProviderError = providerError;
log.error(`\xBB provider error detected (${providerError}): ${trimmed.substring(0, 500)}`);
} else {
try {
const parsed2 = JSON.parse(trimmed);
log.debug(JSON.stringify(parsed2, null, 2));
} catch {
log.warning(trimmed);
}
log.debug(trimmed);
}
}
});
@@ -146091,7 +146096,7 @@ function configureOpenCode(ctx) {
throw error49;
}
log.info(`\xBB OpenCode config written to ${configPath}`);
log.info(
log.debug(
`\xBB OpenCode permissions: edit=${permission.edit}, bash=${permission.bash}, webfetch=${permission.webfetch}`
);
log.debug(`OpenCode config contents:
@@ -146170,20 +146175,21 @@ var messageHandlers4 = {
const status = event.part?.state?.status;
const output = event.part?.state?.output;
if (!toolName || !toolId) {
log.debug(`\xBB tool_use event missing toolName or toolId: ${JSON.stringify(event)}`);
log.info(
`\xBB tool_use event missing toolName or toolId: ${JSON.stringify(event).substring(0, 500)}`
);
return;
}
if (toolName && toolId) {
if (stepHistory.length > 0) {
stepHistory[stepHistory.length - 1].toolCalls.push(toolName);
}
thinkingTimer.markToolCall();
log.toolCall({
toolName,
input: parameters || {}
});
if (status === "completed" && output) {
log.debug(` output: ${output}`);
}
if (stepHistory.length > 0) {
stepHistory[stepHistory.length - 1].toolCalls.push(toolName);
}
thinkingTimer.markToolCall();
log.toolCall({
toolName,
input: parameters || {}
});
if (status === "completed" && output) {
log.debug(` output: ${output}`);
}
},
tool_result: (event, thinkingTimer) => {
+2 -1
View File
@@ -45,7 +45,8 @@ export function DelegateTool(ctx: ToolContext) {
// guard: prevent subagent recursion
if (ctx.toolState.delegationActive) {
return {
error: "delegation is not available inside a delegated subagent",
error:
"delegation is not available inside a subagent. you are already running as a delegated subagent. complete the task directly using the available tools. do not attempt to delegate further. your last agent message will be passed to your supervisor and it will decide what to do next.",
};
}
+5 -5
View File
@@ -30,11 +30,11 @@ function validator(result: AgentResult): ValidationCheck[] {
const setOutputCalled = output !== null;
const correctValue = setOutputCalled && /EFFORT_TEST_PASSED/i.test(output);
// the orchestrator runs at auto (effort=auto in its log line).
// the delegate tool should spawn the subagent at mini (effort=mini in its log line).
// if effort override works, we should see BOTH effort=auto AND effort=mini in the output.
const orchestratorEffort = /running \w+ with effort=auto/i.test(agentOutput);
const subagentEffort = /running \w+ with effort=mini/i.test(agentOutput);
// the orchestrator runs at auto (» effort: auto in its log line).
// the delegate tool should spawn the subagent at mini (» effort: mini in its log line).
// if effort override works, we should see BOTH effort values in the output.
const orchestratorEffort = /» effort:\s+auto/i.test(agentOutput);
const subagentEffort = /» effort:\s+mini/i.test(agentOutput);
return [
{ name: "set_output", passed: setOutputCalled },
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# determines which agents need testing based on changed files.
# reads changed file paths from stdin (JSON array or newline-delimited).
# outputs a JSON array of agent names to stdout.
#
# only agents whose harness file changed are included.
# shared.ts/index.ts and other non-harness action changes fall back to claude as a canary.
set -euo pipefail
# read stdin - auto-detect JSON array vs newline-delimited
input=$(cat)
if echo "$input" | jq -e 'type == "array"' > /dev/null 2>&1; then
files=$(echo "$input" | jq -r '.[]')
else
files="$input"
fi
# find which agent harness files changed
changed_agents=()
has_non_agent_change=false
while IFS= read -r file; do
[[ -z "$file" ]] && continue
case "$file" in
action/agents/shared.ts|action/agents/index.ts)
has_non_agent_change=true
;;
action/agents/*.ts)
changed_agents+=("$(basename "$file" .ts)")
;;
action/*)
has_non_agent_change=true
;;
esac
done <<< "$files"
# output agents based on change type.
# non-agent action changes run claude as a canary.
if [[ ${#changed_agents[@]} -gt 0 ]]; then
printf '%s\n' "${changed_agents[@]}" | sort -u | jq -R . | jq -sc .
elif $has_non_agent_change; then
echo '["claude"]'
else
echo '[]'
fi
+21 -2
View File
@@ -1,3 +1,4 @@
import { execFileSync } from "node:child_process";
import { readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -57,6 +58,7 @@ const expectedAgents = Object.keys(agentsManifest).sort();
const crossagentTests = getTestNamesFromDir("crossagent");
const agnosticTests = getTestNamesFromDir("agnostic");
const adhocTests = getTestNamesFromDir("adhoc");
const dynamicAgentsExpression = "$" + "{{ fromJSON(needs.changes.outputs.agents) }}";
// all API key names from all agents + GITHUB_TOKEN + model overrides
const expectedAgentEnvVars = [
@@ -84,8 +86,25 @@ describe("ci workflow consistency", () => {
const rootJob = rootWorkflow.jobs["action-agents"];
const actionJob = actionWorkflow.jobs.agents;
it("root agent matrix matches agentsManifest", () => {
expect([...rootJob.strategy!.matrix.agent].sort()).toEqual(expectedAgents);
it("root agent matrix uses dynamic output from changes job", () => {
expect(rootJob.strategy!.matrix.agent).toBe(dynamicAgentsExpression);
});
it("changed-agents.sh falls back to claude when shared agent code changed", () => {
const input = JSON.stringify(["action/agents/shared.ts"]);
const output = execFileSync("bash", [join(__dirname, "changed-agents.sh")], {
input,
encoding: "utf-8",
});
expect(JSON.parse(output)).toEqual(["claude"]);
});
it("changed-agents.sh falls back to claude for non-agent action changes", () => {
const output = execFileSync("bash", [join(__dirname, "changed-agents.sh")], {
input: JSON.stringify(["action/mcp/delegate.ts"]),
encoding: "utf-8",
});
expect(JSON.parse(output)).toEqual(["claude"]);
});
it("action agent matrix matches agentsManifest", () => {
+12 -9
View File
@@ -53,33 +53,36 @@ function isOIDCAvailable(): boolean {
);
}
// github installation token permission levels
type ReadWrite = "read" | "write";
type WriteOnly = "write";
type ReadOnly = "read";
// permission names use underscores (API format)
type InstallationTokenPermissions = {
/**
* GitHub App installation access token permissions.
* passed to `POST /app/installations/{id}/access_tokens` to scope the token.
* fields and allowed values come from the `app-permissions` OpenAPI schema.
* @see https://docs.github.com/en/rest/apps/installations#create-an-installation-access-token-for-an-app
* @see https://github.com/github/rest-api-description — components.schemas.app-permissions
*/
type GitHubAppPermissions = {
actions?: ReadWrite;
artifact_metadata?: ReadWrite;
attestations?: ReadWrite;
checks?: ReadWrite;
contents?: ReadWrite;
deployments?: ReadWrite;
id_token?: WriteOnly;
issues?: ReadWrite;
models?: ReadOnly;
discussions?: ReadWrite;
issues?: ReadWrite;
packages?: ReadWrite;
pages?: ReadWrite;
pull_requests?: ReadWrite;
security_events?: ReadWrite;
statuses?: ReadWrite;
workflows?: WriteOnly;
};
type AcquireTokenOptions = {
repos?: string[];
permissions?: InstallationTokenPermissions;
permissions?: GitHubAppPermissions;
};
async function acquireTokenViaOIDC(opts?: AcquireTokenOptions): Promise<string> {
@@ -215,7 +218,7 @@ const checkRepositoryAccess = async (
const createInstallationToken = async (
jwt: string,
installationId: number,
permissions?: InstallationTokenPermissions
permissions?: GitHubAppPermissions
): Promise<string> => {
const requestOpts: { method: string; headers: Record<string, string>; body?: string } = {
method: "POST",
+7 -1
View File
@@ -422,7 +422,13 @@ export function resolveSubagentInstructions(
): ResolvedInstructions {
const inputs = buildCommonInputs(ctx);
const subagentTaskSection = `You are operating in **${ctx.mode.name}** mode.
const subagentTaskSection = `You are operating in **${ctx.mode.name}** mode as a delegated subagent. An orchestrator spawned you and will read your final output to decide what to do next.
### Delegation rules
- The \`delegate\` tool is NOT available to you — complete your task directly using the available tools.
- When you finish, end with a clear, concise summary: what you did, what succeeded, what failed, and any blockers or next steps. The orchestrator uses this to decide whether to delegate again or report final results.
- If you encounter an error you cannot resolve, report it clearly — do not attempt to delegate or re-run yourself.
${ctx.mode.prompt}`;
+1 -1
View File
@@ -69,7 +69,7 @@ export interface SetupGitParams extends GitContext {
* - sets up authentication via gitToken (minimal contents:write)
* - for PR events, checks out the PR branch using shared helper
*
* gitToken is a minimal-permission token (contents:write only) used for git operations.
* gitToken is a minimal-permission token (contents + workflows) used for git operations.
* it is assumed to be potentially exfiltratable, so it has limited scope.
*/
export async function setupGit(params: SetupGitParams): Promise<void> {
+11 -3
View File
@@ -105,12 +105,20 @@ export async function resolveTokens(params: ResolveTokensParams): Promise<TokenR
// create git token based on push permission (assumed exfiltratable)
// disabled = read-only, restricted/enabled = write (MCP tools enforce branch restrictions)
const gitContents = params.push === "disabled" ? "read" : "write";
const gitToken = await acquireNewToken({ permissions: { contents: gitContents } });
// workflows permission is write-only in the API, so only requested when pushing is allowed
const gitPermissions =
params.push === "disabled"
? { contents: "read" as const }
: { contents: "write" as const, workflows: "write" as const };
const gitToken = await acquireNewToken({ permissions: gitPermissions });
if (isGitHubActions) {
core.setSecret(gitToken);
}
log.info(`» acquired git token (contents:${gitContents})`);
log.info(
`» acquired git token (${Object.entries(gitPermissions)
.map((e) => e.join(":"))
.join(", ")})`
);
// create full MCP token - not exfiltratable (only accessible via MCP tools)
const mcpToken = await acquireNewToken();