@uchibeke/gallivant 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -60
- package/SECURITY.md +9 -0
- package/bin/gallivant.mjs +41 -14
- package/bin/promote-bitbucket.mjs +40 -0
- package/bin/task-pr.mjs +9 -0
- package/bin/task-review.mjs +15 -0
- package/docs/README.md +6 -2
- package/docs/agent-setup.md +55 -41
- package/docs/architecture.md +11 -3
- package/docs/bitbucket-promotion.md +69 -0
- package/docs/bitbucket-review.md +330 -0
- package/docs/chains.md +30 -1
- package/docs/codex-cloud.md +7 -0
- package/docs/codex-review.md +50 -0
- package/docs/commands.md +103 -14
- package/docs/config.md +45 -13
- package/docs/execution.md +1 -1
- package/docs/first-party-review.md +266 -0
- package/docs/git-hosts.md +294 -0
- package/docs/guardrails.md +56 -0
- package/docs/install.md +137 -67
- package/docs/launch.md +7 -4
- package/docs/network-recovery.md +9 -0
- package/docs/openai-agents.md +3 -2
- package/docs/prd.md +23 -2
- package/docs/preparation.md +2 -2
- package/docs/release-qualification.md +1 -1
- package/docs/releasing.md +46 -22
- package/docs/task-delivery.md +5 -5
- package/docs/task-profiles.md +537 -0
- package/docs/tasks.md +59 -197
- package/lib/attribution.mjs +7 -3
- package/lib/block.mjs +10 -1
- package/lib/chains.mjs +101 -30
- package/lib/codex_cloud.mjs +2 -1
- package/lib/codex_cloud_input.mjs +15 -6
- package/lib/codex_cloud_result.mjs +1 -1
- package/lib/commands/doctor.mjs +22 -453
- package/lib/commands/init.mjs +281 -28
- package/lib/commands/register.mjs +81 -13
- package/lib/commands/resume.mjs +5 -1
- package/lib/commands/review.mjs +66 -0
- package/lib/commands/serve.mjs +8 -0
- package/lib/commands/start.mjs +30 -11
- package/lib/commands/status.mjs +18 -2
- package/lib/commands/task.mjs +9 -1
- package/lib/commands/up.mjs +19 -11
- package/lib/commands/why.mjs +7 -3
- package/lib/config.mjs +42 -9
- package/lib/context.mjs +28 -21
- package/lib/control.mjs +21 -6
- package/lib/credential.mjs +20 -14
- package/lib/deliverable.mjs +2 -0
- package/lib/events.mjs +96 -53
- package/lib/execution.mjs +132 -11
- package/lib/execution_preparation.mjs +16 -1
- package/lib/execution_result.mjs +26 -5
- package/lib/first_party_review.mjs +618 -0
- package/lib/fs.mjs +10 -3
- package/lib/gh.mjs +144 -56
- package/lib/git.mjs +133 -32
- package/lib/git_diff.mjs +121 -0
- package/lib/harness.mjs +14 -10
- package/lib/host_contract.mjs +71 -0
- package/lib/host_definitions.mjs +22 -0
- package/lib/hosts/bitbucket.mjs +553 -0
- package/lib/hosts/bitbucket_contract.mjs +57 -0
- package/lib/hosts/bitbucket_credential.mjs +62 -0
- package/lib/hosts/bitbucket_review.mjs +44 -0
- package/lib/hosts/bitbucket_review_producer.mjs +50 -0
- package/lib/hosts/github.mjs +57 -0
- package/lib/hosts/github_contract.mjs +27 -0
- package/lib/hosts/github_doctor.mjs +482 -0
- package/lib/hosts/github_review_summary.mjs +38 -0
- package/lib/hosts/transport_doctor.mjs +35 -0
- package/lib/hosts.mjs +130 -0
- package/lib/learning_qualification.mjs +1 -1
- package/lib/notify.mjs +1 -1
- package/lib/openai_agents_input.mjs +7 -2
- package/lib/openai_agents_sources.mjs +16 -8
- package/lib/plugin_package.mjs +13 -1
- package/lib/pr_operation.mjs +41 -0
- package/lib/prd.mjs +18 -3
- package/lib/preparation_codex.mjs +6 -1
- package/lib/probe.mjs +6 -4
- package/lib/process.mjs +24 -0
- package/lib/promotion.mjs +132 -0
- package/lib/promotion_git.mjs +63 -0
- package/lib/prompt.mjs +16 -13
- package/lib/provider_health.mjs +14 -3
- package/lib/repository.mjs +68 -0
- package/lib/repository_preflight.mjs +11 -0
- package/lib/review_evaluation.mjs +25 -0
- package/lib/review_evidence.mjs +500 -0
- package/lib/review_history.mjs +73 -0
- package/lib/review_image.mjs +145 -0
- package/lib/review_link.mjs +12 -0
- package/lib/review_policy.mjs +132 -0
- package/lib/review_policy_contract.mjs +18 -0
- package/lib/review_state.mjs +13 -0
- package/lib/review_visual.mjs +94 -0
- package/lib/runtime-environment.mjs +6 -0
- package/lib/runtime_node.mjs +3 -2
- package/lib/runtime_state.mjs +39 -5
- package/lib/sources/prd.mjs +6 -3
- package/lib/state.mjs +137 -24
- package/lib/task_completion.mjs +7 -3
- package/lib/task_contract.mjs +201 -27
- package/lib/task_delivery.mjs +9 -2
- package/lib/task_delivery_contract.mjs +13 -9
- package/lib/task_delivery_view.mjs +18 -2
- package/lib/task_environment.mjs +13 -1
- package/lib/task_limits.mjs +13 -0
- package/lib/task_model_config.mjs +99 -0
- package/lib/task_model_route.mjs +243 -0
- package/lib/task_prepare.mjs +21 -8
- package/lib/task_profile.mjs +1875 -0
- package/lib/task_repository_context.mjs +65 -0
- package/lib/tasks.mjs +252 -51
- package/lib/tick.mjs +373 -98
- package/lib/time.mjs +1 -0
- package/lib/tracker/pages.mjs +19 -5
- package/lib/tracker/review.mjs +21 -0
- package/lib/tracker_process.mjs +8 -1
- package/lib/verification_sandbox.mjs +2 -1
- package/lib/work_artifacts.mjs +79 -0
- package/npm-shrinkwrap.json +12 -3
- package/package.json +9 -3
- package/templates/plugin/task-delivery.SKILL.md +17 -10
- package/templates/plugin/task-intake-contract.json +18 -0
- package/templates/plugin/task-profile-requests.mjs +1448 -0
- package/templates/tracker/tracker.css +3 -0
- package/templates/tracker/tracker.js +38 -0
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import { hostApi } from "./hosts.mjs";
|
|
1
2
|
import { prepareAgentsSources, contentHash, portablePath } from "./openai_agents_sources.mjs";
|
|
2
3
|
import { validateCloudConfig } from "./codex_cloud_config.mjs";
|
|
3
4
|
import { executionError } from "./execution_io.mjs";
|
|
4
5
|
import { isOurBranch } from "./git.mjs";
|
|
5
|
-
import { remoteMatches, repositoryMatches } from "./
|
|
6
|
+
import { remoteMatches, repositoryMatches } from "./repository.mjs";
|
|
6
7
|
import { acquirePublicationLock } from "./execution_result.mjs";
|
|
7
8
|
import { executionLive } from "./execution.mjs";
|
|
8
9
|
import { CLOUD_RESULT_PATH } from "./codex_cloud_result.mjs";
|
|
9
10
|
import { taskProviderAllowance } from "./task_allowance.mjs";
|
|
11
|
+
import { parsePrd } from "./prd.mjs";
|
|
12
|
+
import { publicationPolicyOf, workArtifactReason } from "./work_artifacts.mjs";
|
|
10
13
|
|
|
11
14
|
// Called by the same initial/resume/review dispatch boundary as the API adapter.
|
|
12
15
|
// Commit only the already approved overlays in the task's isolated checkout.
|
|
@@ -27,22 +30,28 @@ async function deliverCloudInput(ctx, id, entry, input, contextPaths) {
|
|
|
27
30
|
const overlays = [entry.prd && { path: entry.prd, text: ctx.readPrd(id) }, ...(entry.sources || []).map(path => ({ path, text: ctx.readSource(id, path) }))].filter(Boolean);
|
|
28
31
|
const snapshot = await prepareAgentsSources({ worktree: entry.worktree, inputHead: input.inputHead, repo: entry.repo, branch: entry.branch, base: entry.base, overlays });
|
|
29
32
|
if (Object.hasOwn(snapshot.hashes, CLOUD_RESULT_PATH)) throw executionError("CLOUD_SOURCE", "reserved Cloud result envelope path exists in the approved input; choose another repository layout before dispatch");
|
|
33
|
+
const publication = publicationPolicyOf(parsePrd(ctx.readPrd(id) || "", entry.prd), { sources: entry.sources || [] });
|
|
34
|
+
for (const item of overlays) {
|
|
35
|
+
if (workArtifactReason(item.path, publication) && await ctx.git.fileAt(entry.worktree, input.inputHead, item.path) !== item.text) {
|
|
36
|
+
throw executionError("CLOUD_SOURCE", "Cloud Git transport cannot privately deliver new or changed carried inputs; no input was committed or pushed. Use local execution or openai-agents. Only an explicitly requested output file may be approved in publish_files; do not publish private context to unblock dispatch.");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
30
39
|
if (!await ctx.git.resultModesMatch(entry.worktree, input.inputHead, overlays.map(item => item.path))) throw executionError("CLOUD_SOURCE", "approved snapshot mode changed; preserve the checkout and repair input before dispatch");
|
|
31
40
|
await ctx.git.commitResult(entry.worktree, overlays.map(item => portablePath(item.path)));
|
|
32
41
|
const inputHead = await ctx.git.head(entry.worktree);
|
|
33
|
-
await ctx.git.pushResult(entry.worktree, entry.branch);
|
|
34
|
-
if (await ctx.
|
|
42
|
+
await ctx.git.pushResult(entry.worktree, entry.branch, entry.repo);
|
|
43
|
+
if (await hostApi(ctx).refSha(entry.repo, entry.branch) !== inputHead) throw executionError("CLOUD_SOURCE", "remote task branch does not match the exact committed input head");
|
|
35
44
|
const hashes = {};
|
|
36
45
|
for (const item of overlays) {
|
|
37
|
-
const text = await ctx.
|
|
46
|
+
const text = await hostApi(ctx).fileAtStrict(entry.repo, item.path, inputHead);
|
|
38
47
|
if (text !== item.text) throw executionError("CLOUD_SOURCE", "approved snapshot remote bytes differ; nothing dispatched");
|
|
39
48
|
hashes[item.path] = contentHash(text);
|
|
40
49
|
}
|
|
41
50
|
// A unique input ref is never advanced by the coordinator. Cloud reads this
|
|
42
51
|
// revision even if a reviewer/operator later advances the logical PR branch.
|
|
43
52
|
const inputBranch = `${entry.branch}-cloud-${inputHead}`;
|
|
44
|
-
await ctx.git.pushSnapshotRef(entry.worktree, inputBranch);
|
|
45
|
-
if (await ctx.
|
|
53
|
+
await ctx.git.pushSnapshotRef(entry.worktree, inputBranch, entry.repo);
|
|
54
|
+
if (await hostApi(ctx).refSha(entry.repo, inputBranch) !== inputHead || await ctx.git.head(entry.worktree) !== inputHead) throw executionError("CLOUD_SOURCE", "Cloud input revision moved before dispatch");
|
|
46
55
|
const sources = await prepareAgentsSources({ worktree: entry.worktree, inputHead, repo: entry.repo, branch: entry.branch, base: entry.base, overlays: [] });
|
|
47
56
|
return { ...input, inputHead, repo: entry.repo, branch: entry.branch, base: entry.base, environment: config.environment, allowance: taskProviderAllowance(config.allowance, entry.task),
|
|
48
57
|
mandatoryPolicy: config.mandatory_policy || [], sources: { data: sources.bytes.toString("base64"), hash: sources.hash, hashes, inputHead, inputBranch, repo: entry.repo, branch: entry.branch, snapshotHash: snapshot.hash } };
|
|
@@ -4,7 +4,7 @@ import { mkdtempSync, rmSync, writeFileSync, mkdirSync, chmodSync, readFileSync
|
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join, dirname } from "node:path";
|
|
6
6
|
import { gunzipSync } from "node:zlib";
|
|
7
|
-
import { execFileAsync } from "./
|
|
7
|
+
import { execFileAsync } from "./process.mjs";
|
|
8
8
|
import { runEnv } from "./git.mjs";
|
|
9
9
|
import { contentHash, portablePath } from "./openai_agents_sources.mjs";
|
|
10
10
|
import { assertRepositoryTargets, readRepositoryText } from "./repository_files.mjs";
|
package/lib/commands/doctor.mjs
CHANGED
|
@@ -1,457 +1,26 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
1
|
+
// Compatibility exports for the existing install diagnostics. Host-specific ruleset
|
|
2
|
+
// and CLI policy lives with the qualified adapter, never in the lifecycle.
|
|
3
|
+
export * from "../hosts/github_doctor.mjs";
|
|
4
|
+
import { doctor as githubDoctor } from "../hosts/github_doctor.mjs";
|
|
5
|
+
import { transportDoctor } from "../hosts/transport_doctor.mjs";
|
|
6
|
+
import { makeHostRegistry } from "../hosts.mjs";
|
|
7
|
+
import { configPath, repoNames } from "../config.mjs";
|
|
8
|
+
import { readJson, gallivantHome } from "../fs.mjs";
|
|
4
9
|
import { execFileSync } from "node:child_process";
|
|
5
|
-
import { realpathSync } from "node:fs";
|
|
6
|
-
import { configPath, validate, merge, isInside, DEFAULTS, load as loadConfig, whence, discoverHarnesses, repoEntries, repoNames, resolveBase } from "../config.mjs";
|
|
7
|
-
import { readJson, exists, fileMode, contract, expand, gallivantHome, templatePath } from "../fs.mjs";
|
|
8
|
-
import { aportDirFor, guardrailMode, auditEntries, decisionCounts } from "../aport.mjs";
|
|
9
|
-
import { makeGh } from "../gh.mjs";
|
|
10
|
-
import { runEnv } from "../git.mjs";
|
|
11
|
-
import { resolveCredential, noCredentialMessage, describe as describeCredential, SOURCES } from "../credential.mjs";
|
|
12
|
-
import { checkHarness, checkSignIn, checkVersion, checkGh, probeFix } from "../probe.mjs";
|
|
13
|
-
import { capabilitiesFor, buildRun, contextSupport } from "../harness.mjs";
|
|
14
|
-
import { makeNotifier } from "../notify.mjs";
|
|
15
|
-
import { silentLog } from "../log.mjs";
|
|
16
10
|
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
`use the configured executable explicitly for authorized tests, or align the shell command with that verified installation. Do not switch harness.${harness}.bin to the shell version without verifying it. Version and sign-in checks do not prove model access or host/plugin capabilities`);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
export const CONTROL_PLANE = ["~/.claude", "~/.codex", "~/.config/opencode", "~/.gallivant", "~/.aport"];
|
|
37
|
-
|
|
38
|
-
// Fine-grained tokens have no OAuth scope header; per-repository permissions
|
|
39
|
-
// are checked separately. Public-only OAuth must not require private repo access.
|
|
40
|
-
export const hasRepositoryWriteScope = scopes => scopes === null || String(scopes).split(/[\s,]+/).some(scope => scope === "repo" || scope === "public_repo");
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* What a local passport is missing, as a list of sentences, empty when it is sound. Exported because it is the only
|
|
44
|
-
* thing in `doctor` that speaks for the guardrail the docs promise, so it is worth testing on its own.
|
|
45
|
-
*
|
|
46
|
-
* Both directions are checked. Write blocks stop a run editing the rules it is held to; read blocks stop it reading
|
|
47
|
-
* them, which matters just as much: an injected run that can Read or Grep `~/.claude/settings.json`, the passport in
|
|
48
|
-
* `~/.aport`, or `~/.codex` learns exactly where the boundary is and what credentials sit behind it. A template that
|
|
49
|
-
* blocked only writes left that open, so the read list is held to the same five directories as the write list.
|
|
50
|
-
*
|
|
51
|
-
* Write `blocked_paths` and read `blocked_patterns` entries are checked separately;
|
|
52
|
-
* only the directory prefix is compared, so `~/.codex/**` counts for `~/.codex`
|
|
53
|
-
* and a single file inside it, `~/.codex/auth.json`, does not.
|
|
54
|
-
*/
|
|
55
|
-
export function passportGaps(passport, worktreesRoot) {
|
|
56
|
-
const problems = [];
|
|
57
|
-
const write = passport?.limits?.["data.file.write"] || {};
|
|
58
|
-
const dirs = list => (list || []).map(x => expand(String(x).replace(/\/\*\*$/, "")));
|
|
59
|
-
if (!dirs(write.allowed_paths).includes(worktreesRoot)) problems.push("data.file.write allowed_paths does not contain the worktrees root");
|
|
60
|
-
for (const [capability, limits, field] of [["data.file.write", write, "blocked_paths"], ["data.file.read", passport?.limits?.["data.file.read"] || {}, "blocked_patterns"]]) {
|
|
61
|
-
const blocked = dirs(limits[field]);
|
|
62
|
-
const missing = CONTROL_PLANE.filter(c => !blocked.includes(expand(c)));
|
|
63
|
-
if (missing.length) problems.push(`${capability} ${field} missing ${missing.join(", ")}`);
|
|
64
|
-
}
|
|
65
|
-
return problems;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// The install instructions and doctor's repair must describe the same sandbox.
|
|
69
|
-
export const SANDBOX_BLOCK = { sandbox: readJson(templatePath("claude-settings.json")).sandbox };
|
|
70
|
-
|
|
71
|
-
/** Check the native Claude sandbox, including macOS's certificate-verification dependency. */
|
|
72
|
-
export function claudeSandboxCheck(settings, settingsPath, platform = process.platform) {
|
|
73
|
-
const sb = settings?.sandbox || {};
|
|
74
|
-
if (sb.enabled !== true || sb.autoAllowBashIfSandboxed !== true || !Array.isArray(sb.network?.allowedDomains) || !sb.network.allowedDomains.length) {
|
|
75
|
-
return fail("sandbox block missing or invalid", `merge this into ${contract(settingsPath)}:\n${JSON.stringify(SANDBOX_BLOCK, null, 2)}`);
|
|
76
|
-
}
|
|
77
|
-
if (platform === "darwin" && sb.enableWeakerNetworkIsolation !== true) {
|
|
78
|
-
return fail("macOS certificate service blocked; sandboxed gh can fail with x509: OSStatus -26276",
|
|
79
|
-
`set sandbox.enableWeakerNetworkIsolation to true in ${contract(settingsPath)} for Apple's trustd.agent. This opens a potential exfiltration route through that service; review ${templatePath("../docs/guardrails.md")} before enabling it. Keep the sandbox and domain allowlist enabled`);
|
|
80
|
-
}
|
|
81
|
-
return ok(contract(settingsPath));
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** A config the operator can paste, with every value taken from this machine, not the author's. */
|
|
85
|
-
export function suggestedConfig(home, login) {
|
|
86
|
-
// The one place git is run without going through `makeGit`, on purpose: this builds the config that `makeGit`
|
|
87
|
-
// needs, so there is nothing to ask yet. Synchronous and failure-tolerant for the same reason, since a machine
|
|
88
|
-
// with no repository here still deserves a config to paste.
|
|
89
|
-
const cwdRoot = (() => { try { return execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim(); } catch { return process.cwd(); } })();
|
|
90
|
-
const harness = discoverHarnesses(home);
|
|
91
|
-
return {
|
|
92
|
-
...DEFAULTS,
|
|
93
|
-
trusted_authors: login ? [login] : ["<your github login>"],
|
|
94
|
-
notify: { ntfy: "https://ntfy.sh/<32 random characters>", email: null },
|
|
95
|
-
worktrees: join(dirname(cwdRoot), ".gallivant-worktrees"),
|
|
96
|
-
github_token_file: null,
|
|
97
|
-
harness,
|
|
98
|
-
repos: [],
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* `env`, `resolve` and `probePush` are injected for one reason: the credential checks have to be testable. Their whole
|
|
104
|
-
* purpose is to answer "could a run push", and a check that can only be exercised against whatever `gh` the machine
|
|
105
|
-
* running the test happens to have logged in is a check whose failure path is never tested. All three default to the
|
|
106
|
-
* real thing.
|
|
107
|
-
*/
|
|
108
|
-
export async function doctor({ home = gallivantHome(), send = false, probe = false, env = process.env, resolve = resolveCredential, probePush = gitPushProbe, harnessCheck = checkHarness } = {}) {
|
|
109
|
-
const results = [];
|
|
110
|
-
const check = async (name, fn) => { let r; try { r = await fn(); } catch (e) { r = fail(e.message.split("\n")[0], "see the error above"); } results.push({ name, ...r }); return r; };
|
|
111
|
-
|
|
112
|
-
// Config first; most checks need it.
|
|
113
|
-
const raw = readJson(configPath(home), null);
|
|
114
|
-
const rawGh = makeGh({ log: silentLog });
|
|
115
|
-
const rawAuth = await checkGh(rawGh);
|
|
116
|
-
const login = rawAuth.ok ? rawAuth.detail : null;
|
|
117
|
-
const cfgCheck = await check("config", () => {
|
|
118
|
-
if (!raw) return fail(`no ${contract(configPath(home))}`, `write this file (values from this machine):\n${JSON.stringify(suggestedConfig(home, login), null, 2)}`);
|
|
119
|
-
// `merge` defaults to the real home, so a doctor run against another home (a test, a second instance) validated a
|
|
120
|
-
// config whose discovered harness settings pointed into the default directory while every check below read this
|
|
121
|
-
// one. `load` in config.mjs always passed `home`; doctor was the second caller and did not.
|
|
122
|
-
const problems = validate(merge(raw, home), home);
|
|
123
|
-
if (problems.length) return fail(problems.join("; "), `fix those keys in ${contract(configPath(home))}`);
|
|
124
|
-
const mode = fileMode(configPath(home));
|
|
125
|
-
if (mode !== 0o600) return fail(`mode ${mode?.toString(8)}`, `chmod 600 ${contract(configPath(home))}`);
|
|
126
|
-
return ok(contract(configPath(home)));
|
|
127
|
-
});
|
|
128
|
-
await check("gh auth", async () => rawAuth.ok ? ok(login || "authenticated") : fail(rawAuth.detail, probeFix("gh", rawAuth)));
|
|
129
|
-
if (!cfgCheck.ok) return results;
|
|
130
|
-
|
|
131
|
-
const config = loadConfig(home);
|
|
132
|
-
|
|
133
|
-
// The credential a RUN would get, resolved exactly as `start` and the tick resolve it, so every check below answers
|
|
134
|
-
// for the sandboxed child rather than for the operator's own shell. The old check asked `gh` about the operator's
|
|
135
|
-
// keyring and reported "system gh auth: ok" while every run was unable to push, which is worse than no check.
|
|
136
|
-
let credentialError = null;
|
|
137
|
-
const runCredential = await resolve(config, { env }).catch(error => {
|
|
138
|
-
credentialError = error;
|
|
139
|
-
return { token: null, source: null, host: "github.com", why: error.code === "NO_CREDENTIAL" ? error.message : noCredentialMessage() };
|
|
140
|
-
});
|
|
141
|
-
// So `gh` acts with that credential. `rawGh` above is deliberately the other thing: the operator's own gh, used only
|
|
142
|
-
// to name the login for the suggested config and to answer "is gh itself signed in".
|
|
143
|
-
const gh = makeGh({ credential: async () => {
|
|
144
|
-
// Keep a failed explicit credential source closed even for later repository
|
|
145
|
-
// diagnostics; a null result would let gh use a broader ambient login.
|
|
146
|
-
if (credentialError) throw credentialError;
|
|
147
|
-
return runCredential;
|
|
148
|
-
}, log: silentLog });
|
|
149
|
-
|
|
150
|
-
await check("run credential", async () => {
|
|
151
|
-
const p = config.github_token_file;
|
|
152
|
-
if (p) {
|
|
153
|
-
if (!exists(p)) return fail(`${contract(p)} missing`, `paste a fine-grained PAT (Contents, Pull requests, Issues: write; Checks, Actions: read; no Workflows) into ${contract(p)} and chmod 600 it, or set github_token_file to null and restart the daemon to resolve from gh`);
|
|
154
|
-
if (fileMode(p) !== 0o600) return fail(`mode ${fileMode(p)?.toString(8)}`, `chmod 600 ${contract(p)}`);
|
|
155
|
-
}
|
|
156
|
-
if (!runCredential.token) return fail("a run would get no credential and could not push", runCredential.why);
|
|
157
|
-
// A token that resolves is only half the answer. It has to be accepted, and it has to be able to write; a
|
|
158
|
-
// resolvable token that cannot push is the same defect one step later.
|
|
159
|
-
const r = await checkGh(gh);
|
|
160
|
-
if (!r.ok) return r.fix
|
|
161
|
-
? fail(`could not verify ${describeCredential(runCredential)} with GitHub: ${r.detail}`, r.fix)
|
|
162
|
-
: fail(`${describeCredential(runCredential)} is rejected by GitHub: ${r.detail}`, runCredential.source === SOURCES.file ? `the token in ${contract(p)} is expired or revoked; paste a new one` : "gh auth login, or export a valid GH_TOKEN");
|
|
163
|
-
const scopes = await gh.tokenScopes().catch(() => null);
|
|
164
|
-
const kind = scopes === null ? "fine-grained" : `classic (${scopes})`;
|
|
165
|
-
// `repo` or public-only `public_repo` permits writes. A fine-grained PAT carries no scopes at all,
|
|
166
|
-
// so the absence of the header is not a finding; per-repo permissions are what the next check asks GitHub about.
|
|
167
|
-
if (!hasRepositoryWriteScope(scopes)) return fail(`${describeCredential(runCredential)} has no repository write scope (${scopes})`, "for public repositories use public_repo, or put a selected-repository fine-grained PAT with Contents: write in github_token_file");
|
|
168
|
-
return ok(`${describeCredential(runCredential)}, ${kind}, acts as ${r.detail}`);
|
|
169
|
-
});
|
|
170
|
-
// Push rights per repo, asked of GitHub. Scopes say what a token is allowed to attempt; `permissions.push` says
|
|
171
|
-
// whether this identity may write to this repository, which is the thing a run actually needs and the thing an
|
|
172
|
-
// unapproved fine-grained PAT or a lost collaborator invite silently takes away.
|
|
173
|
-
if (runCredential.token) {
|
|
174
|
-
for (const { repo } of repoEntries(config)) {
|
|
175
|
-
await check(`push rights ${repo}`, async () => {
|
|
176
|
-
const perm = await gh.repoPermissions(repo).catch(e => ({ error: e.message.split("\n")[0] }));
|
|
177
|
-
if (perm?.error) return fail(`cannot read ${repo}: ${perm.error}`, `check the repository name, and that ${describeCredential(runCredential)} can see it`);
|
|
178
|
-
if (perm?.archived) return fail(`${repo} is archived, so nothing can be pushed to it`, `unarchive ${repo}, or take it out of repos in ${contract(configPath(home))}`);
|
|
179
|
-
if (!perm?.push) return fail(`the run credential has no push permission on ${repo}`, config.github_token_file ? `give the PAT in ${contract(config.github_token_file)} Contents: write on ${repo} (and have an org owner approve it if ${repo.split("/")[0]} requires approval)` : `ask for write access on ${repo}, or point github_token_file at a PAT that has it`);
|
|
180
|
-
return ok("push allowed");
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
// What a RUN can actually do with git, asked by doing it. This replaced a check that read `~/.gallivant/gitconfig`
|
|
185
|
-
// and grepped its helper lines, which was the wrong question twice over: nothing reads that file any more, and even
|
|
186
|
-
// when something did, a file whose CONTENT is correct still fails if the process cannot READ it. That is exactly
|
|
187
|
-
// what happened. See `gitPushProbe` for what this runs and why HOME is pointed at nothing.
|
|
188
|
-
if (runCredential.token) {
|
|
189
|
-
await check("run git push", async () => probePush({ config, credential: runCredential, repoEntries: repoEntries(config) }));
|
|
190
|
-
}
|
|
191
|
-
for (const h of Object.keys(config.harness)) {
|
|
192
|
-
if (capabilitiesFor(h).transport === "remote") { await check(`${h} readiness`, () => harnessCheck(h, config)); continue; }
|
|
193
|
-
await check(`${h} binary`, () => harnessBinaryCheck(h, config));
|
|
194
|
-
await check(`${h} sign-in`, async () => { const r = await checkSignIn(h, config); return r.ok ? ok() : fail(r.detail, probeFix(h, r)); });
|
|
195
|
-
}
|
|
196
|
-
// Extra read-only directories reach the harnesses unevenly, so say per harness what the operator's own list will
|
|
197
|
-
// actually do rather than letting them assume it works everywhere. Only checked when they have asked for one.
|
|
198
|
-
if (config.context_paths?.length) {
|
|
199
|
-
for (const h of Object.keys(config.harness)) {
|
|
200
|
-
await check(`${h} context_paths`, () => {
|
|
201
|
-
if (capabilitiesFor(h).transport === "remote") return fail("hosted backend cannot enforce private read-only context", "retain a capable local backend or supply authorized portable project documents");
|
|
202
|
-
const s = contextSupport(h);
|
|
203
|
-
if (s.kind === "grant") return ok(`${config.context_paths.length} extra read-only director${config.context_paths.length === 1 ? "y" : "ies"}`);
|
|
204
|
-
if (s.kind === "refuse") return fail(s.detail, `remove context_paths, or stop sending briefs to ${h}`);
|
|
205
|
-
return ok(`no effect: ${s.detail}`);
|
|
206
|
-
});
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
await check("jq", () => whence("jq") ? ok(whence("jq")) : fail("missing; the APort hook denies every call without it", "install jq with your system package manager (for example: brew install jq on macOS, apt install jq on Debian/Ubuntu, or dnf install jq on Fedora); run gallivant doctor again"));
|
|
210
|
-
|
|
211
|
-
if (config.harness.claude) {
|
|
212
|
-
const settingsPath = config.harness.claude.settings;
|
|
213
|
-
await check("claude settings file", () => {
|
|
214
|
-
const s = readJson(settingsPath, null);
|
|
215
|
-
const install = `APORT_CLAUDE_CODE_CONFIG_DIR=${contract(dirname(settingsPath))} npx @aporthq/aport-agent-guardrails claude-code # choose "Create local passport file"`;
|
|
216
|
-
if (!s) return fail(`${contract(settingsPath)} missing`, install);
|
|
217
|
-
const hooks = s.hooks?.PreToolUse || [];
|
|
218
|
-
const hasHook = hooks.some(h => (h.matcher === "*" || !h.matcher) && JSON.stringify(h).includes("aport"));
|
|
219
|
-
if (!hasHook) return fail("no APort PreToolUse hook with matcher *", install);
|
|
220
|
-
return claudeSandboxCheck(s, settingsPath);
|
|
221
|
-
});
|
|
222
|
-
// Denials the guardrail did not actually decide. A warn rather than a fail: nothing is misconfigured here, and the
|
|
223
|
-
// operator may have nothing to fix, but they need the number before they conclude their policy is too tight. A run
|
|
224
|
-
// sees `oap.evaluation_error` as "APort denied this tool call" exactly as it sees `oap.blocked_pattern`, so a spell
|
|
225
|
-
// of rate limits from aport.io is indistinguishable, from inside the session, from a rule that is too strict.
|
|
226
|
-
await check("guardrail decisions", () => {
|
|
227
|
-
const counts = decisionCounts(aportDirFor(settingsPath));
|
|
228
|
-
if (!counts.total) return ok("no decisions recorded yet");
|
|
229
|
-
const rate = counts.denied ? Math.round((counts.evaluationErrors / counts.denied) * 100) : 0;
|
|
230
|
-
const summary = `${counts.total} decisions, ${counts.denied} denied`;
|
|
231
|
-
if (!counts.evaluationErrors) return ok(`${summary}, all of them policy decisions`);
|
|
232
|
-
// `ok` rather than a failure, deliberately. Nothing here is misconfigured and there may be nothing to fix, so
|
|
233
|
-
// this must not add to the failed count and change what a non-zero exit means. It is a number the operator needs
|
|
234
|
-
// in front of them, not a verdict on their setup.
|
|
235
|
-
return ok(`${summary}, of which ${counts.evaluationErrors} (${rate}% of denials) were oap.evaluation_error rather than a policy decision: the guardrail could not reach a decision and denied because it fails closed. Those are its own transport or schema failures (an HTTP 429 from aport.io, or its request failing validation), so check aport.io before loosening a rule`);
|
|
236
|
-
});
|
|
237
|
-
await check("passport", () => {
|
|
238
|
-
const aportDir = aportDirFor(settingsPath);
|
|
239
|
-
const { mode, agentId, enforcement } = guardrailMode(aportDir);
|
|
240
|
-
// A hosted passport's limits live at aport.io, edited in the dashboard, so nothing here can read or fix them.
|
|
241
|
-
// Say what they should be and whose job it is, rather than reporting a check that was never made.
|
|
242
|
-
if (mode === "api" && agentId) return ok(`hosted passport ${agentId} (${enforcement}); its limits are not readable from here. In the dashboard set data.file.write allowed_paths to ${config.worktrees}/** and both data.file.write and data.file.read blocked_paths to ${CONTROL_PLANE.join(", ")} (see ${templatePath("passport.gallivant.json")})`);
|
|
243
|
-
const p = join(aportDir, "passport.json");
|
|
244
|
-
const pass = readJson(p, null);
|
|
245
|
-
const fix = [
|
|
246
|
-
"one of:",
|
|
247
|
-
` hosted: APORT_CLAUDE_CODE_CONFIG_DIR=${contract(dirname(settingsPath))} npx @aporthq/aport-agent-guardrails claude-code --quick-hosted --email <you>`,
|
|
248
|
-
` existing: APORT_CLAUDE_CODE_CONFIG_DIR=${contract(dirname(settingsPath))} npx @aporthq/aport-agent-guardrails claude-code <agent_id>`,
|
|
249
|
-
` local: cp ${templatePath("passport.gallivant.json")} ${contract(p)} and replace {{worktrees}} with ${config.worktrees} and {{home}} with your home directory`,
|
|
250
|
-
].join("\n");
|
|
251
|
-
if (!pass) return fail(`${contract(p)} missing and no hosted passport configured`, fix);
|
|
252
|
-
const problems = passportGaps(pass, config.worktrees);
|
|
253
|
-
if (problems.length) return fail(problems.join("; "), fix);
|
|
254
|
-
return ok(`local passport ${contract(p)} (${enforcement})`);
|
|
255
|
-
});
|
|
256
|
-
await check("global claude settings", () => {
|
|
257
|
-
const g = readJson(expand("~/.claude/settings.json"), {}) || {};
|
|
258
|
-
return ok(g.permissions?.blockReadsOutsideWorkingDirectories ? "blockReadsOutsideWorkingDirectories on; runs deny out-of-tree reads instead of prompting" : "no read block set");
|
|
259
|
-
});
|
|
260
|
-
}
|
|
261
|
-
if (config.harness.codex) {
|
|
262
|
-
await check("codex hook", () => {
|
|
263
|
-
const install = "npx @aporthq/aport-agent-guardrails codex --global --enforcement=warn # then open codex, run /hooks, trust it";
|
|
264
|
-
const hooks = readJson(expand("~/.codex/hooks.json"), null);
|
|
265
|
-
const groups = hooks?.hooks?.PreToolUse;
|
|
266
|
-
const wired = Array.isArray(groups) && groups.some(g => (g.hooks || []).some(h => /aport-codex-hook\.sh/.test(String(h.command || ""))));
|
|
267
|
-
if (!wired) return fail(hooks ? "~/.codex/hooks.json has no APort PreToolUse hook entry (only a description)" : "~/.codex/hooks.json missing", install);
|
|
268
|
-
const { mode: m0, enforcement } = guardrailMode(expand("~/.aport/codex/aport"));
|
|
269
|
-
const mode = m0 || "local";
|
|
270
|
-
return enforcement === "warn" ? ok(`global, ${mode}, warn`) : ok(`global, ${mode}, ${enforcement} (warn is recommended until APort handles multi-file apply_patch)`);
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
await check("worktrees root", () => {
|
|
274
|
-
if (isInside(config.worktrees, home)) return fail("under the Gallivant home", "move worktrees outside ~/.gallivant");
|
|
275
|
-
if (!exists(config.worktrees)) return fail(`${config.worktrees} missing`, `mkdir -p ${config.worktrees}`);
|
|
276
|
-
return ok(contract(config.worktrees));
|
|
277
|
-
});
|
|
278
|
-
// The base branch each configured repository targets, and where that answer came from. It is a check rather than a
|
|
279
|
-
// log line because the failure it catches is silent: a repository whose pull requests all go to `staging` while
|
|
280
|
-
// nothing is configured reads as perfectly healthy, and the first sign is a PR opened against `main`. So the source
|
|
281
|
-
// is named on every line. Nothing here is derived from the repository's history or its CONTRIBUTING file, and that is
|
|
282
|
-
// the reason this check exists in this shape: a derived base could be wrong, and a wrong base nobody typed is the
|
|
283
|
-
// hardest kind to find. An explicit one has to be typed once and then it is visible here forever.
|
|
284
|
-
for (const { repo } of repoEntries(config)) {
|
|
285
|
-
await check(`base branch ${repo}`, async () => {
|
|
286
|
-
if (credentialError) return fail("base branch unavailable: selected GitHub credential could not be resolved", runCredential.why);
|
|
287
|
-
const def = await gh.repoDefaultBranch(repo).catch(() => null);
|
|
288
|
-
const { base, source } = resolveBase({ config, repo, defaultBranch: def });
|
|
289
|
-
if (!base) return fail(`no base branch for ${repo}`, `GitHub reported no default branch and none is configured. Add { "repo": "${repo}", "base": "<branch>" } to repos in ${contract(configPath(home))}`);
|
|
290
|
-
if (source === "default") return ok(`${base} (${repo}'s default branch; nothing configured). Pull requests will target it. To target another branch, set { "repo": "${repo}", "base": "<branch>" } in repos, or run start with --base <branch> once`);
|
|
291
|
-
// A configured base that is not on the remote is the whole reason to check rather than only print. `start` refuses
|
|
292
|
-
// it, but an operator who set it last month wants to hear about it now, not the next time they start something.
|
|
293
|
-
if (!await gh.branchExists(repo, base)) return fail(`repos configures base ${base} for ${repo}, and GitHub does not have that branch`, `fix the base for ${repo} in repos in ${contract(configPath(home))}, or create ${base} on ${repo}`);
|
|
294
|
-
return ok(`${base} (configured in repos${def && def !== base ? `; ${repo}'s default branch is ${def}` : ""})`);
|
|
295
|
-
});
|
|
296
|
-
}
|
|
297
|
-
// `repoNames` and not `config.repos`, because an entry may be `{ repo, base }` rather than a bare string. Reading the
|
|
298
|
-
// raw list named the check "ruleset [object Object]" and handed that object to `gh.rulesets`, which then answered
|
|
299
|
-
// for no repository at all and reported "no active ruleset" for a repo whose ruleset was never asked about. Every
|
|
300
|
-
// other loop over repositories here already goes through `repoEntries`; this one was the exception.
|
|
301
|
-
for (const repo of repoNames(config)) {
|
|
302
|
-
await check(`ruleset ${repo}`, async () => {
|
|
303
|
-
if (credentialError) return fail("ruleset unavailable: selected GitHub credential could not be resolved", runCredential.why);
|
|
304
|
-
const rs = await gh.rulesets(repo).catch(() => []);
|
|
305
|
-
const create = `gh api -X POST repos/${repo}/rulesets --input - <<'EOF'\n${JSON.stringify(rulesetBody(), null, 2)}\nEOF`;
|
|
306
|
-
// An active ruleset somewhere in the repo says nothing about the default branch: one on a release branch
|
|
307
|
-
// would pass while main sat open. The guarantee in section 6.11 is specifically that the default branch
|
|
308
|
-
// refuses a direct push and a force push, so the check has to name the branch and the rules.
|
|
309
|
-
const verdict = await protectsDefaultBranch(gh, repo, rs);
|
|
310
|
-
return verdict.ok ? ok(verdict.detail) : fail(verdict.detail, verdict.fix ?? create);
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
await check("ntfy", async () => {
|
|
314
|
-
if (!config.notify) return ok("notify is null; transitions are logged only");
|
|
315
|
-
if (/[<>\s]/.test(config.notify.ntfy)) return fail("notify.ntfy still has the placeholder", "set notify.ntfy to https://ntfy.sh/<topic> with a topic of 32 random characters, e.g. from: openssl rand -hex 16");
|
|
316
|
-
try { new URL(config.notify.ntfy); } catch { return fail("notify.ntfy is not a URL", "set notify.ntfy to https://ntfy.sh/<topic>"); }
|
|
317
|
-
if (!send) return ok(`${config.notify.ntfy} (pass --send to test)`);
|
|
318
|
-
const sent = await makeNotifier({ config, log: silentLog }).test();
|
|
319
|
-
return sent ? ok("test message sent") : fail("send failed", `curl -d test -H 'Title: gallivant' ${config.notify.ntfy}`);
|
|
320
|
-
});
|
|
321
|
-
if (probe && config.harness.claude) await check("probe run", () => probeRun(config));
|
|
11
|
+
export async function doctor(options = {}) {
|
|
12
|
+
const home = options.home ?? gallivantHome(), raw = readJson(configPath(home), {}), registry = options.registry ?? makeHostRegistry({ config: raw });
|
|
13
|
+
const repos = repoNames(raw);
|
|
14
|
+
if (!repos.length) {
|
|
15
|
+
try {
|
|
16
|
+
const origin = execFileSync("git", ["remote", "get-url", "origin"], { cwd: options.dir ?? process.cwd(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
17
|
+
const target = registry.inspectRemote(origin);
|
|
18
|
+
if (target.adapter.features?.transportSetup) repos.push(target.repository.key);
|
|
19
|
+
} catch { /* No checkout; existing install diagnostics supply the config remedy. */ }
|
|
20
|
+
}
|
|
21
|
+
const transport = repos.filter(repo => { try { return registry.inspect(repo).adapter.features?.transportSetup; } catch { return false; } });
|
|
22
|
+
if (!transport.length) return githubDoctor(options);
|
|
23
|
+
const results = await transportDoctor({ ...options, home, repos: transport, registry });
|
|
24
|
+
if (repos.some(repo => !transport.includes(repo))) results.push(...await githubDoctor({ ...options, repoFilter: repo => !transport.includes(repo) }));
|
|
322
25
|
return results;
|
|
323
26
|
}
|
|
324
|
-
|
|
325
|
-
/**
|
|
326
|
-
* Whether some active ruleset actually protects the default branch. Returns { ok, detail, fix }.
|
|
327
|
-
*
|
|
328
|
-
* A ruleset counts only when it targets branches, is active, matches the default branch (by `~DEFAULT_BRANCH`
|
|
329
|
-
* or by its name), blocks force pushes (`non_fast_forward`) and requires a pull request for updates, and has no
|
|
330
|
-
* bypass actors. Anything less is reported as what it is rather than as a pass; `rulesets` needs admin rights, so
|
|
331
|
-
* a repo the token cannot read rulesets for is reported as unknown instead of as protected.
|
|
332
|
-
*/
|
|
333
|
-
export async function protectsDefaultBranch(gh, repo, rulesets) {
|
|
334
|
-
const active = (rulesets || []).filter(r => r.enforcement === "active" && (r.target ?? "branch") === "branch");
|
|
335
|
-
if (!active.length) return { ok: false, detail: (rulesets || []).length ? "rulesets exist but none is active" : "no active ruleset" };
|
|
336
|
-
let branch = null;
|
|
337
|
-
try { branch = (await gh.repoDefaultBranch(repo)) ?? null; } catch { /* reported below */ }
|
|
338
|
-
const matches = r => {
|
|
339
|
-
const inc = r.conditions?.ref_name?.include ?? [];
|
|
340
|
-
const exc = r.conditions?.ref_name?.exclude ?? [];
|
|
341
|
-
if (branch && (exc.includes("~DEFAULT_BRANCH") || exc.includes(`refs/heads/${branch}`))) return false;
|
|
342
|
-
return inc.includes("~DEFAULT_BRANCH") || inc.includes("~ALL") || (!!branch && inc.includes(`refs/heads/${branch}`));
|
|
343
|
-
};
|
|
344
|
-
const onDefault = active.filter(matches);
|
|
345
|
-
const named = branch ? `the default branch (${branch})` : "the default branch";
|
|
346
|
-
if (!onDefault.length) return { ok: false, detail: `${active.length} active ruleset(s), none targeting ${named}` };
|
|
347
|
-
// The rules may be spread across several rulesets; GitHub applies them all, so check the union.
|
|
348
|
-
const types = new Set(onDefault.flatMap(r => (r.rules || []).map(x => x.type)));
|
|
349
|
-
const bypass = onDefault.flatMap(r => r.bypass_actors || []);
|
|
350
|
-
const missing = ["non_fast_forward", "pull_request"].filter(t => !types.has(t));
|
|
351
|
-
if (missing.length) return { ok: false, detail: `ruleset on ${named} is missing: ${missing.join(", ")}` };
|
|
352
|
-
if (bypass.length) return { ok: false, detail: `ruleset on ${named} has ${bypass.length} bypass actor(s); a run using that identity is not held by it`, fix: `gh api repos/${repo}/rulesets and remove bypass_actors` };
|
|
353
|
-
return { ok: true, detail: `${named}: force pushes blocked, updates need a pull request, no bypass actors` };
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
/** A ruleset that blocks direct updates and force pushes on the default branch, with no bypass actors. */
|
|
357
|
-
export const rulesetBody = () => ({
|
|
358
|
-
name: "Protect default branch", target: "branch", enforcement: "active",
|
|
359
|
-
conditions: { ref_name: { include: ["~DEFAULT_BRANCH"], exclude: [] } },
|
|
360
|
-
rules: [{ type: "deletion" }, { type: "non_fast_forward" }, { type: "pull_request", parameters: { required_approving_review_count: 0, dismiss_stale_reviews_on_push: false, require_code_owner_review: false, require_last_push_approval: false, required_review_thread_resolution: false } }],
|
|
361
|
-
bypass_actors: [],
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
/**
|
|
365
|
-
* Whether a run could actually talk to GitHub over git, asked by running git rather than by reading a config file.
|
|
366
|
-
*
|
|
367
|
-
* This exists because two sessions in a row diagnosed the wrong file. The real failure was
|
|
368
|
-
*
|
|
369
|
-
* fatal: unable to access '/Users/<you>/.gallivant/gitconfig': Operation not permitted
|
|
370
|
-
*
|
|
371
|
-
* and git's fallback message for a missing credential names `~/.git-credentials`, so the reported symptom and the
|
|
372
|
-
* actual cause named different files. A check that reads a config file and pronounces on its content cannot see that
|
|
373
|
-
* class of failure at all: the content was correct and unreadable. So this one reproduces the run's condition and
|
|
374
|
-
* prints whatever git says, verbatim.
|
|
375
|
-
*
|
|
376
|
-
* Two things make it the run's condition rather than the operator's shell:
|
|
377
|
-
*
|
|
378
|
-
* - The environment is `runEnv`'s, and only `runEnv`'s, with nothing inherited but `PATH`. A check that spread
|
|
379
|
-
* `process.env` would pass on the strength of variables a detached child never gets.
|
|
380
|
-
* - `HOME` is pointed at a path that does not exist. A sandboxed run cannot read outside its worktree, so anything
|
|
381
|
-
* git would look for under `$HOME` is denied to it; an unreadable HOME is the cheapest faithful stand-in, and it is
|
|
382
|
-
* what makes this check fail when a path outside the worktree creeps back into `runEnv`.
|
|
383
|
-
*
|
|
384
|
-
* `GIT_TERMINAL_PROMPT=0` so a missing credential is an error rather than a hang. `ls-remote` and not `push
|
|
385
|
-
* --dry-run`: it is read-only, needs no local checkout of the repo, and authenticates the same way, so it answers
|
|
386
|
-
* "would the credential be accepted for this remote" without writing anything anywhere.
|
|
387
|
-
*
|
|
388
|
-
* Returns doctor's `{ ok, detail, fix }`. Exported so a test can call it against a remote that cannot work.
|
|
389
|
-
*/
|
|
390
|
-
export async function gitPushProbe({ config, credential, repoEntries: entries = [], exec = execFileSync } = {}) {
|
|
391
|
-
const repo = entries[0]?.repo;
|
|
392
|
-
if (!repo) return ok("no repos configured, so there is no remote to try");
|
|
393
|
-
const { mkdtempSync, rmSync } = await import("node:fs");
|
|
394
|
-
const { tmpdir } = await import("node:os");
|
|
395
|
-
const dir = mkdtempSync(join(tmpdir(), "gallivant-git-probe-"));
|
|
396
|
-
const env = { PATH: process.env.PATH, HOME: join(dir, "no-such-home"), GIT_TERMINAL_PROMPT: "0", ...runEnv(config, credential) };
|
|
397
|
-
try {
|
|
398
|
-
exec("git", ["init", "--quiet", dir], { cwd: dir, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 60_000 });
|
|
399
|
-
exec("git", ["ls-remote", "--heads", `https://github.com/${repo}.git`], { cwd: dir, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 120_000 });
|
|
400
|
-
} catch (e) {
|
|
401
|
-
// git's own words, not a paraphrase. The whole reason this check exists is that the paraphrase named the wrong
|
|
402
|
-
// file. Every line is kept: the first names what failed and a later one often names the path it failed on.
|
|
403
|
-
const said = scrubbed(String(e.stderr || e.stdout || e.message), credential?.token).trim() || "git failed and said nothing";
|
|
404
|
-
// `unable to access '<path>'` is the shape the real defect took, and the path it names is the finding. Taken from
|
|
405
|
-
// the message rather than guessed at from a list of known filenames: any path outside the worktree is the same
|
|
406
|
-
// defect, and a list would only ever name the ones already found.
|
|
407
|
-
const outside = said.match(/unable to (?:access|read config file) '([^']+)'/)?.[1];
|
|
408
|
-
return fail(
|
|
409
|
-
`a run could not reach ${repo} over git. git said:\n ${said.split("\n").join("\n ")}`,
|
|
410
|
-
outside
|
|
411
|
-
? `git is being told to read ${outside}, which is outside the worktree and which a sandboxed run cannot read. Nothing should name a file outside the worktree; check runEnv in lib/git.mjs`
|
|
412
|
-
: "the run credential is not accepted for this remote over git. Check the `run credential` line above, and that the token has Contents access on this repository",
|
|
413
|
-
);
|
|
414
|
-
} finally {
|
|
415
|
-
try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
416
|
-
}
|
|
417
|
-
return ok(`${repo} reachable with the run's own environment and no readable HOME`);
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
/** Whatever git said, with the credential taken out of it. Nothing that reports on a run may print the token. */
|
|
421
|
-
const scrubbed = (text, token) => (token ? text.split(token).join("<token>") : text);
|
|
422
|
-
|
|
423
|
-
/** A real Claude run in a temp dir: a write inside must succeed, a write outside must be denied. */
|
|
424
|
-
async function probeRun(config) {
|
|
425
|
-
const { mkdtempSync, existsSync, rmSync } = await import("node:fs");
|
|
426
|
-
const { tmpdir } = await import("node:os");
|
|
427
|
-
const { spawnSync } = await import("node:child_process");
|
|
428
|
-
const dir = mkdtempSync(join(tmpdir(), "gallivant-probe-"));
|
|
429
|
-
const outside = join(tmpdir(), `gallivant-probe-outside-${process.pid}`);
|
|
430
|
-
try { rmSync(outside, { force: true }); } catch { /* fresh */ }
|
|
431
|
-
const prompt = `Use the Write tool to create ./ok.txt containing "ok". Then use the Write tool to create ${outside} containing "no". Report what happened in one line.`;
|
|
432
|
-
const spec = buildRun({ harness: "claude", config, mode: "start", session: crypto.randomUUID(), prompt, cwd: dir });
|
|
433
|
-
const startedAt = Date.now();
|
|
434
|
-
const r = spawnSync(spec.cmd, spec.args, { cwd: dir, env: { ...process.env, ...spec.env }, encoding: "utf8", timeout: 180_000 });
|
|
435
|
-
const inside = existsSync(join(dir, "ok.txt")), escaped = existsSync(outside);
|
|
436
|
-
const denials = (r.stdout.match(/"permission_denials":\s*\[[^\]]*\]/) || [])[0] || "";
|
|
437
|
-
try { rmSync(dir, { recursive: true, force: true }); rmSync(outside, { force: true }); } catch { /* best effort */ }
|
|
438
|
-
if (r.status !== 0) return fail(`exit ${r.status}: ${(r.stderr || r.stdout).trim().split("\n").at(-1)}`, "run the command from cmd.json of a run folder by hand");
|
|
439
|
-
if (!inside) return fail("./ok.txt was not written", "the sandbox or the passport blocks writes inside the worktree; check allowed_paths");
|
|
440
|
-
if (escaped) {
|
|
441
|
-
// Tell apart "the hook never ran" from "the hook ran and the passport allowed it": the audit log knows.
|
|
442
|
-
const aportDir = aportDirFor(config.harness.claude.settings);
|
|
443
|
-
const hookRan = auditEntries(aportDir, { fromMs: startedAt, filter: /tool=write/ }).length > 0;
|
|
444
|
-
const { agentId } = guardrailMode(aportDir);
|
|
445
|
-
if (!hookRan) return fail(`${outside} was written and the APort audit log has no write decision for this run`, "the hook did not run: check hooks.PreToolUse in the settings file names the runtime hook script and that jq is installed");
|
|
446
|
-
if (agentId) return fail(`${outside} was written; the hook ran and hosted passport ${agentId} allowed it`, `in the aport.io dashboard set data.file.write allowed_paths on ${agentId} to ${config.worktrees}/** and both write and read blocked_paths to the control-plane directories (see ${templatePath("passport.gallivant.json")}), or switch this install to a local passport`);
|
|
447
|
-
return fail(`${outside} was written; the hook ran and the local passport allowed it`, `set data.file.write allowed_paths in the passport to ${config.worktrees}/**`);
|
|
448
|
-
}
|
|
449
|
-
return ok(`inside ok, outside denied${denials ? ` (${denials.slice(0, 120)})` : ""}`);
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
export function renderDoctor(results) {
|
|
453
|
-
const lines = results.map(r => `${r.ok ? "ok " : "FAIL"} ${r.name.padEnd(24)} ${r.detail || ""}${r.ok ? "" : `\n fix: ${String(r.fix).replace(/\n/g, "\n ")}`}`);
|
|
454
|
-
const failed = results.filter(r => !r.ok).length;
|
|
455
|
-
lines.push("", failed ? `${failed} check(s) failed.` : "All checks passed.");
|
|
456
|
-
return { text: lines.join("\n"), exitCode: failed ? 1 : 0 };
|
|
457
|
-
}
|