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
41 changes: 41 additions & 0 deletions src/cli/agent-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ import {
normalizeSetupConfigLike,
operatorEnvPathForHome,
primaryRepoFromConfig,
providerEnvPathForHome,
readSetupConfigFromHome,
recipePathForHome,
resolveAgentRailHome,
type ConnectedRepo,
} from "./agentrail-home.ts";
import { parseSimpleEnv } from "../env-file.ts";
import {
captureCliTelemetry,
createCliTelemetry,
Expand Down Expand Up @@ -469,6 +471,11 @@ export async function runAgentCreate(argv: string[], options: RunAgentCommandOpt
try {
const inputs = await collectCreateInputs({ cwd, flags, repo, setupConfig, prompt });
createAgentId = inputs.agentId;
const githubCheck = await verifyGitHubProviderReadiness(setupConfig, homePath);
if (!githubCheck.ok) {
stderr.write(`${githubCheck.message}\n`);
return 1;
}
const runnerCheck = verifyRunnerReadiness(inputs.runner);
if (prompt) {
await showCreateReview({ prompt, inputs, runnerCheck });
Expand Down Expand Up @@ -2270,6 +2277,40 @@ function runnerDefinitionFor(runner: string): RunnerDefinition {
?? { value: runner, label: runner, description: "Custom local runner.", signInHint: `Make sure you are already signed in to ${runner} on this machine.` };
}

async function verifyGitHubProviderReadiness(
setupConfig: unknown,
homePath: string,
): Promise<{ ok: boolean; message?: string }> {
const github = (setupConfig as { providers?: { github?: { mode?: string; tokenEnv?: string } } })?.providers?.github;
const mode = github?.mode === "real" || github?.mode === "disabled" ? github.mode : "real";
if (mode === "disabled") {
return { ok: true };
}
const tokenEnvName = github?.tokenEnv?.trim() || "GITHUB_TOKEN";
const inheritedToken = process.env[tokenEnvName]?.trim();
if (inheritedToken) {
return { ok: true };
}
let providerEnvToken: string | undefined;
try {
const providerEnvPath = providerEnvPathForHome(homePath);
const content = await readFile(providerEnvPath, "utf8");
providerEnvToken = parseSimpleEnv(content)[tokenEnvName]?.trim();
} catch {
providerEnvToken = undefined;
}
if (providerEnvToken) {
return { ok: true };
}
return {
ok: false,
message:
`AgentRail requires a connected GitHub provider before creating an agent.\n`
+ `Run \`agentrail provider connect github\` first, then retry. `
+ `(Looking for ${tokenEnvName} in shell env or ~/.agentrail/provider.env.)`,
};
}

function verifyRunnerReadiness(runner: string): { ok: boolean; message: string } {
const definition = runnerDefinitionFor(runner);
if (!definition.executable) {
Expand Down
4 changes: 2 additions & 2 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -629,8 +629,8 @@ async function finalizeInit({

if (prompt) {
const shouldConnectGitHub = await prompt.confirm({
message: "Connect GitHub now?",
defaultValue: false,
message: "Connect GitHub now? (Required to create agents and ship pull requests.)",
defaultValue: true,
});
if (shouldConnectGitHub) {
const providerExitCode = await runProviderCommand(["connect", "github"], {
Expand Down
6 changes: 6 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,12 @@ export function startServer() {

server.listen(port, host, () => {
process.stdout.write(`✓ AgentRail API ready at ${publicBaseUrl}\n`);
if (providerConfig.github.mode === "real" && !githubToken) {
process.stdout.write(
"⚠ GitHub provider is enabled but no GitHub token is configured. "
+ "Agents cannot submit pull requests until you run `agentrail provider connect github`.\n",
);
}
void localRunnerSupervisor.start().catch((error) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`Local runner supervisor failed to start: ${message}\n`);
Expand Down
62 changes: 62 additions & 0 deletions test/agent-management.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,68 @@ test("agent create provisions a managed local agent and doctor passes", async (t
assert.doesNotMatch(serialized, /oxnw\/agentrail/);
});

test("agent create refuses without a connected GitHub provider when provider mode is real", async (t) => {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-agent-create-no-gh-"));
const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-"));
const stdout = createMemoryWriter();
const stderr = createMemoryWriter();
const previousHome = process.env.AGENTRAIL_HOME;
const previousToken = process.env.GITHUB_TOKEN;
process.env.AGENTRAIL_HOME = homePath;
delete process.env.GITHUB_TOKEN;
await installFakeExecutableOnPath(t, homePath, "codex");

t.after(async () => {
await rm(repoRoot, { recursive: true, force: true });
await rm(homePath, { recursive: true, force: true });
if (previousHome === undefined) delete process.env.AGENTRAIL_HOME;
else process.env.AGENTRAIL_HOME = previousHome;
if (previousToken !== undefined) process.env.GITHUB_TOKEN = previousToken;
});

const config = createSetupConfig({
cwd: repoRoot,
detectedRepo: {
repoPath: repoRoot,
remoteSlug: "oxnw/agentrail",
defaultBranch: "main",
gitIgnoreHasAgentrail: true,
},
interactionMode: "non_interactive",
acceptedDefaults: true,
baseUrl: "http://127.0.0.1:1",
providerMode: "real",
});
await writeSetupFiles({ homePath, config });

const exitCode = await runCli([
"agent",
"create",
"--base-url",
"http://127.0.0.1:1",
"--agent-id",
"agt_no_gh",
"--name",
"NoGH",
"--runner",
"codex",
"--scopes",
"tasks:read,tasks:write",
"--repo-allowlist",
"oxnw/agentrail",
], {
cwd: repoRoot,
stdinIsTTY: false,
stdoutIsTTY: false,
stdout,
stderr,
});

assert.equal(exitCode, 1);
assert.match(stderr.toString(), /requires a connected GitHub provider/);
assert.match(stderr.toString(), /agentrail provider connect github/);
});

test("agent create rejects the removed default local agent flag", async (t) => {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-agent-create-default-removed-"));
const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-"));
Expand Down
Loading