From e67c868009ad18bcaf808de9646e4ef3ff1248f4 Mon Sep 17 00:00:00 2001 From: OAC Core Date: Sat, 3 Oct 2026 12:28:03 +0800 Subject: [PATCH] Node link: allow non-loopback http origin behind allow_insecure_origin Retain the enrollment policy in the node identity (identity.json allow_insecure_origin), validate it through one validateEndpoint rule with explicit endpoint variants, and read it back on every enroll/refresh/connect. The installer accepts --allow-insecure-origin, records the policy in its state and root records, forwards it to `oac-node register`, and matches it on reruns and readiness checks. Default behavior and on-disk bytes are unchanged; TLS certificate verification is never relaxed. Baseline: OAC-2 feat/allow-insecure-origin (e1e29049). Tracks OAC-4. Co-authored-by: multica-agent --- .../agents-api/node-generation-protocol.md | 2 +- .../agents-api/zh/node-generation-protocol.md | 4 +- deploy/install/node_install.py | 54 ++++++++--- deploy/install/test_node_install.py | 54 ++++++++++- deploy/install/test_node_readiness.py | 15 ++- docs/getting-started/nodes.md | 2 + docs/zh/getting-started/nodes.md | 4 +- services/core/cmd/sandbox-node/main.go | 8 +- services/core/cmd/sandbox-node/main_test.go | 10 +- services/core/internal/sandbox/node/agent.go | 4 +- .../internal/sandbox/node/capacity_test.go | 2 +- .../internal/sandbox/node/connection_test.go | 2 +- .../sandbox/node/creation_settlement_test.go | 2 +- .../internal/sandbox/node/docker_live_test.go | 2 +- .../core/internal/sandbox/node/enrollment.go | 6 +- .../node/generation_connection_test.go | 2 +- .../node/health_connection_linux_test.go | 2 +- .../core/internal/sandbox/node/identity.go | 47 +++++++--- .../core/internal/sandbox/node/node_test.go | 94 ++++++++++++++++++- .../sandbox/node/observations_test.go | 2 +- .../internal/sandbox/node/recovery_test.go | 14 +-- .../internal/sandbox/node/timeout_test.go | 2 +- 22 files changed, 277 insertions(+), 57 deletions(-) diff --git a/contracts/agents-api/node-generation-protocol.md b/contracts/agents-api/node-generation-protocol.md index a7b293f41..255593d65 100644 --- a/contracts/agents-api/node-generation-protocol.md +++ b/contracts/agents-api/node-generation-protocol.md @@ -10,7 +10,7 @@ Every frame is one JSON text message whose `version` equals `node.ProtocolVersio ## Connection -1. The node dials `/api/v1/sandbox-node/connect?node_id=` on its stored Core origin (`wss` for `https`) with its node credential as a Bearer header. Core answers 401 to a rejected credential, which the node treats as permanent; any other failure, including a 403 from a proxy, is retried with bounded backoff. Core refuses a second connection for a node identity while one is opening, live or closing, with 409. +1. The node dials `/api/v1/sandbox-node/connect?node_id=` on its stored Core origin (`wss` for `https`, or `ws` for a non-loopback `http` origin admitted by the retained `allow_insecure_origin` policy) with its node credential as a Bearer header. Core answers 401 to a rejected credential, which the node treats as permanent; any other failure, including a 403 from a proxy, is retried with bounded backoff. Core refuses a second connection for a node identity while one is opening, live or closing, with 409. 2. Within 15 seconds the node sends `hello` with its identity (`node_id`, `installation_id`, `provider`, `backend_fingerprint`, the enrolled `deployment_generation` and `specification_digest`, `max_active`, `max_retained`), its first health report and, when it can prepare and retain several deployment generations, `generation_management: true`. Core closes the connection unless the identity matches the authenticated node. 3. Core records the node's presence, then replies `welcome` with a new `connection_id`, the current `owner_epoch` and, for a generation-managing node, `deployment`: the target `generation`, its `specification_digest` and a nullable `serving_generation`. The node stores a higher owner epoch and refuses a lower one. 4. Every 10 seconds the node sends `heartbeat` with `connection_id`, `owner_epoch` and health. On each heartbeat Core authenticates the node credential again and checks the owner epoch, records the health and replies `heartbeat_ack`, with `deployment` for a generation-managing node. Either peer closes the connection after 35 seconds without a frame. diff --git a/contracts/agents-api/zh/node-generation-protocol.md b/contracts/agents-api/zh/node-generation-protocol.md index e6e5de8fc..484fa5e42 100644 --- a/contracts/agents-api/zh/node-generation-protocol.md +++ b/contracts/agents-api/zh/node-generation-protocol.md @@ -1,7 +1,7 @@ --- title: "沙箱节点协议" source: contracts/agents-api/node-generation-protocol.md -source_hash: 1ee43dfcdd0eec0806ea3bc8a4c1227e10bd8ac5e69486505cb113a98e3f548a +source_hash: 9313cff647dada07bcdf8f09f6c84a841a1115b752a6c9145221f4d31ebd6404 --- 沙箱节点在其主机上运行 Docker 或 microsandbox Provider,并通过一个 WebSocket 与 Core 相连。Core 通过该连接发送 Provider 操作;节点针对本地 Provider 执行这些操作,并报告就绪状态、主机测量值及其持有的部署代次。Core 始终是唯一的生命周期所有者:节点绝不重试变更操作或调度工作。帧和校验器位于 [`services/core/internal/sandbox/node`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/services/core/internal/sandbox/node)(`wire.go`、`generation_wire.go`);节点用于注册和读取配置的 HTTP 路由位于[机器连接 API](machine-api.md#node-routes)。 @@ -12,7 +12,7 @@ source_hash: 1ee43dfcdd0eec0806ea3bc8a4c1227e10bd8ac5e69486505cb113a98e3f548a ## 连接 {#connection} -1. 节点在其存储的 Core 源地址上发起对 `/api/v1/sandbox-node/connect?node_id=` 的连接(对于 `https` 使用 `wss`),并以 Bearer 请求头发送节点凭据。Core 对被拒绝的凭据返回 401,节点将其视为永久性拒绝;其他任何失败(包括代理返回的 403)都会使用有界退避进行重试。当某个节点身份已有一个连接正在建立、存活或关闭时,Core 会以 409 拒绝第二个连接。 +1. 节点在其存储的 Core 源地址上发起对 `/api/v1/sandbox-node/connect?node_id=` 的连接(对于 `https` 使用 `wss`;保留的 `allow_insecure_origin` 策略允许时,非回环 `http` 源地址使用 `ws`),并以 Bearer 请求头发送节点凭据。Core 对被拒绝的凭据返回 401,节点将其视为永久性拒绝;其他任何失败(包括代理返回的 403)都会使用有界退避进行重试。当某个节点身份已有一个连接正在建立、存活或关闭时,Core 会以 409 拒绝第二个连接。 2. 节点须在 15 秒内发送 `hello`,其中包含节点身份(`node_id`、`installation_id`、`provider`、`backend_fingerprint`、已登记的 `deployment_generation` 和 `specification_digest`、`max_active`、`max_retained`)、首次健康报告;如果节点能够准备并保留多个部署代次,还包含 `generation_management: true`。除非身份与已认证节点匹配,否则 Core 会关闭连接。 3. Core 记录节点的存在状态,然后回复 `welcome`,其中包含新的 `connection_id` 和当前 `owner_epoch`;对于支持代次管理的节点,还包含 `deployment`:目标 `generation`、其 `specification_digest` 和可空的 `serving_generation`。节点保存更高的所有者 epoch,并拒绝更低的值。 4. 节点每 10 秒发送一次 `heartbeat`,其中包含 `connection_id`、`owner_epoch` 和健康状态。对于每次心跳,Core 都会再次认证节点凭据并检查所有者 epoch,记录健康状态并回复 `heartbeat_ack`;对于支持代次管理的节点,回复中还包含 `deployment`。任一端连续 35 秒未收到任何帧时都会关闭连接。 diff --git a/deploy/install/node_install.py b/deploy/install/node_install.py index 62cc7b31f..ead705341 100644 --- a/deploy/install/node_install.py +++ b/deploy/install/node_install.py @@ -71,7 +71,7 @@ class RuntimeDownloadError(InstallError): NOTHING_CHANGED = " Nothing was changed." -def origin(value): +def origin(value, allow_insecure_origin=False): try: parsed = urlsplit(value) parsed.port @@ -84,7 +84,7 @@ def origin(value): if (parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username is not None or parsed.password is not None or parsed.path not in ("", "/") or any(c.isspace() for c in value) or any(c in value for c in "?#\\") - or (parsed.scheme == "http" and not local)): + or (parsed.scheme == "http" and not local and not allow_insecure_origin)): raise argparse.ArgumentTypeError("Use an HTTPS origin, or loopback HTTP for a local node") return value.rstrip("/") @@ -397,6 +397,10 @@ def register_node(root, args, token, helper_archive=None): state = {"installation_id": args.installation_id, "provider": args.provider, "core_url": args.core_url, "source_commit": manifest["source_commit"], "generation": args.configuration["generation"], "specification_digest": args.configuration["specification_digest"]} + if args.allow_insecure_origin: + # Only an opted-in installation records the policy, so a default node's + # installation.json and registered.json stay byte-for-byte unchanged. + state["allow_insecure_origin"] = True write_once(root / "installation.json", json_text(state)) node_generations.record_root_runtime(root, args, manifest, sums, sys.modules[__name__]) for name in names: @@ -437,10 +441,13 @@ def register_node(root, args, token, helper_archive=None): with os.fdopen(descriptor, "w") as secret: secret.write(token) install_display.step("Registering this node with Core") + register = [str(root / provider_assets.artifacts(args.provider, ("node",))[0]), "register", "--config", str(root / "provider.json"), + "--state-dir", str(root / "state/node"), "--core-url", args.core_url, "--name", socket.gethostname(), + "--enrollment-token-file", secret_path] + if args.allow_insecure_origin: + register.append("--allow-insecure-origin") try: - checked([str(root / provider_assets.artifacts(args.provider, ("node",))[0]), "register", "--config", str(root / "provider.json"), "--state-dir", str(root / "state/node"), - "--core-url", args.core_url, "--name", socket.gethostname(), - "--enrollment-token-file", secret_path], REGISTRATION_UNCONFIRMED, explain=registration_failure) + checked(register, REGISTRATION_UNCONFIRMED, explain=registration_failure) except AddressChanged: discard_unregistered(root) raise @@ -953,10 +960,10 @@ def quote(value): + "\n\n[Install]\nWantedBy=multi-user.target\n") -def recorded_origin(value, source): +def recorded_origin(value, source, allow_insecure_origin=False): """A Core address read from a file, checked with the same rule as the command line.""" try: - return origin(value) + return origin(value, allow_insecure_origin) except (argparse.ArgumentTypeError, TypeError, AttributeError): raise InstallError(source + " holds an invalid Core address; preserve it and inspect the host." + NOTHING_CHANGED) from None @@ -970,7 +977,9 @@ def node_record(installation_id): if any(record.get(key) != value for key, value in expected.items()) or record.get("provider") not in DEVICE_GROUPS: raise InstallError(str(SYSTEM_RECORDS / (installation_id + ".json")) + " is not this installer's record; preserve " "it and inspect the host." + NOTHING_CHANGED) - recorded_origin(record.get("core_url"), str(SYSTEM_RECORDS / (installation_id + ".json"))) + # Validate the recorded address under the policy the record itself carries, so + # uninstall can read an http record without the original command's flag. + recorded_origin(record.get("core_url"), str(SYSTEM_RECORDS / (installation_id + ".json")), bool(record.get("allow_insecure_origin", False))) return record @@ -990,6 +999,9 @@ def install_system(args, token): if record["core_url"] != args.core_url: raise InstallError("This host's node uses " + record["core_url"] + ", but this command uses " + args.core_url + ". Remove the node on the Nodes page, uninstall it, then run a new command." + NOTHING_CHANGED) + if bool(record.get("allow_insecure_origin", False)) != args.allow_insecure_origin: + raise InstallError("This host's node was installed with a different insecure-origin policy; remove the node on " + "the Nodes page, uninstall it, then run a new command." + NOTHING_CHANGED) install_display.step("Checking host requirements") group, details = provider_group(provider) if record is None: @@ -1006,9 +1018,13 @@ def install_system(args, token): child_docker_config() root = SERVICE_HOME / ".oac/nodes" / args.installation_id unit = SYSTEM_UNITS / unit_name(args.installation_id) - root_file(SYSTEM_RECORDS / (args.installation_id + ".json"), - json_text({"format": 1, "installation_id": args.installation_id, "provider": provider, - "core_url": args.core_url, "node_root": str(root), "unit": str(unit)})) + record_state = {"format": 1, "installation_id": args.installation_id, "provider": provider, + "core_url": args.core_url, "node_root": str(root), "unit": str(unit)} + if args.allow_insecure_origin: + # An opted-in installation records the policy so uninstall and reruns can + # validate the address; a default record keeps its existing bytes. + record_state["allow_insecure_origin"] = True + root_file(SYSTEM_RECORDS / (args.installation_id + ".json"), json_text(record_state)) args.system, args.provider = True, provider run_as(account, prepare_service_node, args, token, helper_archive) root_file(unit, system_unit(root, provider)) @@ -1210,6 +1226,7 @@ def wait_ready(root, args, timeout=60): identity = stored["identity"] credential = stored["credential"] if (len(raw) > 16384 or stored["core_url"] != args.core_url + or bool(stored.get("allow_insecure_origin", False)) != args.allow_insecure_origin or identity["installation_id"] != args.installation_id or identity["provider"] != args.provider or str(uuid.UUID(identity["node_id"])) != identity["node_id"] or not re.fullmatch(r"[0-9a-f]{64}", credential)): @@ -1257,9 +1274,11 @@ def read_token(args): def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) source = parser.add_mutually_exclusive_group() - source.add_argument("--source-url", type=origin) + source.add_argument("--source-url") source.add_argument("--bundle", type=Path) - parser.add_argument("--core-url", type=origin) + parser.add_argument("--core-url") + parser.add_argument("--allow-insecure-origin", action="store_true", + help="Allow a non-loopback plaintext http Core origin (development and test only)") parser.add_argument("--provider", choices=("docker", "microsandbox"), help="Optional assertion; Core owns provider selection") parser.add_argument("--installation-id", required=True) parser.add_argument("--enrollment-token-stdin", action="store_true", help="Read the one-time enrollment token from standard input") @@ -1273,6 +1292,15 @@ def main(argv=None): args = parser.parse_args(argv) if args.no_color: os.environ["NO_COLOR"] = "1" + # The origin rule depends on --allow-insecure-origin, which argparse's per-value + # type cannot see, so validate both addresses after the whole command line is read. + for name in ("source_url", "core_url"): + value = getattr(args, name) + if value is not None: + try: + setattr(args, name, origin(value, args.allow_insecure_origin)) + except argparse.ArgumentTypeError as error: + parser.error(str(error)) if str(uuid.UUID(args.installation_id)) != args.installation_id: raise InstallError("Installation ID must be a canonical UUID") if args.update: diff --git a/deploy/install/test_node_install.py b/deploy/install/test_node_install.py index afbea2a90..0e4a244fd 100644 --- a/deploy/install/test_node_install.py +++ b/deploy/install/test_node_install.py @@ -51,7 +51,8 @@ def setUp(self): self.addCleanup(temporary.cleanup) self.home = Path(temporary.name).resolve() self.args = argparse.Namespace(source_url="https://console.example", core_url="https://172.29.144.1:24443", - provider="docker", installation_id="94be54a1-138c-4f30-bc87-b13686272dbe") + provider="docker", installation_id="94be54a1-138c-4f30-bc87-b13686272dbe", + allow_insecure_origin=False) self.root = self.home / ".oac/nodes" / self.args.installation_id self.manifest = {"platform": "linux/amd64", "source_commit": "a" * 40, "images": {"runtime": "sha256:" + "b" * 64}, "image_manifest_digests": {"runtime": "sha256:" + "c" * 64}, @@ -1026,6 +1027,57 @@ def test_origin_rejects_remote_http_credentials_paths_and_redirects(self): with self.assertRaisesRegex(installer.InstallError, "redirects"): installer.NoRedirect().redirect_request(None, None, 302, "", {}, "https://other.example") + def test_origin_admits_non_loopback_http_only_with_the_explicit_flag(self): + self.assertEqual(installer.origin("http://private.example:8091/", True), "http://private.example:8091") + # The relaxed rule admits only the scheme; every other origin rule still holds. + for value in ("http://user:pass@private.example", "http://private.example/v1", "http://private.example?x=1", + "ftp://private.example", ""): + with self.subTest(value=value), self.assertRaises(argparse.ArgumentTypeError): + installer.origin(value, True) + + def test_main_gates_a_non_loopback_http_origin_on_the_flag(self): + base = ["--source-url", "http://console.example", "--core-url", "http://core.example", + "--installation-id", self.args.installation_id, "--enrollment-token-stdin"] + with mock.patch.object(installer.sys, "stdin", io.StringIO("synthetic-once-token\n")), \ + mock.patch.object(installer.os, "geteuid", return_value=0), \ + mock.patch.object(installer, "install_system") as install: + with self.assertRaises(SystemExit): + installer.main(base) + install.assert_not_called() + installer.main(base + ["--allow-insecure-origin"]) + self.assertTrue(install.call_args.args[0].allow_insecure_origin) + self.assertEqual(install.call_args.args[0].core_url, "http://core.example") + self.assertEqual(install.call_args.args[0].source_url, "http://console.example") + + def test_register_passes_the_insecure_origin_flag_only_when_enabled(self): + # Artifact downloads stay on HTTPS here: the distribution downloader's own + # HTTPS rule is a separate gate, reported rather than relaxed by this change. + self.args.allow_insecure_origin = True + self.install() + registers = [arguments for arguments, _ in self.calls if "register" in arguments] + self.assertEqual(len(registers), 1) + self.assertIn("--allow-insecure-origin", registers[0]) + self.assertTrue(json.loads((self.root / "registered.json").read_text())["allow_insecure_origin"]) + + def test_default_install_omits_the_policy_and_the_register_flag(self): + self.install() + registers = [arguments for arguments, _ in self.calls if "register" in arguments] + self.assertEqual(len(registers), 1) + self.assertNotIn("--allow-insecure-origin", registers[0]) + self.assertNotIn("allow_insecure_origin", json.loads((self.root / "installation.json").read_text())) + self.assertNotIn("allow_insecure_origin", json.loads((self.root / "registered.json").read_text())) + + def test_node_record_validates_an_http_address_under_its_recorded_policy(self): + record = {"installation_id": self.args.installation_id, "provider": "docker", "core_url": "http://private.example", + "node_root": str(installer.SERVICE_HOME / ".oac/nodes" / self.args.installation_id), + "unit": str(installer.SYSTEM_UNITS / installer.unit_name(self.args.installation_id))} + # A record without the policy (an older install) keeps the strict rule. + with mock.patch.object(installer, "read_root_json", return_value=record): + with self.assertRaisesRegex(installer.InstallError, "invalid Core address"): + installer.node_record(self.args.installation_id) + with mock.patch.object(installer, "read_root_json", return_value=dict(record, allow_insecure_origin=True)): + self.assertEqual(installer.node_record(self.args.installation_id)["core_url"], "http://private.example") + class NodePrerequisiteTests(unittest.TestCase): def test_preflight_rejects_missing_kvm_before_downloads(self): diff --git a/deploy/install/test_node_readiness.py b/deploy/install/test_node_readiness.py index b0b3e726b..e8851ef2c 100644 --- a/deploy/install/test_node_readiness.py +++ b/deploy/install/test_node_readiness.py @@ -21,7 +21,7 @@ def setUp(self): self.addCleanup(temporary.cleanup) self.root = Path(temporary.name).resolve() self.args = argparse.Namespace(core_url="https://core.example", provider="docker", - installation_id="94be54a1-138c-4f30-bc87-b13686272dbe") + installation_id="94be54a1-138c-4f30-bc87-b13686272dbe", allow_insecure_origin=False) self.identity = {"node_id": "634d97be-e54d-40f0-9468-ae6b62be85bf", "installation_id": self.args.installation_id, "provider": self.args.provider, "deployment_generation": 1, "specification_digest": "b" * 64} self.path = self.root / "state/node/identity.json" @@ -84,6 +84,19 @@ def test_response_cannot_substitute_another_node(self): with self.assertRaisesRegex(installer.InstallError, "different node identity"): installer.wait_ready(self.root, self.args) + def test_wait_ready_matches_the_retained_insecure_origin_policy(self): + # A command that asks for the relaxed policy must match the retained identity. + self.args.allow_insecure_origin = True + with mock.patch.object(installer, "open_request") as request: + with self.assertRaisesRegex(installer.InstallError, "identity differs"): + installer.wait_ready(self.root, self.args) + request.assert_not_called() + stored = json.loads(self.path.read_text()) + stored["allow_insecure_origin"] = True + self.path.write_text(json.dumps(stored)) + with mock.patch.object(installer, "open_request", return_value=self.response(connected=True, provider_ready=True)): + installer.wait_ready(self.root, self.args) + class MetadataRetryTests(unittest.TestCase): def test_fetch_retries_transient_failure_with_bounded_delays(self): diff --git a/docs/getting-started/nodes.md b/docs/getting-started/nodes.md index 6443c2900..376ce3cf4 100644 --- a/docs/getting-started/nodes.md +++ b/docs/getting-started/nodes.md @@ -149,6 +149,8 @@ Use manual registration when you manage the node's files and service yourself in --state-dir /var/lib/oac/node ``` +To register over a non-loopback `http://` origin for development or testing, add `--allow-insecure-origin` to `oac-node register`. The node then keeps a `ws://` connection; `oac-node run` takes no such flag and uses the policy recorded at registration. Plaintext HTTP carries no transport encryption and is not for production. + `oac-node run` exits with status 78 once Core rejects its credential, after the node is removed; configure the supervisor not to restart it then (systemd: `RestartPreventExitStatus=78`). The node connects out to Core; Core needs no SSH or Docker TCP access to the host. Registration writes the node's identity to the state directory before contacting Core, so a lost response can be retried under the same identity. Keep the state directory on persistent storage, private to the node's account and used by one process at a time. Never copy it to another directory or host: Core refuses a second connection for a node while the first is open. The provider file can't change the node's capacity or sandbox configuration. Core compares its digest at registration and on every connection, and a node whose file differs takes no work until the approved configuration is restored. diff --git a/docs/zh/getting-started/nodes.md b/docs/zh/getting-started/nodes.md index e5decba1c..7ea4bcdbc 100644 --- a/docs/zh/getting-started/nodes.md +++ b/docs/zh/getting-started/nodes.md @@ -1,7 +1,7 @@ --- title: "添加和管理节点" source: docs/getting-started/nodes.md -source_hash: 10ec00613b1072139e98c8f48a0d3942a55ced4999abd4289cd1e0f00e7315f7 +source_hash: 56fc4463786dabf7e6d7588b35ebf10c59572cfa44fc83c2f97b5632ddeae24a --- 节点是一台 Linux 主机,在沙箱后端为 Docker 或 microsandbox 时,为 Core 托管 Session 运行沙箱。Core 将新 Session 分配给有空余容量的节点;节点创建沙箱,沙箱回连 Core。E2B 不需要节点。应用为自己的 Session 连接的机器是[自托管执行器](self-hosted.md),而不是节点。 @@ -151,6 +151,8 @@ root 只准备账号、组和服务单元;其他操作(包括 Docker 网络 --state-dir /var/lib/oac/node ``` +为开发或测试而在非回环 `http://` 源地址上注册时,在 `oac-node register` 上添加 `--allow-insecure-origin`。节点随后保持 `ws://` 连接;`oac-node run` 不接受该 flag,而使用注册时记录的策略。明文 HTTP 不提供传输加密,不适用于生产环境。 + 节点移除后,Core 拒绝其凭据时,`oac-node run` 以状态 78 退出;配置管理器不要在该情况下重启(systemd:`RestartPreventExitStatus=78`)。 节点向外连接 Core;Core 不需要通过 SSH 或 Docker TCP 访问主机。注册在联系 Core 前先将节点身份写入状态目录,因此响应丢失时可以复用同一身份重试。状态目录放在持久存储上,仅节点账号可访问,每次只由一个进程使用。不要复制到其他目录或主机:节点已有连接打开时,Core 拒绝第二个连接。提供商文件不能修改节点容量或沙箱配置。Core 在注册及每次连接时比较摘要;文件不匹配的节点在恢复批准配置前不接收工作。 diff --git a/services/core/cmd/sandbox-node/main.go b/services/core/cmd/sandbox-node/main.go index 68749e61f..a91403643 100644 --- a/services/core/cmd/sandbox-node/main.go +++ b/services/core/cmd/sandbox-node/main.go @@ -53,15 +53,19 @@ func run(ctx context.Context, args []string) error { flags := flag.NewFlagSet("sandbox-node "+args[0], flag.ContinueOnError) configFile := flags.String("config", "", "absolute provider configuration file") stateDir := flags.String("state-dir", "", "absolute private node state directory") - coreURL := flags.String("core-url", "", "Core HTTPS origin (register only)") + coreURL := flags.String("core-url", "", "Core HTTPS origin, or a non-loopback http origin with --allow-insecure-origin (register only)") name := flags.String("name", "sandbox-node", "display name (register only)") tokenFile := flags.String("enrollment-token-file", "", "private single-use enrollment token file (register only)") + allowInsecureOrigin := flags.Bool("allow-insecure-origin", false, "Allow a non-loopback plaintext http Core origin (development and test only; register only)") if err := flags.Parse(args[1:]); err != nil { return err } if flags.NArg() != 0 { return errors.New("unexpected arguments") } + if args[0] == "run" && *allowInsecureOrigin { + return errors.New("--allow-insecure-origin applies only to register; run uses the retained identity") + } if !filepath.IsAbs(*configFile) || !filepath.IsAbs(*stateDir) { return errors.New("config and state-dir must be absolute paths") } @@ -119,7 +123,7 @@ func run(ctx context.Context, args []string) error { if token == "" || len(token) > 4096 { return errors.New("invalid enrollment token") } - if _, err = node.InitIdentity(*stateDir, *coreURL, expected); err != nil { + if _, err = node.InitIdentity(*stateDir, *coreURL, expected, *allowInsecureOrigin); err != nil { return err } probeCtx, stopProbe := context.WithTimeout(ctx, 5*time.Second) diff --git a/services/core/cmd/sandbox-node/main_test.go b/services/core/cmd/sandbox-node/main_test.go index 4da4298bc..7b716cb93 100644 --- a/services/core/cmd/sandbox-node/main_test.go +++ b/services/core/cmd/sandbox-node/main_test.go @@ -52,13 +52,21 @@ func refreshIdentity(t *testing.T, coreURL string) error { t.Fatal(err) } identity := node.Identity{InstallationID: uuid.NewString(), Provider: "docker", BackendFingerprint: strings.Repeat("1", 64)} - if _, err := node.InitIdentity(dir, coreURL, identity); err != nil { + if _, err := node.InitIdentity(dir, coreURL, identity, false); err != nil { t.Fatal(err) } _, err := node.RefreshIdentity(t.Context(), dir) return err } +func TestRunRejectsTheEnrollmentOnlyOriginFlag(t *testing.T) { + // run never chooses a policy: it uses the one retained at registration. + err := run(t.Context(), []string{"run", "--config", "/missing/provider.json", "--state-dir", "/missing/state", "--allow-insecure-origin"}) + if err == nil || !strings.Contains(err.Error(), "--allow-insecure-origin applies only to register") { + t.Fatalf("run accepted the register-only flag: %v", err) + } +} + func TestProtocolVersionRequiresNoProviderOrIdentity(t *testing.T) { // This is a binary capability check, not a provider readiness probe. if err := run(t.Context(), []string{"protocol-version"}); err != nil { diff --git a/services/core/internal/sandbox/node/agent.go b/services/core/internal/sandbox/node/agent.go index cb86b6eac..d51fbe614 100644 --- a/services/core/internal/sandbox/node/agent.go +++ b/services/core/internal/sandbox/node/agent.go @@ -102,7 +102,7 @@ func Run(ctx context.Context, config AgentConfig) error { if config.CoreURL != stored.CoreURL { return sandbox.ErrOwnership } - if _, err = endpoint(config.CoreURL, ""); err != nil { + if _, err = stored.coreEndpoint(""); err != nil { return err } a := &agent{config: config, stored: stored, queue: make(chan work, maxPending)} @@ -179,7 +179,7 @@ func (a *agent) health(ctx context.Context, host *hostHealthSampler) (Health, er return h, e } func (a *agent) connect(ctx context.Context) error { - endpointURL, err := endpoint(a.config.CoreURL, "/api/v1/sandbox-node/connect") + endpointURL, err := a.stored.coreEndpoint("/api/v1/sandbox-node/connect") if err != nil { return err } diff --git a/services/core/internal/sandbox/node/capacity_test.go b/services/core/internal/sandbox/node/capacity_test.go index 1de42f08a..371e93261 100644 --- a/services/core/internal/sandbox/node/capacity_test.go +++ b/services/core/internal/sandbox/node/capacity_test.go @@ -26,7 +26,7 @@ func TestCapacityRefreshUsesAuthenticatedCoreIdentity(t *testing.T) { defer server.Close() dir := stateDir(t) var err error - stored, err = InitIdentity(dir, server.URL, id) + stored, err = InitIdentity(dir, server.URL, id, false) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/sandbox/node/connection_test.go b/services/core/internal/sandbox/node/connection_test.go index 30f88ecd8..f92ff76d6 100644 --- a/services/core/internal/sandbox/node/connection_test.go +++ b/services/core/internal/sandbox/node/connection_test.go @@ -141,7 +141,7 @@ func TestCopiedIdentityCannotReplaceNodeWithInflightCreate(t *testing.T) { defer server.Close() defer hub.Close() dir := stateDir(t) - stored, err := InitIdentity(dir, server.URL, id) + stored, err := InitIdentity(dir, server.URL, id, false) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/sandbox/node/creation_settlement_test.go b/services/core/internal/sandbox/node/creation_settlement_test.go index 02ef95248..e1f2dc8ec 100644 --- a/services/core/internal/sandbox/node/creation_settlement_test.go +++ b/services/core/internal/sandbox/node/creation_settlement_test.go @@ -51,7 +51,7 @@ func TestNodeCarriesCreationSettlementWithoutConvertingFailureToSuccess(t *testi server := httptest.NewServer(hub) defer server.Close() dir := stateDir(t) - stored, err := InitIdentity(dir, server.URL, id) + stored, err := InitIdentity(dir, server.URL, id, false) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/sandbox/node/docker_live_test.go b/services/core/internal/sandbox/node/docker_live_test.go index 5862b308c..dc5f1acd9 100644 --- a/services/core/internal/sandbox/node/docker_live_test.go +++ b/services/core/internal/sandbox/node/docker_live_test.go @@ -49,7 +49,7 @@ func TestDockerNodeTransportLifecycle(t *testing.T) { defer server.Close() defer hub.Close() dir := stateDir(t) - stored, err := InitIdentity(dir, server.URL, id) + stored, err := InitIdentity(dir, server.URL, id, false) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/sandbox/node/enrollment.go b/services/core/internal/sandbox/node/enrollment.go index 4ec8660b9..73add97d7 100644 --- a/services/core/internal/sandbox/node/enrollment.go +++ b/services/core/internal/sandbox/node/enrollment.go @@ -35,7 +35,7 @@ func Enroll(ctx context.Context, coreURL, dir, token string, input EnrollmentReq input.CoreURL = coreURL client := &http.Client{Timeout: 15 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} // This also recovers a consumed registration whose success response was lost. - target, err := endpoint(coreURL, "/api/v1/sandbox-node/identity") + target, err := stored.coreEndpoint("/api/v1/sandbox-node/identity") if err != nil { return StoredIdentity{}, err } @@ -50,7 +50,7 @@ func Enroll(ctx context.Context, coreURL, dir, token string, input EnrollmentReq return retainEnrollment(dir, stored, out) } } - target, err = endpoint(coreURL, "/api/v1/sandbox-node/enroll") + target, err = stored.coreEndpoint("/api/v1/sandbox-node/enroll") if err != nil { return StoredIdentity{}, err } @@ -121,7 +121,7 @@ func RefreshIdentity(ctx context.Context, dir string) (StoredIdentity, error) { if err != nil { return StoredIdentity{}, err } - target, err := endpoint(stored.CoreURL, "/api/v1/sandbox-node/identity") + target, err := stored.coreEndpoint("/api/v1/sandbox-node/identity") if err != nil { return StoredIdentity{}, err } diff --git a/services/core/internal/sandbox/node/generation_connection_test.go b/services/core/internal/sandbox/node/generation_connection_test.go index d39b68459..66e65fd0a 100644 --- a/services/core/internal/sandbox/node/generation_connection_test.go +++ b/services/core/internal/sandbox/node/generation_connection_test.go @@ -41,7 +41,7 @@ func TestGenerationWireRoutesOldOwnershipAndCurrentTargetSeparately(t *testing.T defer server.Close() defer hub.Close() dir := stateDir(t) - stored, err := InitIdentity(dir, server.URL, id) + stored, err := InitIdentity(dir, server.URL, id, false) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/sandbox/node/health_connection_linux_test.go b/services/core/internal/sandbox/node/health_connection_linux_test.go index c2c8fefae..9f58af06a 100644 --- a/services/core/internal/sandbox/node/health_connection_linux_test.go +++ b/services/core/internal/sandbox/node/health_connection_linux_test.go @@ -33,7 +33,7 @@ func TestHostHealthReconnectStartsFreshInterval(t *testing.T) { } })) defer server.Close() - a := agent{config: AgentConfig{CoreURL: server.URL, StateDirectory: t.TempDir(), Identity: identity(), Probe: probe}, stored: StoredIdentity{OwnerEpoch: 1}} + a := agent{config: AgentConfig{CoreURL: server.URL, StateDirectory: t.TempDir(), Identity: identity(), Probe: probe}, stored: StoredIdentity{CoreURL: server.URL, OwnerEpoch: 1}} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() for i := 0; i < 2; i++ { diff --git a/services/core/internal/sandbox/node/identity.go b/services/core/internal/sandbox/node/identity.go index 79567666e..5a4e7de6a 100644 --- a/services/core/internal/sandbox/node/identity.go +++ b/services/core/internal/sandbox/node/identity.go @@ -16,10 +16,11 @@ import ( ) type StoredIdentity struct { - Identity Identity `json:"identity"` - Credential string `json:"credential"` - CoreURL string `json:"core_url"` - OwnerEpoch uint64 `json:"owner_epoch"` + Identity Identity `json:"identity"` + Credential string `json:"credential"` + CoreURL string `json:"core_url"` + OwnerEpoch uint64 `json:"owner_epoch"` + AllowInsecureOrigin bool `json:"allow_insecure_origin,omitempty"` } func lockDirectory(dir string) (func(), error) { @@ -99,16 +100,16 @@ func writeIdentity(dir string, s StoredIdentity) error { // InitIdentity creates the credential before any enrollment request. A lost // enrollment response can therefore be recovered by authenticating this identity. -func InitIdentity(dir, coreURL string, identity Identity) (StoredIdentity, error) { +func InitIdentity(dir, coreURL string, identity Identity, allowInsecureOrigin bool) (StoredIdentity, error) { release, err := lockDirectory(dir) if err != nil { return StoredIdentity{}, err } defer release() - return initIdentity(dir, coreURL, identity) + return initIdentity(dir, coreURL, identity, allowInsecureOrigin) } -func initIdentity(dir, coreURL string, identity Identity) (StoredIdentity, error) { - if _, err := endpoint(coreURL, ""); err != nil { +func initIdentity(dir, coreURL string, identity Identity, allowInsecureOrigin bool) (StoredIdentity, error) { + if _, err := validateEndpoint(coreURL, "", allowInsecureOrigin); err != nil { return StoredIdentity{}, err } stored, err := readIdentity(dir) @@ -117,7 +118,7 @@ func initIdentity(dir, coreURL string, identity Identity) (StoredIdentity, error if expected.NodeID == "" { expected.NodeID = stored.Identity.NodeID } - if !sameBackend(stored.Identity, expected) || stored.CoreURL != coreURL { + if !sameBackend(stored.Identity, expected) || stored.CoreURL != coreURL || stored.AllowInsecureOrigin != allowInsecureOrigin { return StoredIdentity{}, errors.New("node configuration does not match its retained identity") } return stored, nil @@ -135,7 +136,7 @@ func initIdentity(dir, coreURL string, identity Identity) (StoredIdentity, error if _, err = rand.Read(secret); err != nil { return StoredIdentity{}, err } - stored = StoredIdentity{Identity: identity, Credential: hex.EncodeToString(secret), CoreURL: coreURL} + stored = StoredIdentity{Identity: identity, Credential: hex.EncodeToString(secret), CoreURL: coreURL, AllowInsecureOrigin: allowInsecureOrigin} if err = writeIdentity(dir, stored); err != nil { return StoredIdentity{}, err } @@ -150,14 +151,38 @@ func LoadIdentity(dir string) (StoredIdentity, error) { return readIdentity(dir) } +// endpoint is the default Core origin contract: HTTPS, or plaintext HTTP only +// for a loopback address. func endpoint(raw, path string) (string, error) { + return validateEndpoint(raw, path, false) +} + +// endpointAllowingInsecureOrigin accepts a non-loopback plaintext HTTP origin. It +// exists only for a node whose retained identity recorded that enrollment policy. +func endpointAllowingInsecureOrigin(raw, path string) (string, error) { + return validateEndpoint(raw, path, true) +} + +// coreEndpoint resolves the retained Core origin under the policy recorded when +// the node was enrolled. The enrollment flag has exactly one home: identity.json. +func (s StoredIdentity) coreEndpoint(path string) (string, error) { + if s.AllowInsecureOrigin { + return endpointAllowingInsecureOrigin(s.CoreURL, path) + } + return endpoint(s.CoreURL, path) +} + +// validateEndpoint is the single origin rule. TLS certificate verification is +// never relaxed here; allowInsecureOrigin only admits a plaintext HTTP scheme. +func validateEndpoint(raw, path string, allowInsecureOrigin bool) (string, error) { u, err := url.Parse(raw) if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") { return "", errors.New("node Core URL must be an origin") } if u.Scheme != "https" { ip := net.ParseIP(u.Hostname()) - if u.Scheme != "http" || !(u.Hostname() == "localhost" || ip != nil && ip.IsLoopback()) { + loopback := u.Hostname() == "localhost" || ip != nil && ip.IsLoopback() + if u.Scheme != "http" || (!loopback && !allowInsecureOrigin) { return "", errors.New("remote node Core URL requires HTTPS") } } diff --git a/services/core/internal/sandbox/node/node_test.go b/services/core/internal/sandbox/node/node_test.go index 2d375be1c..23b21e47c 100644 --- a/services/core/internal/sandbox/node/node_test.go +++ b/services/core/internal/sandbox/node/node_test.go @@ -97,7 +97,7 @@ func TestLostCreateResponseDoesNotReplayAndReconnectSerializesCleanup(t *testing defer server.Close() defer hub.Close() dir := stateDir(t) - stored, err := InitIdentity(dir, server.URL, id) + stored, err := InitIdentity(dir, server.URL, id, false) if err != nil { t.Fatal(err) } @@ -202,7 +202,7 @@ func TestAgentRejectsDuplicateSequenceAndRetainsEpoch(t *testing.T) { })) defer server.Close() dir := stateDir(t) - stored, e := InitIdentity(dir, server.URL, id) + stored, e := InitIdentity(dir, server.URL, id, false) if e != nil { t.Fatal(e) } @@ -260,7 +260,7 @@ func TestEnrollmentLostResponseRecoversWithPersistedCredential(t *testing.T) { defer server.Close() dir := stateDir(t) var err error - stored, err = InitIdentity(dir, server.URL, id) + stored, err = InitIdentity(dir, server.URL, id, false) if err != nil { t.Fatal(err) } @@ -275,7 +275,7 @@ func TestEnrollmentLostResponseRecoversWithPersistedCredential(t *testing.T) { t.Fatal("enrollment was replayed, identity rotated or approved capacity lost") } id.MaxActive, id.MaxRetained = 0, 0 - if retry, err := InitIdentity(dir, server.URL, id); err != nil || retry != recovered { + if retry, err := InitIdentity(dir, server.URL, id, false); err != nil || retry != recovered { t.Fatal("register retry treated absent local capacity as an override", err) } if _, err := Enroll(t.Context(), server.URL, dir, "consumed", EnrollmentRequest{Name: "test"}); err != nil || enrollments != 1 { @@ -294,6 +294,92 @@ func TestCoreURLRejectsRemotePlaintextAndCredentials(t *testing.T) { t.Fatalf("rejected %q: %v", raw, err) } } + // The explicit variant admits a non-loopback plaintext origin but keeps every + // other origin rule: credentials, query, fragment, path and non-http schemes. + if got, err := endpointAllowingInsecureOrigin("http://core.example.test:8080", "/api/v1/sandbox-node/connect"); err != nil || got != "http://core.example.test:8080/api/v1/sandbox-node/connect" { + t.Fatalf("relaxed endpoint = %q, %v", got, err) + } + for _, raw := range []string{"http://user:pass@example.com", "http://example.com/path", "http://example.com/?x=1", "ftp://example.com"} { + if _, err := endpointAllowingInsecureOrigin(raw, "/x"); err == nil { + t.Fatalf("relaxed endpoint accepted %q", raw) + } + } +} + +func TestInsecureOriginPolicyPersistsInRetainedIdentity(t *testing.T) { + dir := stateDir(t) + id := identity() + stored, err := InitIdentity(dir, "http://core.example.test:8080", id, true) + if err != nil { + t.Fatal(err) + } + if !stored.AllowInsecureOrigin { + t.Fatal("InitIdentity did not return the enrollment policy") + } + reloaded, err := LoadIdentity(dir) + if err != nil || !reloaded.AllowInsecureOrigin || reloaded.CoreURL != "http://core.example.test:8080" { + t.Fatalf("policy was not retained: %+v, %v", reloaded, err) + } + if got, err := reloaded.coreEndpoint("/api/v1/sandbox-node/connect"); err != nil || got != "http://core.example.test:8080/api/v1/sandbox-node/connect" { + t.Fatalf("retained endpoint = %q, %v", got, err) + } + if _, err := InitIdentity(dir, "http://core.example.test:8080", id, false); err == nil { + t.Fatal("a rerun silently changed the retained origin policy") + } +} + +func TestDefaultIdentityOmitsAndRejectsInsecureOrigin(t *testing.T) { + dir := stateDir(t) + if _, err := InitIdentity(dir, "http://core.example.test", identity(), false); err == nil { + t.Fatal("default enrollment accepted a non-loopback http origin") + } + stored, err := InitIdentity(dir, "https://core.example.test", identity(), false) + if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(dir, "identity.json")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "allow_insecure_origin") { + t.Fatalf("a default identity changed its on-disk shape: %s", raw) + } + if _, err := stored.coreEndpoint("/x"); err != nil { + t.Fatalf("https endpoint rejected: %v", err) + } +} + +func TestOldIdentityWithoutPolicyStaysStrict(t *testing.T) { + // An identity.json written before the field existed decodes to the strict policy. + dir := stateDir(t) + stored := StoredIdentity{Identity: identity(), Credential: strings.Repeat("ab", 32), CoreURL: "http://core.example.test"} + if err := writeIdentity(dir, stored); err != nil { + t.Fatal(err) + } + decoded, err := LoadIdentity(dir) + if err != nil { + t.Fatal(err) + } + if decoded.AllowInsecureOrigin { + t.Fatal("a missing field enabled the relaxed policy") + } + if _, err := decoded.coreEndpoint("/x"); err == nil { + t.Fatal("an old identity accepted a non-loopback http origin") + } +} + +func TestEnrollUsesRetainedInsecureOriginPolicy(t *testing.T) { + dir := stateDir(t) + id := identity() + if _, err := InitIdentity(dir, "http://core.invalid:8080", id, true); err != nil { + t.Fatal(err) + } + if _, err := Enroll(context.Background(), "http://core.invalid:8080", dir, "token", EnrollmentRequest{Name: "x"}); err == nil || strings.Contains(err.Error(), "requires HTTPS") { + t.Fatalf("Enroll did not use the retained policy: %v", err) + } + if _, err := RefreshIdentity(context.Background(), dir); err == nil || strings.Contains(err.Error(), "requires HTTPS") { + t.Fatalf("RefreshIdentity did not use the retained policy: %v", err) + } } func TestHealthSendsOnlyFixedDiagnosticCode(t *testing.T) { diff --git a/services/core/internal/sandbox/node/observations_test.go b/services/core/internal/sandbox/node/observations_test.go index f9b7e1656..7b03b0c3c 100644 --- a/services/core/internal/sandbox/node/observations_test.go +++ b/services/core/internal/sandbox/node/observations_test.go @@ -43,7 +43,7 @@ func observationTarget(r sandbox.Reference, installation string) runtimeobs.Targ func runObservationNode(t *testing.T, hub *Hub, url string, id Identity, provider sandbox.SandboxProvider) context.CancelFunc { t.Helper() dir := stateDir(t) - stored, err := InitIdentity(dir, url, id) + stored, err := InitIdentity(dir, url, id, false) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/sandbox/node/recovery_test.go b/services/core/internal/sandbox/node/recovery_test.go index 23ba863e3..33212c135 100644 --- a/services/core/internal/sandbox/node/recovery_test.go +++ b/services/core/internal/sandbox/node/recovery_test.go @@ -33,7 +33,7 @@ func TestCoreRestartFencesOldConnectionAndNodeRestartKeepsIdentity(t *testing.T) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock(); h := current; mu.Unlock(); h.ServeHTTP(w, r) })) defer server.Close() dir := stateDir(t) - stored, err := InitIdentity(dir, server.URL, id) + stored, err := InitIdentity(dir, server.URL, id, false) if err != nil { t.Fatal(err) } @@ -95,7 +95,7 @@ func TestAgentRejectsOwnerEpochRollback(t *testing.T) { })) defer server.Close() dir := stateDir(t) - stored, err := InitIdentity(dir, server.URL, id) + stored, err := InitIdentity(dir, server.URL, id, false) if err != nil { t.Fatal(err) } @@ -130,7 +130,7 @@ func TestHeartbeatAcknowledgementKeepsIdleConnectionAlive(t *testing.T) { defer server.Close() defer hub.Close() dir := stateDir(t) - stored, err := InitIdentity(dir, server.URL, id) + stored, err := InitIdentity(dir, server.URL, id, false) if err != nil { t.Fatal(err) } @@ -209,7 +209,7 @@ func TestDegradedNodeRetainsObservationAndCleanup(t *testing.T) { defer server.Close() defer hub.Close() dir := stateDir(t) - stored, err := InitIdentity(dir, server.URL, id) + stored, err := InitIdentity(dir, server.URL, id, false) if err != nil { t.Fatal(err) } @@ -255,13 +255,13 @@ func TestDegradedNodeRetainsObservationAndCleanup(t *testing.T) { func TestCorruptOrMismatchedIdentityNeverRotates(t *testing.T) { id := identity() dir := stateDir(t) - stored, err := InitIdentity(dir, "https://core.example.test", id) + stored, err := InitIdentity(dir, "https://core.example.test", id, false) if err != nil { t.Fatal(err) } mismatch := id mismatch.BackendFingerprint = strings.Repeat("2", 64) - if _, err = InitIdentity(dir, stored.CoreURL, mismatch); err == nil { + if _, err = InitIdentity(dir, stored.CoreURL, mismatch, false); err == nil { t.Fatal("adopted wrong backend") } original, err := LoadIdentity(dir) @@ -271,7 +271,7 @@ func TestCorruptOrMismatchedIdentityNeverRotates(t *testing.T) { if err = os.WriteFile(filepath.Join(dir, "identity.json"), []byte("corrupt"), 0600); err != nil { t.Fatal(err) } - if _, err = InitIdentity(dir, stored.CoreURL, id); err == nil { + if _, err = InitIdentity(dir, stored.CoreURL, id, false); err == nil { t.Fatal("corrupt identity replaced") } if err = os.Remove(filepath.Join(dir, "identity.json")); err != nil { diff --git a/services/core/internal/sandbox/node/timeout_test.go b/services/core/internal/sandbox/node/timeout_test.go index c849b853d..5d0f29bea 100644 --- a/services/core/internal/sandbox/node/timeout_test.go +++ b/services/core/internal/sandbox/node/timeout_test.go @@ -82,7 +82,7 @@ func TestQueuedMutationExpiresWithoutExecution(t *testing.T) { defer server.Close() defer hub.Close() dir := stateDir(t) - stored, err := InitIdentity(dir, server.URL, id) + stored, err := InitIdentity(dir, server.URL, id, false) if err != nil { t.Fatal(err) }