diff --git a/src/cli/agent-management.ts b/src/cli/agent-management.ts index a473dd0..b27d5e0 100644 --- a/src/cli/agent-management.ts +++ b/src/cli/agent-management.ts @@ -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, @@ -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 }); @@ -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) { diff --git a/src/cli/index.ts b/src/cli/index.ts index a3256c0..14d9a48 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -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"], { diff --git a/src/server.ts b/src/server.ts index 1772293..5e63fee 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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`); diff --git a/test/agent-management.test.ts b/test/agent-management.test.ts index 48cc076..c319b2d 100644 --- a/test/agent-management.test.ts +++ b/test/agent-management.test.ts @@ -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-"));