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
7 changes: 6 additions & 1 deletion agent-node/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3938,7 +3938,7 @@ async function processWithGrokCli(
debug(`[grok-cli] cwd=${grokCwd}`);

const { execFileSync } = await import("child_process");
const { runGrokCliTurn, assertGrokCliFeatures, assertGrokCliVersion } = await import("./runtime/grok-build-cli");
const { runGrokCliTurn, assertGrokCliFeatures, assertGrokCliVersion, assertUnprivilegedUserNsUsable } = await import("./runtime/grok-build-cli");
const {
prepareGrokCliHome,
assertNoDiscoveredGrokHooks,
Expand All @@ -3954,6 +3954,11 @@ async function processWithGrokCli(
throw new Error("grok-build-cli secure turn supervision currently requires Linux user/PID namespaces");
}
const unshareBinary = process.env.UNSHARE_BINARY || "unshare";
// 上面的 platform 判断是**必要不充分**的:是 Linux 不等于非特权 userns 可用。
// Ubuntu 24.04+ 默认禁写 uid_map,而这个 runtime 每个 turn 都依赖它。
// 在这里挡下来,才能给出「换 grok-build-acp」这种可执行建议;
// 否则失败推迟到第一个 turn,以内核层 errno 出现,把人引去查权限。
assertUnprivilegedUserNsUsable(unshareBinary);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Run the userns preflight before registering the node

For a headless grok-build-cli node on a host that blocks this operation, this call is reached only from processWithGrokCli, after startup has already called register() and begun draining the inbox. The node therefore still advertises itself as healthy and discovers the hard prerequisite only after accepting its first task—the delayed-failure behavior this change is intended to eliminate; run or cache this gate in the headless startup path before registration.

Useful? React with 👍 / 👎.

const flockBinary = process.env.FLOCK_BINARY || "flock";
const setprivBinary = process.env.SETPRIV_BINARY || "setpriv";
const grokTurnLauncher = {
Expand Down
44 changes: 42 additions & 2 deletions agent-node/src/runtime/grok-build-cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { afterEach, describe, expect, it } from "bun:test";
import { afterEach, describe, expect, it, test } from "bun:test";
import { chmodSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { assertGrokCliFeatures, assertGrokCliVersion, buildGrokCliArgs, normalizeGrokCliTools, runGrokCliTurn } from "./grok-build-cli";
import { assertGrokCliFeatures, assertGrokCliVersion, assertUnprivilegedUserNsUsable, buildGrokCliArgs, normalizeGrokCliTools, runGrokCliTurn } from "./grok-build-cli";
import { buildGrokChildEnv } from "./grok-child-env";

const roots: string[] = [];
Expand Down Expand Up @@ -359,3 +359,43 @@ describe("runGrokCliTurn", () => {
expect(Date.now() - started).toBeLessThan(2_000);
});
});

describe("assertUnprivilegedUserNsUsable (#grok userns preflight)", () => {
test("passes when the probe succeeds", () => {
Comment on lines +363 to +364

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Record the new tests' Docker result

These four cases extend the grok-build-cli.test.ts suite selected by Docker test219, but docs/tests/report-test219.txt remains bound to source commit 026937d0... and contains only the previous 21 cases, while this commit records only a direct bun test run. Consequently there is no repository artifact showing that the changed suite and build passed in the required isolated environment; run test219 through Docker and commit its updated report.

AGENTS.md reference: AGENTS.md:L7-L8

Useful? React with 👍 / 👎.

let seen: { bin: string; args: string[] } | null = null;
expect(() => assertUnprivilegedUserNsUsable("unshare", (bin, args) => {
seen = { bin, args };
return { ok: true, stderr: "" };
})).not.toThrow();
// 探针必须是**真实那个操作**,不是读 sysctl 之类的代理值。
expect(seen).toEqual({ bin: "unshare", args: ["--user", "--map-root-user", "/bin/true"] });
});

test("throws with the real stderr and an actionable next step when uid_map is refused", () => {
// 这段 stderr 是 2026-08-13 在 Ubuntu 24.04.3 上实测到的原文。
const real = "unshare: write failed /proc/self/uid_map: Operation not permitted";
let msg = "";
try {
assertUnprivilegedUserNsUsable("unshare", () => ({ ok: false, stderr: real }));
} catch (e: any) { msg = String(e.message); }
expect(msg).toContain("write failed /proc/self/uid_map");
// 必须指出可执行的出路,而不是只报「失败了」。
expect(msg).toContain("grok-build-acp");
// 必须给出自查命令。
expect(msg).toContain("sysctl kernel.apparmor_restrict_unprivileged_userns");
// 🔴 不能把「放宽 sysctl」说成推荐做法 —— 那是削弱全机安全边界的运维决策。
expect(msg).toContain("operator decision");
});

test("still throws when the probe fails with no stderr at all", () => {
// 兜底:拿不到 stderr 也必须 fail-closed,不能因为「没有证据」就放行。
expect(() => assertUnprivilegedUserNsUsable("unshare", () => ({ ok: false, stderr: "" })))
.toThrow(/refuses unprivileged user-namespace uid_map writes/);
});

test("honours a custom unshare binary path", () => {
let bin = "";
assertUnprivilegedUserNsUsable("/opt/util-linux/bin/unshare", (b) => { bin = b; return { ok: true, stderr: "" }; });
expect(bin).toBe("/opt/util-linux/bin/unshare");
});
});
45 changes: 45 additions & 0 deletions agent-node/src/runtime/grok-build-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,51 @@ export function assertGrokCliVersion(version: string): void {
}

/** Translate the node profile's Claude-style names to Grok's internal IDs. */
/** grok-build-cli 每个 turn 都在 `unshare --user --map-root-user` 下跑。
* 调用方已经挡掉了非 Linux,但**「是 Linux」不等于「非特权 userns 可用」**:
* Ubuntu 24.04+ 默认 `kernel.apparmor_restrict_unprivileged_userns=1`,
* 此时写 /proc/self/uid_map 会被拒。
*
* 实测(2026-08-13,Ubuntu 24.04.3):
* unshare --user --map-root-user … /bin/true
* → rc=1 "unshare: write failed /proc/self/uid_map: Operation not permitted"
* unshare --user /bin/true
* → rc=0 ← 命名空间本身能建,被拒的**只是 uid_map 那一步**
*
* 没有这道预检,失败会推迟到第一个 turn,并以内核层的 errno 出现 ——
* 读到的人会去查内核/权限,而不是「这个 runtime 在这台机上用不了」。
*
* 🔴 判据是**真跑一次那个操作**,不是读 sysctl。sysctl 只是一个代理值:
* 发行版、容器、seccomp、LSM 都可能让两者不一致,而真正决定成败的是操作本身。
*
* `run` 可注入,便于测试;默认用 execFileSync。 */
export function assertUnprivilegedUserNsUsable(
unshareBinary: string,
run?: (bin: string, args: string[]) => { ok: boolean; stderr: string },
): void {
const exec = run ?? ((bin: string, args: string[]) => {
try {
require("child_process").execFileSync(bin, args, { stdio: ["ignore", "ignore", "pipe"], timeout: 10_000 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Sanitize the environment passed to the userns probe

When the parent contains config envRef values or ambient cloud credentials, this execFileSync call inherits the complete process.env, exposing those secrets to the executable selected through UNSHARE_BINARY or PATH. The actual turn launcher deliberately receives the allowlisted childEnv instead, so the new preflight bypasses that boundary; pass a projected helper environment to the probe as well.

Useful? React with 👍 / 👎.

return { ok: true, stderr: "" };
} catch (e: any) {
return { ok: false, stderr: String(e?.stderr ?? e?.message ?? e) };
}
});
const probe = exec(unshareBinary, ["--user", "--map-root-user", "/bin/true"]);
if (probe.ok) return;
throw new Error(
"grok-build-cli cannot start: this machine refuses unprivileged user-namespace uid_map writes"
+ (probe.stderr.trim() ? ` (${probe.stderr.trim().split("\n")[0]})` : "")
+ ".\n"
+ " Ubuntu 24.04+ ships kernel.apparmor_restrict_unprivileged_userns=1, which blocks the\n"
+ " `unshare --map-root-user` this runtime uses for every turn.\n"
Comment on lines +139 to +143

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve non-uid_map probe failures

When unshare is missing, too old, times out, or fails for a container/seccomp reason, probe.ok is also false, but this path unconditionally reports a refused uid_map write and recommends inspecting Ubuntu's AppArmor sysctl. That makes the new diagnostic misleading precisely for the non-sysctl cases the comment says the real probe should distinguish; report a general probe failure unless the captured error actually identifies the mapping restriction.

Useful? React with 👍 / 👎.

+ " Check with: sysctl kernel.apparmor_restrict_unprivileged_userns\n"
+ " Preferred fix: use the `grok-build-acp` runtime instead — it does not need user namespaces.\n"
Comment on lines +144 to +145

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Document the headless userns prerequisite and fallback

This new operational gate and its recommendation to switch runtimes are absent from the authoritative docs/grok-build-cli-preview.md: its Requirements section lists only Linux/procfs, its headless quick start gives no userns compatibility check, and Common errors does not explain this failure or how to change and verify the runtime. A rebuild on the affected Ubuntu hosts therefore cannot determine from the repository whether the headless service is viable or follow a reviewed migration/rollback procedure; update the runbook in the same change.

AGENTS.md reference: AGENTS.md:L24-L24

Useful? React with 👍 / 👎.

+ " (Relaxing the sysctl weakens a host-wide security boundary; that is an operator decision,\n"
+ " not something this node should require.)",
);
}

export function normalizeGrokCliTools(tools: readonly string[]): string[] {
const mapped: string[] = [];
const unknown: string[] = [];
Expand Down
Loading