Skip to content
Closed
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
2 changes: 1 addition & 1 deletion apps/web/e2e/fixture-console.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ function installation() {
return {
// As Core: api_base_url is always public_url followed by /v1; local_only marks a loopback public_url.
object: "core.installation", installation_id: INSTALLATION_ID, public_url: publicUrl(), api_base_url: `${publicUrl()}/v1`,
local_only: local, source_commit: release.source_commit,
local_only: local, insecure_public_url: false, source_commit: release.source_commit,
configuration: {
path: "/opt/oac/config.json", apply_command: "sudo oac apply", applied_at: "2026-09-24T09:30:00Z",
settings: [
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/InstallationNotice.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
import { InstallationNotice } from "./InstallationNotice";

const installation: CoreInstallation = {
object: "core.installation", installation_id: null, public_url: "http://127.0.0.1:8091", api_base_url: "http://127.0.0.1:8091/v1",
object: "core.installation", installation_id: null, public_url: "http://127.0.0.1:8091", api_base_url: "http://127.0.0.1:8091/v1", insecure_public_url: false,
source_commit: null, local_only: true, configuration: null,
address_bindings: { nodes: 0, nodes_on_other_address: 0, hosted_sandboxes: 0, self_hosted_executors: 0 },
};
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/features/sandbox/NodeEnrollment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open,
// Nodes download from, and reach Core at, the public URL; the browser's address may be a tunnel or loopback.
// The deployment's core_url is the same address, but the installation is read again on each opening, so a fix shows at once.
const publicUrl = installation.data ? nodeSourceUrl(installation.data) : null;
// Only a plain-HTTP origin needs the installer's opt-in, and nodeSourceUrl returns one only for
// an installation that accepted it.
const insecureSourceUrl = Boolean(publicUrl?.startsWith("http://"));
const available = consoleConfig.node_installer;
const provider = deployment.provider === "docker" || deployment.provider === "microsandbox" ? deployment.provider : null;
const backend = provider === "microsandbox" ? "microsandbox" : "Docker";
Expand Down Expand Up @@ -136,7 +139,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open,
// Expired only once a read begun after the expiry found no node for the command.
const expired = lapsed && fresh && checked !== null && checked.startedAt >= expiresAt && checked.nodes === nodes;
const command = enrollment && provider && available && publicUrl && (registered || !expired) && !ready
? nodeInstallCommand({ token: enrollment.token, coreUrl: publicUrl, sourceUrl: publicUrl, provider, installationId: deployment.installation_id, scriptDigest: consoleConfig.node_installer_sha256 }) : "";
? nodeInstallCommand({ token: enrollment.token, coreUrl: publicUrl, sourceUrl: publicUrl, provider, installationId: deployment.installation_id, scriptDigest: consoleConfig.node_installer_sha256, allowInsecureCoreUrl: insecureSourceUrl }) : "";
const nodeId = node?.id ?? null;
const polling = open && enrollment !== null && !ready && (registered || !expired);
const check = useCallback(async () => {
Expand Down
16 changes: 12 additions & 4 deletions apps/web/src/features/sandbox/core-origin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,17 @@ describe("HTTPS origin", () => {

describe("node command source", () => {
it("is the installation's public URL, never a loopback, missing or plain HTTP one", () => {
expect(nodeSourceUrl({ public_url: "https://core.example.com:8443", local_only: false })).toBe("https://core.example.com:8443");
expect(nodeSourceUrl({ public_url: "https://127.0.0.1:8091", local_only: true })).toBeNull();
expect(nodeSourceUrl({ public_url: null, local_only: false })).toBeNull();
expect(nodeSourceUrl({ public_url: "http://core.example.com", local_only: false })).toBeNull();
expect(nodeSourceUrl({ public_url: "https://core.example.com:8443", local_only: false, insecure_public_url: false })).toBe("https://core.example.com:8443");
expect(nodeSourceUrl({ public_url: "https://127.0.0.1:8091", local_only: true, insecure_public_url: false })).toBeNull();
expect(nodeSourceUrl({ public_url: null, local_only: false, insecure_public_url: false })).toBeNull();
expect(nodeSourceUrl({ public_url: "http://core.example.com", local_only: false, insecure_public_url: false })).toBeNull();
});
it("keeps a plain HTTP one only for an installation that opted into a trusted network", () => {
expect(nodeSourceUrl({ public_url: "http://core.internal:8091", local_only: false, insecure_public_url: true })).toBe("http://core.internal:8091");
expect(nodeSourceUrl({ public_url: " http://core.internal:8091/ ", local_only: false, insecure_public_url: true })).toBe("http://core.internal:8091");
for (const public_url of ["http://user:secret@core.internal", "http://core.internal/v1", "ws://core.internal:8091", "https://core.example#", "", "core.internal:8091"]) {
expect(nodeSourceUrl({ public_url, local_only: false, insecure_public_url: true })).toBeNull();
}
expect(nodeSourceUrl({ public_url: "http://127.0.0.1:8091", local_only: true, insecure_public_url: true })).toBeNull();
});
});
13 changes: 9 additions & 4 deletions apps/web/src/features/sandbox/core-origin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,15 @@ export function httpsOrigin(value: string): string | null {
* Where the node commands download the installer, and the `--source-url` they
* pass it: the installation's public URL, whose reverse proxy sends
* `/node-install/*` to this console. Unlike the browser's address, it is the
* same from every machine. Null when other machines can't use it: loopback
* (`local_only`), missing, or not an HTTPS origin.
* same from every machine, and the node, its installer and this console accept
* it only as HTTPS — or as plain HTTP on a trusted network, when the
* installation opted in (`insecure_public_url`). Null when other machines
* can't use it: loopback (`local_only`), missing, or neither of the two.
*/
export function nodeSourceUrl(installation: Pick<CoreInstallation, "public_url" | "local_only">): string | null {
export function nodeSourceUrl(installation: Pick<CoreInstallation, "public_url" | "local_only" | "insecure_public_url">): string | null {
if (installation.local_only || !installation.public_url) return null;
return httpsOrigin(installation.public_url);
const insecure = installation.insecure_public_url;
const candidate = installation.public_url.trim().replace(/\/$/, "");
const pattern = insecure ? /^https?:\/\/[^/?#\\\s@]+$/i : /^https:\/\/[^/?#\\\s@]+$/i;
return pattern.test(candidate) && isValidDirectCoreBaseUrl(candidate, insecure) ? candidate : null;
}
6 changes: 6 additions & 0 deletions apps/web/src/features/sandbox/enrollment-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ $s \${s:+--preserve-env=http_proxy,https_proxy,no_proxy,HTTP_PROXY,HTTPS_PROXY,N
expect(nodeUninstallCommand({ sourceUrl: "https://console.example", installationId: "7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f", scriptDigest: digest, force: true }).split("\n").at(-1))
.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("passes the installation's plain-HTTP opt-in only when it asked for it", () => {
const args = { token: "secret'onetime", coreUrl: "http://core.internal:8091", sourceUrl: "http://core.internal:8091", provider: "docker" as const, installationId: "7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f", scriptDigest: digest };
expect(nodeInstallCommand(args).split("\n").at(-1)).toMatch(/--installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f'\)$/);
expect(nodeInstallCommand({ ...args, allowInsecureCoreUrl: true }).split("\n").at(-1)).toMatch(/--installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f' --allow-insecure-core-url\)$/);
expect(nodeInstallCommand({ ...args, allowInsecureCoreUrl: false })).toBe(nodeInstallCommand(args));
});
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'");
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/features/sandbox/enrollment-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,13 @@ const runInstaller = `$s \${s:+--preserve-env=http_proxy,https_proxy,no_proxy,HT
* standard input (`printf` is a shell builtin), never in an argument, the
* environment or sudo's command line.
*/
export function nodeInstallCommand({ token, coreUrl, sourceUrl, provider, installationId, scriptDigest }: {
export function nodeInstallCommand({ token, coreUrl, sourceUrl, provider, installationId, scriptDigest, allowInsecureCoreUrl = false }: {
token: string; coreUrl: string; sourceUrl: string; provider: "docker" | "microsandbox"; installationId: string; scriptDigest: string;
/** The installation's opt-in, so the installer and the node accept a plain-HTTP origin on a trusted network. */
allowInsecureCoreUrl?: boolean;
}): string {
return `${nodeInstaller(sourceUrl, scriptDigest)}printf '%s\\n' ${quote(token)} | ${runInstaller} --enrollment-token-stdin --source-url ${quote(sourceUrl)} --core-url ${quote(coreUrl)} --provider ${quote(provider)} --installation-id ${quote(installationId)})`;
const insecure = allowInsecureCoreUrl ? " --allow-insecure-core-url" : "";
return `${nodeInstaller(sourceUrl, scriptDigest)}printf '%s\\n' ${quote(token)} | ${runInstaller} --enrollment-token-stdin --source-url ${quote(sourceUrl)} --core-url ${quote(coreUrl)} --provider ${quote(provider)} --installation-id ${quote(installationId)}${insecure})`;
}

/**
Expand Down
9 changes: 7 additions & 2 deletions apps/web/src/lib/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,17 @@ function hasExplicitUserInfo(candidate: string): boolean {
return authority.includes("@");
}

export function isValidDirectCoreBaseUrl(value: string): boolean {
/**
* Whether the value is a Core origin a direct caller may use. Plain HTTP qualifies only for a
* loopback host, or when the caller says the installation opted into a trusted network; that
* decision stays with the caller.
*/
export function isValidDirectCoreBaseUrl(value: string, allowInsecureHTTP = false): boolean {
try {
const candidate = value.trim();
if (candidate.includes("?") || candidate.includes("#") || hasExplicitUserInfo(candidate)) return false;
const url = new URL(candidate);
const secureTransport = url.protocol === "https:" || (url.protocol === "http:" && isLoopbackHostname(url.hostname));
const secureTransport = url.protocol === "https:" || (url.protocol === "http:" && (allowInsecureHTTP || isLoopbackHostname(url.hostname)));
return secureTransport && !url.username && !url.password && !url.search && !url.hash;
} catch {
return false;
Expand Down
1 change: 1 addition & 0 deletions contracts/agents-api/admin-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ Core writes this record in the same transaction that creates the Session. Later
| `public_url` | The [`public_url`](../../docs/configuration.md#settings) setting: the origin applications, nodes, sandboxes and self-hosted executors use. Null when unset |
| `api_base_url` | `public_url` followed by `/v1`, the `OPENAI_BASE_URL` for Project API keys. Null when `public_url` is null |
| `local_only` | True when `public_url` names a loopback host, which only the Core host reaches |
| `insecure_public_url` | True when `OAC_PUBLIC_URL_INSECURE` accepts a plain-HTTP `public_url` on a host that is not loopback, so nodes may enroll over it |
| `source_commit` | The full source commit Core was built from; null for development builds |
| `configuration` | The installer's snapshot of `config.json`; null when the installer did not start Core |
| `address_bindings` | What a change of `public_url` affects, counted on each read |
Expand Down
3 changes: 3 additions & 0 deletions contracts/agents-api/core.openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,9 @@ definitions:
description: OAC_INSTALLATION_ID; null when Core runs without the sandbox manager.
type: string
x-nullable: true
insecure_public_url:
description: True when OAC_PUBLIC_URL_INSECURE accepts a plain-HTTP public URL on a host that is not loopback, so nodes may enroll over that origin. False unless the operator opted in.
type: boolean
local_only:
description: True when public_url names a loopback host, reachable only from the Core host.
type: boolean
Expand Down
1 change: 1 addition & 0 deletions contracts/agents-api/zh/admin-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ Core 会在创建 Session 的同一事务中写入此记录。之后的 Agent
| `public_url` | `public_url` 设置([设置](../../../docs/zh/configuration.md#settings)):应用程序、节点、沙箱和自托管执行器使用的源地址。未设置时为 null |
| `api_base_url` | 在 `public_url` 后附加 `/v1`,即 Project API 密钥使用的 `OPENAI_BASE_URL`。当 `public_url` 为 null 时为 null |
| `local_only` | 当 `public_url` 指向回环主机时为 True,该主机只能由 Core 主机访问 |
| `insecure_public_url` | 当 `OAC_PUBLIC_URL_INSECURE` 允许在非回环主机上使用明文 `public_url` 时为 True,节点可经该地址注册 |
| `source_commit` | Core 构建所依据的完整源代码提交;开发构建为 null |
| `configuration` | 安装器对 `config.json` 的快照;安装器未启动 Core 时为 null |
| `address_bindings` | 更改 `public_url` 所影响的内容,每次读取都会重新统计 |
Expand Down
27 changes: 23 additions & 4 deletions deploy/install/distribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,21 @@ def artifact(manifest, name):
return entry


# Plain HTTP reaches another machine only for an installation whose administrator opted into a
# trusted network. The installer records that, and calls allow_plain_http once before any download,
# so every guard here stays closed by default.
_plain_http = False


def allow_plain_http(allowed=True):
global _plain_http
_plain_http = bool(allowed)


def plain_http_allowed():
return _plain_http


def safe_url(value):
try:
parsed = urlsplit(value)
Expand All @@ -108,18 +123,22 @@ def safe_url(value):
pass
if (not parsed.hostname or parsed.username is not None or parsed.password is not None
or parsed.fragment or any(c.isspace() for c in value)
or '\\' in value or parsed.scheme != 'https' and not (parsed.scheme == 'http' and loopback)):
or '\\' in value or parsed.scheme != 'https'
and not (parsed.scheme == 'http' and (loopback or plain_http_allowed()))):
raise ValueError()
except ValueError:
raise ArtifactError('Artifact downloads require HTTPS; loopback HTTP is only for local testing') from None
raise ArtifactError('Artifact downloads require HTTPS; plain HTTP is only for a loopback host '
'or an installation that opted into a trusted network') from None
return value


class ArtifactRedirect(urllib.request.HTTPRedirectHandler):
"""Only artifact bytes may follow HTTPS redirects; metadata stays on Core."""
"""Only artifact bytes may follow redirects, and only to an origin safe_url accepts; metadata
stays on Core."""
def redirect_request(self, request, fp, code, msg, headers, newurl):
safe_url(newurl)
if urlsplit(newurl).scheme != 'https' or request.get_method() not in ('GET', 'HEAD'):
scheme = urlsplit(newurl).scheme
if not (scheme == 'https' or (scheme == 'http' and plain_http_allowed())) or request.get_method() not in ('GET', 'HEAD'):
raise ArtifactError('Artifact redirects require HTTPS')
# Carry resume headers, never credentials or cookies, to a release/CDN host.
forwarded = {name: value for name, value in request.header_items()
Expand Down
3 changes: 3 additions & 0 deletions deploy/install/node_generations.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,9 @@ def prepare(args, installer):
args.provider = args.configuration["provider"]
configurations = retained_configs(root, installer)
base = installer.private_json(root / "provider.json")
# Every generation this node serves keeps the opt-in the installation recorded.
args.allow_insecure_core_url = bool(base.get("insecure_core_url"))
installer.distribution.allow_plain_http(args.allow_insecure_core_url)
runtime = args.configuration["specification"]["runtime"]
value = configurations.get(args.generation)
finalized = target.exists() or base["generation"] == args.generation
Expand Down
Loading