Files
shockbot/utils/payload.ts
T
2026-01-28 21:06:57 +00:00

163 lines
6.5 KiB
TypeScript

import { isAbsolute, resolve } from "node:path";
import * as core from "@actions/core";
import { type } from "arktype";
import { AgentName, type AuthorPermission, Effort, type PayloadEvent } from "../external.ts";
import packageJson from "../package.json" with { type: "json" };
import type { RepoSettings } from "./runContext.ts";
import { validateCompatibility } from "./versioning.ts";
// tool permission enum types for inputs
const ToolPermissionInput = type.enumerated("disabled", "enabled");
const BashPermissionInput = type.enumerated("disabled", "restricted", "enabled");
// schema for JSON payload passed via prompt (internal dispatch invocation)
// note: permissions are intentionally NOT included here to prevent injection attacks
// permissions are derived from event.authorPermission instead
export const JsonPayload = type({
"~pullfrog": "true",
version: "string",
"agent?": AgentName.or("undefined"),
"prompt?": "string",
"eventInstructions?": "string",
"repoInstructions?": "string",
"event?": "object",
"effort?": Effort.or("undefined"),
"timeout?": type.string.or("undefined"),
});
// permission levels that indicate collaborator status (have push access)
const COLLABORATOR_PERMISSIONS: AuthorPermission[] = ["admin", "maintain", "write"];
// check if the event author has collaborator-level permissions
function isCollaborator(event: PayloadEvent): boolean {
const perm = event.authorPermission;
return perm !== undefined && COLLABORATOR_PERMISSIONS.includes(perm);
}
// inputs schema - action inputs from core.getInput()
// note: tool permissions use .or("undefined") because getInput() || undefined
// explicitly sets the property to undefined when empty, which is different from
// the property being absent. arktype's "prop?" means "optional to include" but
// if included, must match the type - so we need to explicitly allow undefined.
export const Inputs = type({
prompt: "string",
"effort?": Effort.or("undefined"),
"timeout?": type.string.or("undefined"),
"agent?": AgentName.or("undefined"),
"web?": ToolPermissionInput.or("undefined"),
"search?": ToolPermissionInput.or("undefined"),
"write?": ToolPermissionInput.or("undefined"),
"bash?": BashPermissionInput.or("undefined"),
"cwd?": type.string.or("undefined"),
});
export type Inputs = typeof Inputs.infer;
function isAgentName(value: unknown): value is AgentName {
return typeof value === "string" && AgentName(value) instanceof type.errors === false;
}
function isPayloadEvent(value: unknown): value is PayloadEvent {
return typeof value === "object" && value !== null && "trigger" in value;
}
function resolveCwd(cwd: string | undefined): string | undefined {
const workspace = process.env.GITHUB_WORKSPACE;
if (!cwd) return workspace;
if (isAbsolute(cwd)) return cwd;
return workspace ? resolve(workspace, cwd) : cwd;
}
export function resolvePayload(repoSettings: RepoSettings) {
const inputs = Inputs.assert({
prompt: core.getInput("prompt", { required: true }),
effort: core.getInput("effort") || undefined,
timeout: core.getInput("timeout") || undefined,
agent: core.getInput("agent") || undefined,
cwd: core.getInput("cwd") || undefined,
web: core.getInput("web") || undefined,
search: core.getInput("search") || undefined,
write: core.getInput("write") || undefined,
bash: core.getInput("bash") || undefined,
});
// validate agent name
const agent: AgentName | undefined =
inputs.agent !== undefined && isAgentName(inputs.agent) ? inputs.agent : undefined;
// try to parse prompt as JSON payload (internal invocation)
let jsonPayload: typeof JsonPayload.infer | null = null;
try {
const parsed = JSON.parse(inputs.prompt);
// if it looks like a pullfrog payload but fails validation, that's an error
if (parsed && typeof parsed === "object" && "~pullfrog" in parsed) {
jsonPayload = JsonPayload.assert(parsed);
}
} catch (error) {
// JSON parse error is fine (plain text prompt), but validation error should propagate
if (error instanceof type.errors) {
throw new Error(`invalid pullfrog payload: ${error.summary}`);
}
// not JSON, treat as plain string prompt
}
// validate version compatibility from jsonPayload
if (jsonPayload) validateCompatibility(jsonPayload.version, packageJson.version);
// resolve event - use type guard for jsonPayload.event, fallback to unknown trigger
const rawEvent = jsonPayload?.event;
const event: PayloadEvent = isPayloadEvent(rawEvent) ? rawEvent : { trigger: "unknown" };
// resolve agent from jsonPayload with type guard
const jsonAgent = jsonPayload?.agent;
const resolvedAgent: AgentName | undefined =
agent ?? (jsonAgent !== undefined && isAgentName(jsonAgent) ? jsonAgent : undefined);
// determine bash permission - strictest setting wins
// precedence: disabled > restricted > enabled
// non-collaborators always get at least "restricted"
const isNonCollaborator = !isCollaborator(event);
const repoBash = repoSettings.bash ?? "restricted";
const inputBash = inputs.bash;
// resolve bash: start with repo setting, then apply restrictions
let resolvedBash = repoBash;
// input can only make it stricter (disabled > restricted > enabled)
if (inputBash === "disabled") {
resolvedBash = "disabled";
} else if (inputBash === "restricted" && resolvedBash === "enabled") {
resolvedBash = "restricted";
}
// non-collaborators get at least "restricted" (can't have "enabled")
if (isNonCollaborator && resolvedBash === "enabled") {
resolvedBash = "restricted";
}
// build payload - precedence: inputs > repoSettings > fallbacks
// note: modes are NOT in payload - they come from repoSettings in main()
return {
"~pullfrog": true as const,
version: jsonPayload?.version ?? packageJson.version,
agent: resolvedAgent,
// inverted: jsonPayload.prompt extracts the text from the JSON payload,
// whereas inputs.prompt IS the raw JSON string when internally dispatched
prompt: jsonPayload?.prompt ?? inputs.prompt,
eventInstructions: jsonPayload?.eventInstructions,
repoInstructions: jsonPayload?.repoInstructions,
event,
effort: inputs.effort ?? jsonPayload?.effort ?? "auto",
timeout: inputs.timeout ?? jsonPayload?.timeout,
cwd: resolveCwd(inputs.cwd),
// permissions: inputs > repoSettings > fallbacks
web: inputs.web ?? repoSettings.web ?? "enabled",
search: inputs.search ?? repoSettings.search ?? "enabled",
write: inputs.write ?? repoSettings.write ?? "enabled",
bash: resolvedBash,
};
}
export type ResolvedPayload = ReturnType<typeof resolvePayload>;