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
19 changes: 19 additions & 0 deletions apps/docs/content/docs/hosted-providers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,25 @@ as an ordinary user is rejected before reading the enrollment token or changing
the host. This requirement applies to Sandbox Provider nodes, not the native
self-hosted daemon installer.

### Download through a proxy

Export standard `http_proxy` / `https_proxy` and `no_proxy` variables before
running the generated command. Uppercase `HTTP_PROXY`, `HTTPS_PROXY` and
`NO_PROXY` work too; lowercase takes precedence when both are set (including an
empty lowercase value). Use an HTTP proxy URL, optionally with URL-encoded
credentials, and comma-separated host exclusions in `no_proxy`. A proxy for
HTTPS downloads must support CONNECT.

The command requests sudo preservation of only these six names and keeps them
when dropping to `oac-node`. If sudo policy denies preservation, ask the host
administrator to permit those names or run from a root shell with the variables
exported there. A manually added outer `sudo` can discard them before the
command starts. Do not put credentials in command arguments or share shell traces.

These settings apply only to installation requests. They are not saved in node
configuration or the systemd unit; the running node still needs its own network
access to Core. Without proxy variables, downloads use the existing direct path.

### The command

The command shows each installation phase as it runs. After Core confirms the
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/content/guide-sources.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"docs/api/public-agent-api.md": "00979732412a971013e8f01b4c74820ff51aafdded6b0f105d25a78af86a6627",
"docs/examples.md": "0e1bdaeff51c9c36779f817be31ea9816b7d8cb2290cf8350d3c4801f436f4f9",
"contracts/agents-api/environments.md": "424dac4bfd63418afc314896dd6a3e4c5c4375dd0a8bb7579920c5dd9743da22",
"docs/getting-started/nodes.md": "c1ad18c445990ea696625011cc3cfa0725779beefd379064d278656801d5578e",
"docs/getting-started/nodes.md": "7c1b7e364ce85b5b5916358cafc618f29042b2125ac45653be665ba07e772fdb",
"docs/getting-started/self-hosted.md": "5ade597e09cbfa2e321f341693b47730b3696fe7bd4d6834eafbe6b279a55e6b",
"docs/self-hosted-native.md": "b3cf736f88792e6925c50b81a4c33eba6b9ee86e195f9ed4e2187db7bed71d88",
"apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2",
Expand Down Expand Up @@ -45,7 +45,7 @@
"content/docs/sessions.mdx": "bb63d799ed90038836d652f3f866d0309822425114c6b2aa36654b8a991947f6",
"content/docs/examples.mdx": "587061e65ab2e841d14980539ba94e2216d4e8135c948a852b9a8bb0359c85d7",
"content/docs/environments-and-files.mdx": "6d8b2a5d5e95e3a5eff47ce93f0239c7cc38d4678bac53c58f5c8df016c23dc5",
"content/docs/hosted-providers.mdx": "85696d88b76f1233db3d8db3266bb0ab4d297d795a44ede13cf34e5169524c68",
"content/docs/hosted-providers.mdx": "9c241090709a9929ab6a34615db1e20a94c1f36649026281836060e81ac40b4c",
"content/docs/self-hosted-execution.mdx": "eeed4c6b3646927ccc3c7ac2d20b2c0f9c8a65e42d734310fe3959e016324e57",
"content/docs/self-hosted-native.mdx": "671451580dfb4f5c134221991f68292a4f992008174d23190b1da3742046571f",
"public/images/source/apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2",
Expand Down
4 changes: 2 additions & 2 deletions apps/web/e2e/nodes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ test("adds a node: host requirements, a root/sudo command, a countdown, the same
await expect(field).toHaveValue(/enroll_fixture_/);
// The token goes on stdin to the checked installer, run with sudo unless the shell is root.
await expect(field).toHaveValue(/^ \(umask 077;.*\|\| s=sudo\n/);
await expect(field).toHaveValue(/\| \$s python3 "\$d\/node-install\.pyz" \$\{NO_COLOR\+--no-color\} --enrollment-token-stdin /);
await expect(field).toHaveValue(/\| \$s \$\{s:\+--preserve-env=http_proxy,https_proxy,no_proxy,HTTP_PROXY,HTTPS_PROXY,NO_PROXY\} python3 "\$d\/node-install\.pyz" \$\{NO_COLOR\+--no-color\} --enrollment-token-stdin /);
// It downloads from, and names as its source, the public URL, not the loopback address this browser uses.
await expect(field).toHaveValue(/curl [^\n]* 'https:\/\/core\.example\.com\/node-install\/node-install\.pyz' /);
await expect(field).toHaveValue(/ --source-url 'https:\/\/core\.example\.com' --core-url 'https:\/\/core\.example\.com' /);
Expand Down Expand Up @@ -155,7 +155,7 @@ test("removes a node after confirmation", async ({ page, request }) => {
await expect(page.getByRole("table", { name: "Sandbox nodes" })).not.toContainText("edge-03");
// Removing the Core record leaves the system service for root/sudo to uninstall on its host.
const cleanup = page.getByRole("dialog", { name: "Clean up the host" });
await expect(cleanup.getByLabel("Uninstall command", { exact: true })).toHaveValue(/\| s=sudo\nprintf '\\n==> Downloading node installer\.\.\.\\n' &&\ncurl [^\n]* 'https:\/\/core\.example\.com\/node-install\/node-install\.pyz' [^]*\n\$s python3 "\$d\/node-install\.pyz" \$\{NO_COLOR\+--no-color\} --uninstall --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f'\)$/);
await expect(cleanup.getByLabel("Uninstall command", { exact: true })).toHaveValue(/\| s=sudo\nexport http_proxy=[^\n]+\nexport HTTP_PROXY=[^\n]+\nprintf '\\n==> Downloading node installer\.\.\.\\n' &&\ncurl [^\n]* 'https:\/\/core\.example\.com\/node-install\/node-install\.pyz' [^]*\n\$s \$\{s:\+--preserve-env=http_proxy,https_proxy,no_proxy,HTTP_PROXY,HTTPS_PROXY,NO_PROXY\} python3 "\$d\/node-install\.pyz" \$\{NO_COLOR\+--no-color\} --uninstall --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f'\)$/);
await expect(cleanup.getByText("Installed without sudo?")).toHaveCount(0);
await expect(cleanup.getByLabel("Uninstall command without sudo", { exact: true })).toHaveCount(0);
// Nothing to force for a node on the current address; closing leaves focus on the page, as the row is gone.
Expand Down
42 changes: 31 additions & 11 deletions apps/web/src/features/sandbox/enrollment-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,33 @@ describe("sandbox connection and enrollment", () => {
const install = () => nodeInstallCommand({ token: "secret'onetime", coreUrl: "https://core.example", sourceUrl: "https://console.example", provider: "docker", installationId: "7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f", scriptDigest: digest });
it("creates the exact sudo command: a leading space, the checked installer run as root, the token only on stdin", () => {
expect(install()).toBe(` (umask 077; d=$(mktemp -d) || exit; trap 'rm -rf "$d"' EXIT; s=; [ "$(id -u)" -eq 0 ] || s=sudo
export http_proxy="\${http_proxy-\${HTTP_PROXY-}}" https_proxy="\${https_proxy-\${HTTPS_PROXY-}}" no_proxy="\${no_proxy-\${NO_PROXY-}}"
export HTTP_PROXY="$http_proxy" HTTPS_PROXY="$https_proxy" NO_PROXY="$no_proxy"
printf '\\n==> Downloading node installer...\\n' &&
curl -fsS --max-time 30 --max-filesize 1048576 'https://console.example/node-install/node-install.pyz' -o "$d/node-install.pyz" &&
curl -fs --max-time 30 --max-filesize 1048576 'https://console.example/node-install/node-install.pyz' -o "$d/node-install.pyz" || { c=$?; printf 'Cannot download node installer; check the console URL, TLS and proxy settings.\\n' >&2; exit "$c"; }
printf '==> Verifying node installer...\\n' &&
printf '%s %s\\n' '${digest}' "$d/node-install.pyz" | sha256sum -c --status &&
printf '%s\\n' 'secret'\\''onetime' | $s python3 "$d/node-install.pyz" \${NO_COLOR+--no-color} --enrollment-token-stdin --source-url 'https://console.example' --core-url 'https://core.example' --provider 'docker' --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f')`);
printf '%s\\n' 'secret'\\''onetime' | $s \${s:+--preserve-env=http_proxy,https_proxy,no_proxy,HTTP_PROXY,HTTPS_PROXY,NO_PROXY} python3 "$d/node-install.pyz" \${NO_COLOR+--no-color} --enrollment-token-stdin --source-url 'https://console.example' --core-url 'https://core.example' --provider 'docker' --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f')`);
});
it("creates the exact uninstall commands, with no token", () => {
const uninstall = () => nodeUninstallCommand({ sourceUrl: "https://console.example", installationId: "7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f", scriptDigest: digest });
expect(uninstall()).toBe(` (umask 077; d=$(mktemp -d) || exit; trap 'rm -rf "$d"' EXIT; s=; [ "$(id -u)" -eq 0 ] || s=sudo
export http_proxy="\${http_proxy-\${HTTP_PROXY-}}" https_proxy="\${https_proxy-\${HTTPS_PROXY-}}" no_proxy="\${no_proxy-\${NO_PROXY-}}"
export HTTP_PROXY="$http_proxy" HTTPS_PROXY="$https_proxy" NO_PROXY="$no_proxy"
printf '\\n==> Downloading node installer...\\n' &&
curl -fsS --max-time 30 --max-filesize 1048576 'https://console.example/node-install/node-install.pyz' -o "$d/node-install.pyz" &&
curl -fs --max-time 30 --max-filesize 1048576 'https://console.example/node-install/node-install.pyz' -o "$d/node-install.pyz" || { c=$?; printf 'Cannot download node installer; check the console URL, TLS and proxy settings.\\n' >&2; exit "$c"; }
printf '==> Verifying node installer...\\n' &&
printf '%s %s\\n' '${digest}' "$d/node-install.pyz" | sha256sum -c --status &&
$s python3 "$d/node-install.pyz" \${NO_COLOR+--no-color} --uninstall --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f')`);
$s \${s:+--preserve-env=http_proxy,https_proxy,no_proxy,HTTP_PROXY,HTTPS_PROXY,NO_PROXY} python3 "$d/node-install.pyz" \${NO_COLOR+--no-color} --uninstall --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f')`);
expect(nodeUninstallCommand({ sourceUrl: "https://console.example", installationId: "7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f", scriptDigest: digest, force: true }).split("\n").at(-1))
.toBe(`$s python3 "$d/node-install.pyz" \${NO_COLOR+--no-color} --uninstall --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f' --force)`);
.toBe(`$s \${s:+--preserve-env=http_proxy,https_proxy,no_proxy,HTTP_PROXY,HTTPS_PROXY,NO_PROXY} python3 "$d/node-install.pyz" \${NO_COLOR+--no-color} --uninstall --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f' --force)`);
});
it("points at the system node journal", () => {
expect(nodeLogCommand("7f3c2a90-fixture")).toBe("sudo journalctl -u oac-node-7f3c2a90-fixture.service");
expect(nodeLogCommand("a b")).toBe("sudo journalctl -u 'oac-node-a b.service'");
});
// "as root": a root shell runs the sudo command without sudo.
it.each(["success", "download failure", "checksum mismatch", "installer failure", "as root", "no color"])("executes safely, passes the token only on stdin and cleans private downloads after %s", (scenario) => {
it.each(["success", "download failure", "checksum mismatch", "installer failure", "as root", "no color", "uppercase proxy", "lowercase proxy", "empty proxy", "root proxy"])("executes safely, passes the token only on stdin and cleans private downloads after %s", (scenario) => {
const parent = join(homedir(), ".oac", "tests");
mkdirSync(parent, { recursive: true });
const root = mkdtempSync(join(parent, "node-command-"));
Expand All @@ -57,13 +61,15 @@ const line=fs.readFileSync(0,'utf8').trimEnd(), split=line.indexOf(' ');
if(split!==64) process.exit(2);
const actual=crypto.createHash('sha256').update(fs.readFileSync(line.slice(split+2))).digest('hex');
process.exit(actual===line.slice(0,split)?0:1);`);
executable("id", `console.log(process.env.SCENARIO==='as root'?'0':'1000');`);
executable("id", `console.log((process.env.SCENARIO==='as root'||process.env.SCENARIO==='root proxy')?'0':'1000');`);
// The shell's builtin printf feeds the token; an external one would put it in an argv.
executable("printf", `require('node:fs').appendFileSync(process.env.PRINTF_REPORT,'called\\n');`);
executable("sudo", `const fs=require('node:fs'), {spawnSync}=require('node:child_process');
fs.writeFileSync(process.env.SUDO_REPORT,JSON.stringify({args:process.argv.slice(2),env:process.env}));
const args=process.argv.slice(2), keep=args.shift().split('=')[1].split(',');
const env={...process.env}; delete env.NO_COLOR;
process.exit(spawnSync(process.argv[2],process.argv.slice(3),{stdio:'inherit',env}).status ?? 1);`);
for(const key of Object.keys(env)) if(/_proxy$/i.test(key)&&!keep.includes(key)) delete env[key];
process.exit(spawnSync(args[0],args.slice(1),{stdio:'inherit',env}).status ?? 1);`);
executable("python3", `const fs=require('node:fs');
const stdin=fs.readFileSync(0,'utf8');
fs.writeFileSync(process.env.REPORT,JSON.stringify({args:process.argv.slice(2),env:process.env,stdin,mode:fs.statSync(process.argv[2]).mode&511}));
Expand All @@ -72,11 +78,17 @@ process.exit(process.env.SCENARIO==='installer failure'?7:0);`);
const token = "fixture'one-time";
try {
const command = nodeInstallCommand({ token, coreUrl: "http://127.0.0.1:8091", sourceUrl: "http://localhost:8080", provider: "docker", installationId: "fixture-installation", scriptDigest: digest });
const result = spawnSync("sh", ["-c", command], { env: { ...process.env, NO_COLOR: scenario === "no color" ? "" : undefined, PATH: `${bin}:${process.env.PATH}`, TMPDIR: temporary, SCENARIO: scenario, FIXTURE: fixture, REPORT: report, SUDO_REPORT: sudoReport, PRINTF_REPORT: printfReport }, encoding: "utf8" });
const proxy = "http://fixture:private%20password@proxy.example:3128";
const proxyEnv: Record<string, string | undefined> = { http_proxy: undefined, https_proxy: undefined, no_proxy: undefined, HTTP_PROXY: undefined, HTTPS_PROXY: undefined, NO_PROXY: undefined, ALL_PROXY: undefined };
if (["uppercase proxy", "root proxy", "empty proxy"].includes(scenario)) Object.assign(proxyEnv, { HTTP_PROXY: proxy, HTTPS_PROXY: proxy, NO_PROXY: "core.example,localhost" });
if (scenario === "lowercase proxy") Object.assign(proxyEnv, { http_proxy: proxy, https_proxy: proxy, no_proxy: "core.example,localhost", HTTP_PROXY: "http://wrong.invalid" });
if (scenario === "empty proxy") proxyEnv.http_proxy = "";
const result = spawnSync("sh", ["-c", command], { env: { ...process.env, ...proxyEnv, NO_COLOR: scenario === "no color" ? "" : undefined, PATH: `${bin}:${process.env.PATH}`, TMPDIR: temporary, SCENARIO: scenario, FIXTURE: fixture, REPORT: report, SUDO_REPORT: sudoReport, PRINTF_REPORT: printfReport }, encoding: "utf8" });
expect(result.status).toBe(scenario === "installer failure" ? 7 : scenario === "download failure" ? 22 : scenario === "checksum mismatch" ? 1 : 0);
expect(result.stdout).toContain("==> Downloading node installer...");
expect(result.stdout.includes("==> Verifying node installer...")).toBe(scenario !== "download failure");
expect(result.stdout + result.stderr).not.toContain(token);
expect(result.stdout + result.stderr).not.toContain("private%20password");
expect(result.stdout + result.stderr).not.toContain("\u001b[");
expect(readdirSync(temporary)).toEqual([]);
expect(readdirSync(root)).not.toContain("printf.log");
Expand All @@ -87,16 +99,24 @@ process.exit(process.env.SCENARIO==='installer failure'?7:0);`);
}
const invocation = JSON.parse(readFileSync(report, "utf8")) as { args: string[]; env: Record<string, string>; stdin: string; mode: number };
// The token arrives on stdin alone: in no argument and no environment variable, the installer's or sudo's.
expect(invocation.env.http_proxy).toBe(["uppercase proxy", "lowercase proxy", "root proxy"].includes(scenario) ? proxy : "");
expect(invocation.args.join(" ")).not.toContain("private%20password");
for (const key of ["http_proxy", "https_proxy", "no_proxy"]) {
const expected = proxyEnv[key] ?? proxyEnv[key.toUpperCase()] ?? "";
expect(invocation.env[key]).toBe(expected);
expect(invocation.env[key.toUpperCase()]).toBe(expected);
}
expect(invocation.stdin).toBe(`${token}\n`);
expect(invocation.args.some((arg) => arg.includes("one-time"))).toBe(false);
expect(Object.values(invocation.env).some((value) => value.includes("one-time"))).toBe(false);
expect(invocation.args.slice(1)).toEqual([...(scenario === "no color" ? ["--no-color"] : []), "--enrollment-token-stdin", "--source-url", "http://localhost:8080", "--core-url", "http://127.0.0.1:8091", "--provider", "docker", "--installation-id", "fixture-installation"]);
expect(invocation.mode & 0o077).toBe(0);
if (scenario === "as root") {
if (scenario === "as root" || scenario === "root proxy") {
expect(readdirSync(root)).not.toContain("sudo.json");
} else {
const sudo = JSON.parse(readFileSync(sudoReport, "utf8")) as { args: string[]; env: Record<string, string> };
expect(sudo.args.slice(0, 1)).toEqual(["python3"]);
expect(sudo.args.slice(0, 2)).toEqual(["--preserve-env=http_proxy,https_proxy,no_proxy,HTTP_PROXY,HTTPS_PROXY,NO_PROXY", "python3"]);
expect(sudo.args.join(" ")).not.toContain("private%20password");
expect(sudo.args.some((arg) => arg.includes("one-time"))).toBe(false);
expect(Object.values(sudo.env).some((value) => value.includes("one-time"))).toBe(false);
}
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/features/sandbox/enrollment-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,17 @@ const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
*/
function nodeInstaller(sourceUrl: string, scriptDigest: string): string {
return ` (umask 077; d=$(mktemp -d) || exit; trap 'rm -rf "$d"' EXIT; s=; [ "$(id -u)" -eq 0 ] || s=sudo
export http_proxy="\${http_proxy-\${HTTP_PROXY-}}" https_proxy="\${https_proxy-\${HTTPS_PROXY-}}" no_proxy="\${no_proxy-\${NO_PROXY-}}"
export HTTP_PROXY="$http_proxy" HTTPS_PROXY="$https_proxy" NO_PROXY="$no_proxy"
printf '\\n==> Downloading node installer...\\n' &&
curl -fsS --max-time 30 --max-filesize 1048576 ${quote(sourceUrl + "/node-install/node-install.pyz")} -o "$d/node-install.pyz" &&
curl -fs --max-time 30 --max-filesize 1048576 ${quote(sourceUrl + "/node-install/node-install.pyz")} -o "$d/node-install.pyz" || { c=$?; printf 'Cannot download node installer; check the console URL, TLS and proxy settings.\\n' >&2; exit "$c"; }
printf '==> Verifying node installer...\\n' &&
printf '%s %s\\n' ${quote(scriptDigest)} "$d/node-install.pyz" | sha256sum -c --status &&
`;
}

/** Runs the downloaded installer, as root in sudo mode. */
const runInstaller = `$s python3 "$d/node-install.pyz" \${NO_COLOR+--no-color}`;
const runInstaller = `$s \${s:+--preserve-env=http_proxy,https_proxy,no_proxy,HTTP_PROXY,HTTPS_PROXY,NO_PROXY} python3 "$d/node-install.pyz" \${NO_COLOR+--no-color}`;

/**
* Adds this host as a node. The one-time token reaches the installer only on
Expand Down
10 changes: 10 additions & 0 deletions deploy/install/node_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -710,9 +710,19 @@ def service_child(account, function, arguments, parent, mask, read_ends, output_
if os.getppid() != parent:
os._exit(1)
os.umask(0o077)
# This child runs installation/removal steps, never the persistent node
# service. Keep only standard HTTP proxy settings across the UID boundary.
# curl/urllib prefer lowercase, while the Go registration command prefers
# uppercase. Give both spellings one value, including an explicit empty value.
proxies = {}
for name in ("http_proxy", "https_proxy", "no_proxy"):
value = os.environ.get(name, os.environ.get(name.upper()))
if value is not None:
proxies[name] = proxies[name.upper()] = value
os.environ.clear()
os.environ.update(HOME=account.pw_dir, USER=account.pw_name, LOGNAME=account.pw_name, PATH=SAFE_PATH,
LANG="C.UTF-8", DOCKER_CONFIG=str(CHILD_DOCKER_CONFIG))
os.environ.update(proxies)
os.chdir(account.pw_dir)
function(*arguments)
code = 0
Expand Down
Loading