fix(managed-agent): correct upgrade URL guards and plan output

This commit is contained in:
Shaojin Wen committed 2026-11-07 07:45:16 +08:00
1 parent 781dd2d5c6
commit affe6c5de3
6 files changed
+80 -50

No files matched your search

@@ -123,7 +123,7 @@ Two cases stay with the operator. The jar can fail to resolve the configuration,
- **Health endpoint:** Spring Boot's rules for the management port (`ManagementPortType`). Unset or equal to the server's port, it shares the server's listener. A negative one turns the HTTP endpoints off. Any other one opens a listener on `management.server.address`, which binds every interface when unset, under `management.server.base-path`, keeping the server's TLS unless `management.server.ssl` replaces it. `management.endpoints.web.base-path` and the `health` path mapping apply either way, and a blank mapping keeps the name `health`, as Spring Boot ignores it. - **Health endpoint:** Spring Boot's rules for the management port (`ManagementPortType`). Unset or equal to the server's port, it shares the server's listener. A negative one turns the HTTP endpoints off. Any other one opens a listener on `management.server.address`, which binds every interface when unset, under `management.server.base-path`, keeping the server's TLS unless `management.server.ssl` replaces it. `management.endpoints.web.base-path` and the `health` path mapping apply either way, and a blank mapping keeps the name `health`, as Spring Boot ignores it.
- **Harness:** `qwen.managed-agent.harness.base-url`, the URL the control plane dials, and the preflight's verdict on it: whether its harness row takes the value, untrimmed, as the Harness origin, or `skip` with the Harness disabled, where nothing reads it. - **Harness:** `qwen.managed-agent.harness.base-url`, the URL the control plane dials, and the preflight's verdict on it: whether its harness row takes the value, untrimmed, as the Harness origin, or `skip` with the Harness disabled, where nothing reads it.
Each phase checks the URLs it dials: both probe the health endpoint, and only `verify --tenant` reads the API, and only when its session row would: the auth mode is `open`, and an `--actor` has a trusted actor header to go in. A phase refuses only over those. A port of 0, picked at start, or a negative one leaves no URL to wait on, so the phase refuses until `--server-url` names one. It refuses as well a URL `fetch` refuses to dial, rather than wait out `--timeout` on probes that cannot be sent: a link-local IPv6 address, which keeps a zone no URL can carry; a port the Fetch standard blocks, such as 6000; and, from `--server-url`, a URL other than `http` or `https`, such as `proxy.test:8443`, or one with a user name or password. A `--server-url` with a query or a fragment is refused too, since the `/actuator/health` and API paths are appended to it and would land in either, and so is one with an `@` left after the URL parser has read no user information: that is the rest of a password that starts with digits, as in `https://svc:12/[email protected]`, which the parser reads as host `svc` and port `12`, so dialling it would reach the wrong host and naming it would print the password. `check` judges `--server-url` the same way, since its plan repeats the option. `start` waits for the Harness at its URL, so it refuses one that is not `http` or `https`: an opaque value such as `proxy.test:8443` names no host to wait on at all. Past that, it refuses a Harness URL the server's Harness connector would refuse, on the server's own verdict, which its configuration reader passes on: the preflight and the connector read the value untrimmed as `java.net.URI` does and take only an `http` or `https` URL with a host and without user information, a query or a fragment, so user information, a query, a fragment, a host with an underscore, an empty user name or a padded value is refused before the Harness restarts, and the refusal names a reason only where the value shows one the server gives too: a user name or password, a query or a fragment, or an `@` in an authority, which `java.net.URI` reads after `//` up to a `/`, `?` or `#`. With the Harness disabled nothing reads the value, so nothing more is asked of it. Pooled, the preflight skips its harness row before it reads the value, but each pool member's connector checks it by the same rule, and the pool drops a member whose connector refuses it, so it is judged all the same. When the configuration does not resolve there is no verdict, so `start` takes only a plain `http` or `https` URL, a rule of its own and narrower than the server's: a host name, an IPv4 address or a bracketed IPv6 one, an optional port, and a path of the characters a URI path holds, read untrimmed. A disabled Harness's value may then be refused for nothing, but letting it through could start a pool that accepts no Session. The preflight judges the rest, such as whether the Harness answers. A refusal or a report row prints a URL with its user information as `***`, whether or not the URL parses, `start`'s three outcomes and `verify`'s health row included: everything up to its last `@` shows as `***`, since a password may hold a `/`, `?` or `#` that ends the authority early, and one that starts with digits then even parses, as a port, with the rest of it in the path, the query or the fragment (`http://svc:2024#Secret@harness:4170`). A probe that is sent and still fails is named: `start`'s timeout and `verify`'s health row say what the endpoint last answered, such as `HTTP 503, status DOWN` or `self-signed certificate`, with each control character in that text shown as `�` and the text cut at 200 code points, so an endpoint can neither move the cursor, split the row nor scroll the report away. A status that is no string is shown as its JSON, or as `[unprintable]` when it is nested too deep to write out, and an empty one as `""`: `0`, `false` and an empty string are statuses too, and only a `null` one names nothing, as a missing one does. The URLs the two phases print, which come from the configuration or the command line, have their control characters replaced the same way, and so does every value a refusal or a row quotes as JSON, such as a refused URL or a Session id no route can carry: JSON escapes C0 controls but leaves DEL and C1 bytes, such as the CSI introducer U+009B, which are then shown as `�` and the quoted text cut at 200 code points. A Session id or an event type counts only as a string: a Session without a string id fails its row with what came back, and an event type that is no string names no outcome, so the turn row fails. A page whose data is not a list, or that has none, fails its row the same way, and so does a Session id a route cannot carry as one segment (empty, `.` or `..`) or at all (one with a lone surrogate, which has no UTF-8 to percent-encode). Only an event marked `terminal: true` ends the turn, and an event's sequence moves the read on only as a whole number or the digiLine truncated Each phase checks the URLs it dials: both probe the health endpoint, and only `verify --tenant` reads the API, and only when its session row would: the auth mode is `open`, and an `--actor` has a trusted actor header to go in. A phase refuses only over those. A port of 0, picked at start, or a negative one leaves no URL to wait on, so the phase refuses until `--server-url` names one. It refuses as well a URL `fetch` refuses to dial, rather than wait out `--timeout` on probes that cannot be sent: a link-local IPv6 address, which keeps a zone no URL can carry; a port the Fetch standard blocks, such as 6000; and, from `--server-url`, a URL other than `http` or `https`, such as `proxy.test:8443`, or one with a user name or password. A `--server-url` with a query or a fragment is refused too, since the `/actuator/health` and API paths are appended to it and would land in either. An `@` in its path remains valid path text, including when the same characters could spell the rest of a password that starts with digits; the plan and report rows mask everything up to the last `@` instead of refusing a valid proxy route. `check` judges `--server-url` the same way, since its plan repeats the option. `start` waits for the Harness at its URL, so it refuses one that is not `http` or `https`: an opaque value such as `proxy.test:8443` names no host to wait on at all. Past that, it refuses a Harness URL the server's Harness connector would refuse, on the server's own verdict, which its configuration reader passes on: the preflight and the connector read the value untrimmed as `java.net.URI` does and take only an `http` or `https` URL with a host and without user information, a query or a fragment, so user information, a query, a fragment, a host with an underscore, an empty user name or a padded value is refused before the Harness restarts, and the refusal names a reason only where the value shows one the server gives too: a user name or password, a query or a fragment, or an `@` in an authority, which `java.net.URI` reads after `//` up to a `/`, `?` or `#`. With the Harness disabled nothing reads the value, so nothing more is asked of it. Pooled, the preflight skips its harness row before it reads the value, but each pool member's connector checks it by the same rule, and the pool drops a member whose connector refuses it, so it is judged all the same. When the configuration does not resolve there is no verdict, so `start` takes only a plain `http` or `https` URL, a rule of its own and narrower than the server's: a host name, an IPv4 address or a bracketed IPv6 one, an optional port, and a path of the characters a URI path holds, read untrimmed. A disabled Harness's value may then be refused for nothing, but letting it through could start a pool that accepts no Session. The preflight judges the rest, such as whether the Harness answers. A refusal or a report row prints a URL with its user information as `***`, whether or not the URL parses, `start`'s three outcomes and `verify`'s health row included: everything up to its last `@` shows as `***`, since a password may hold a `/`, `?` or `#` that ends the authority early, and one that starts with digits then even parses, as a port, with the rest of it in the path, the query or the fragment (`http://svc:2024#Secret@harness:4170`). A probe that is sent and still fails is named: `start`'s timeout and `verify`'s health row say what the endpoint last answered, such as `HTTP 503, status DOWN` or `self-signed certificate`, with each control character in that text shown as `�` and the text cut at 200 code points, so an endpoint can neither move the cursor, split the row nor scroll the report away. A status that is no string is shown as its JSON, or as `[unprintable]` when it is nested too deep to write out, and an empty one as `""`: `0`, `false` and an empty string are statuses too, and only a `null` one names nothing, as a missing one does. The URLs the two phases print and the `--server-url` `check`'s plan repeats, which come from the configuration or the command line, have their control characters replaced the same way, and so does every value a refusal or a row quotes as JSON, such as a refused URL or a Session id no route can carry: JSON escapes C0 controls but leaves DEL and C1 bytes, such as the CSI introducer U+009B, which are then shown as `�` and the quoted text cut at 200 code points. A Session id or an event type counts only as a string: a Session without a string id fails its row with what came back, and an event type that is no string names no outcome, so the turn row fails. A page whose data is not a list, or that has none, fails its row the same way, and so does a Session id a route cannot carry as one segment (empty, `.` or `..`) or at all (one with a lone surrogate, which has no UTF-8 to percent-encode). Only an event marked `terminal: true` ends the turn, and an event's sequence moves the read on only as a whole number or the digits of one, and only forward. TheLine truncated
### Backup freshness ### Backup freshness
@@ -123,7 +123,7 @@ Spring 会从许多地方解析 `qwen.managed-agent.runtime-broker.state-directo
- **健康端点:** 遵循 Spring Boot 关于 management 端口的规则(`ManagementPortType`)。未设置或与服务端端口相同时,它与服务端共用监听;为负数时关闭 HTTP 端点;其他值则在 `management.server.address` 上打开独立监听(未设置时绑定所有接口),位于 `management.server.base-path` 之下,并沿用服务端的 TLS,除非 `management.server.ssl` 替换它。两种情况下都应用 `management.endpoints.web.base-path` 和 `health` 的路径映射;路径映射为空白时 Spring Boot 会忽略它,仍使用名称 `health`。 - **健康端点:** 遵循 Spring Boot 关于 management 端口的规则(`ManagementPortType`)。未设置或与服务端端口相同时,它与服务端共用监听;为负数时关闭 HTTP 端点;其他值则在 `management.server.address` 上打开独立监听(未设置时绑定所有接口),位于 `management.server.base-path` 之下,并沿用服务端的 TLS,除非 `management.server.ssl` 替换它。两种情况下都应用 `management.endpoints.web.base-path` 和 `health` 的路径映射;路径映射为空白时 Spring Boot 会忽略它,仍使用名称 `health`。
- **Harness:** `qwen.managed-agent.harness.base-url`,即控制面访问它所用的 URL,以及 preflight 对它的判定:其 harness 行是否把这个未经 trim 的取值当作 Harness origin 接受;Harness 被禁用时判定为 `skip`,此时没有任何地方读取这个值。 - **Harness:** `qwen.managed-agent.harness.base-url`,即控制面访问它所用的 URL,以及 preflight 对它的判定:其 harness 行是否把这个未经 trim 的取值当作 Harness origin 接受;Harness 被禁用时判定为 `skip`,此时没有任何地方读取这个值。
每个阶段只检查自己会拨号的 URL:两个阶段都会探测健康端点,只有 `verify --tenant` 会读取 API,而且只在其 session 行确实要读时才读:auth mode 为 `open`,且给了 `--actor` 时服务端配置了可信 actor header。阶段只会因这些 URL 拒绝执行。端口为 0(启动时选取)或负数时没有可等待的 URL,因此该阶段会拒绝执行,直到 `--server-url` 指定一个。`fetch` 会拒绝拨号的 URL,同样会被拒绝,而不是在根本发不出去的探测上等满 `--timeout`:保留了 zone 的 IPv6 链路本地地址(URL 无法携带 zone)、Fetch 标准屏蔽的端口(例如 6000),以及 `--server-url` 给出的非 `http`/`https` URL(例如 `proxy.test:8443`)或带用户名、密码的 URL。带 query 或 fragment 的 `--server-url` 同样会被拒绝,因为 `/actuator/health` 和 API 路径是拼接在它后面的,会落进 query 或 fragment 里;URL 解析器读不出用户信息、却仍带有 `@` 的 `--server-url` 也会被拒绝:那是以数字开头的密码的剩余部分,例如 `https://svc:12/[email protected]` 会被解析成主机 `svc`、端口 `12`,拨号会连到错误的主机,打印又会泄露密码。`check` 以同样的方式判定 `--server-url`,因为它打印的计划会重复这个选项。`start` 要在 Harness URL 上等待 Harness,因此会拒绝不是 `http` 或 `https` 的取值:`proxy.test:8443` 这样的不透明取值根本没有可等待的主机。除此之外,对于服务端 Harness 连接器不接受的 Harness URL,`start` 会在重启 Harness 之前拒绝,依据的是服务端配置解析器转达的服务端自身判定:preflight 和连接器都按 `java.net.URI` 的方式读取未经 trim 的取值,只接受带主机名、且不带用户信息、query 或 fragment 的 `http` 或 `https` URL,因此带用户信息、query、fragment、带下划线的主机名、空用户名或带首尾空白的取值都会在重启 Harness 之前被拒绝;只有当取值本身显示出一个服务端同样会给出的原因时,拒绝提示才会写明:用户名或密码、query 或 fragment,或 authority 中的 `@`(`java.net.URI` 在 `//` 之后读取 authority,直到 `/`、`?` 或 `#`)。Harness 被禁用时没有任何地方读取这个值,因此不再对它提其他要求。启用 pool 时,preflight 的 harness 行在读取该值之前就跳过,但每个 pool 成员的连接器都按同一规则检查它,连接器拒绝的成员会被 pool 丢弃,因此照样要判定。无法解析配置时没有判定,`start` 只接受朴素的 `http` 或 `https` URL,这是命令自己的规则,比服务端的更窄:主机名、IPv4 地址或带方括号的 IPv6 地址,可选端口,以及只含 URI 路径允许字符的路径,按未经 trim 的原样读取。这样,被禁用的 Harness 的取值可能被白白拒绝,但若放行,可能启动一个不接收任何 Session 的 pool。其余情况由 preflight 判定,例如 Harness 是否应答。拒绝提示或报告行打印 URL 时,无论它能否解析,用户信息都显示为 `***`,`start` 的三种结果和 `verify` 的 health 行也不例外:最后一个 `@` 之前的内容全部显示为 `***`,因为密码中可能含有 `/`、`?` 或 `#`,使 authority 提前结束;以数字开头的这类密码甚至能解析成功,被读成端口,其余部分落进路径、query 或 fragment(`http://svc:2024#Secret@harness:4170`)。探测发出后仍失败时,会说明原因:`start` 的超时提示和 `verify` 的 health 行给出健康端点最后一次的应答,例如 `HTTP 503, status DOWN` 或 `self-signed certificate`;应答文本中的控制字符显示为 `�`,文本在 200 个码点处截断,因此端点既无法移动光标、拆开一行,也无法把报告刷出屏幕。不是字符串的 status 显示为它的 JSON;嵌套太深无法写出时显示为 `[unprintable]`;空串显示为 `""`。`0`、`false` 和空串同样是 status,只有 `null` 与缺失一样不代表任何值。两个阶段打印的 URL 来自配置或命令行,其中的控制字符同样会被替换;拒绝提示或某一行以 JSON 形式引用的值也是如此,例如被拒绝的 URL、无法放进路由的 Session id:JSON 会转义 C0 控制字符,但不转义 DEL 和 C1 字节(例如 CSI 引导符 U+009B),这些字节随后显示为 `�`,引用的文本在 200 个码点处截断。Session id 和事件类型只认字符串:Session 没有字符串 id 时,该行判失败并写出实际返回的值;事件类型不是字符串时不代表任何结果,turn 行判失败。页面的 data 不是列表或缺失时,对应的行同样判失败;Session id 无法作为路由中的单独一段时(空串、`.` 或 `..`),或根本无法放进路由时(含孤立代理项,没有可做百分号编码的 UTF-8),也是如此。只有标记Line truncated 每个阶段只检查自己会拨号的 URL:两个阶段都会探测健康端点,只有 `verify --tenant` 会读取 API,而且只在其 session 行确实要读时才读:auth mode 为 `open`,且给了 `--actor` 时服务端配置了可信 actor header。阶段只会因这些 URL 拒绝执行。端口为 0(启动时选取)或负数时没有可等待的 URL,因此该阶段会拒绝执行,直到 `--server-url` 指定一个。`fetch` 会拒绝拨号的 URL,同样会被拒绝,而不是在根本发不出去的探测上等满 `--timeout`:保留了 zone 的 IPv6 链路本地地址(URL 无法携带 zone)、Fetch 标准屏蔽的端口(例如 6000),以及 `--server-url` 给出的非 `http`/`https` URL(例如 `proxy.test:8443`)或带用户名、密码的 URL。带 query 或 fragment 的 `--server-url` 同样会被拒绝,因为 `/actuator/health` 和 API 路径是拼接在它后面的,会落进 query 或 fragment 里。路径中的 `@` 仍是合法的路径文本,即使同样的字符也可能是以数字开头的密码的剩余部分;计划和报告行仍将最后一个 `@` 之前显示为 `***`,不再因此拒绝合法的代理路由。`check` 以同样的方式判定 `--server-url`,因为它打印的计划会重复这个选项。`start` 要在 Harness URL 上等待 Harness,因此会拒绝不是 `http` 或 `https` 的取值:`proxy.test:8443` 这样的不透明取值根本没有可等待的主机。除此之外,对于服务端 Harness 连接器不接受的 Harness URL,`start` 会在重启 Harness 之前拒绝,依据的是服务端配置解析器转达的服务端自身判定:preflight 和连接器都按 `java.net.URI` 的方式读取未经 trim 的取值,只接受带主机名、且不带用户信息、query 或 fragment 的 `http` 或 `https` URL,因此带用户信息、query、fragment、带下划线的主机名、空用户名或带首尾空白的取值都会在重启 Harness 之前被拒绝;只有当取值本身显示出一个服务端同样会给出的原因时,拒绝提示才会写明:用户名或密码、query 或 fragment,或 authority 中的 `@`(`java.net.URI` 在 `//` 之后读取 authority,直到 `/`、`?` 或 `#`)。Harness 被禁用时没有任何地方读取这个值,因此不再对它提其他要求。启用 pool 时,preflight 的 harness 行在读取该值之前就跳过,但每个 pool 成员的连接器都按同一规则检查它,连接器拒绝的成员会被 pool 丢弃,因此照样要判定。无法解析配置时没有判定,`start` 只接受朴素的 `http` 或 `https` URL,这是命令自己的规则,比服务端的更窄:主机名、IPv4 地址或带方括号的 IPv6 地址,可选端口,以及只含 URI 路径允许字符的路径,按未经 trim 的原样读取。这样,被禁用的 Harness 的取值可能被白白拒绝,但若放行,可能启动一个不接收任何 Session 的 pool。其余情况由 preflight 判定,例如 Harness 是否应答。拒绝提示或报告行打印 URL 时,无论它能否解析,用户信息都显示为 `***`,`start` 的三种结果和 `verify` 的 health 行也不例外:最后一个 `@` 之前的内容全部显示为 `***`,因为密码中可能含有 `/`、`?` 或 `#`,使 authority 提前结束;以数字开头的这类密码甚至能解析成功,被读成端口,其余部分落进路径、query 或 fragment(`http://svc:2024#Secret@harness:4170`)。探测发出后仍失败时,会说明原因:`start` 的超时提示和 `verify` 的 health 行给出健康端点最后一次的应答,例如 `HTTP 503, status DOWN` 或 `self-signed certificate`;应答文本中的控制字符显示为 `�`,文本在 200 个码点处截断,因此端点既无法移动光标、拆开一行,也无法把报告刷出屏幕。不是字符串的 status 显示为它的 JSON;嵌套太深无法写出时显示为 `[unprintable]`;空串显示为 `""`。`0`、`false` 和空串同样是 status,只有 `null` 与缺失一样不代表任何值。两个阶段打印的 URL 与 `check` 计划中的 `--server-url` 来自配置或命令行,其中的控制字符同样会被替换;拒绝提示或某一行以 JSON 形式引用的值也是如此,例如被拒绝的 URL、无法放进路由的 Session id:JSON 会转义 C0 控制字符,但不转义 DEL 和 C1 字节(例如 CSI 引导符 U+009B),这些字节随后显示为 `�`,引用的文本在 200 个码点处截断。Session id 和事件类型只认字符串:Session 没有字符串 id 时,该行判失败并写出实际返回的值;事件类型不是字符串时不代表任何结果,turn 行判失败。页面的 data 不是列表或缺失时,对应的行同样判失败;Session id 无法作为路由中的单独一段时(空串、`.` 或 `..`),或根本无法放进路由时(含孤立代理项,没有可做百分号编码的 UTF-8),也是如此。只有标记为 `terminal: true`Line truncated
### 备份是否仍然有效 ### 备份是否仍然有效
@@ -228,7 +228,7 @@ Each phase reads the units, their environment files and the installed release ag
- `check` and `backup` take the Runtime state directory from the server's own configuration. They run the new release's server jar as the control-plane unit runs the server: with its account, working directory and environment, and the launcher's default `config/application.yml` when `SPRING_CONFIG_ADDITIONAL_LOCATION` is unset or empty. The jar prepares Spring's environment the way the server does, so every configuration file, import and profile counts, as do `SPRING_APPLICATION_JSON`, system properties from `JAVA_TOOL_OPTIONS` and relaxed names, and a relative directory is resolved in the unit's working directory. It starts nothing and opens no connection. When it cannot resolve the configuration, or the unit passes the launcher arguments, which `systemctl` does not print exactly, `backup` refuses until `--runtime-state <directory>` names the directory the server uses, or `--runtime-state none` says it keeps none. Without a configured directory, as on a deployment without the tool path, `backup` archives no Runtime state and says so. - `check` and `backup` take the Runtime state directory from the server's own configuration. They run the new release's server jar as the control-plane unit runs the server: with its account, working directory and environment, and the launcher's default `config/application.yml` when `SPRING_CONFIG_ADDITIONAL_LOCATION` is unset or empty. The jar prepares Spring's environment the way the server does, so every configuration file, import and profile counts, as do `SPRING_APPLICATION_JSON`, system properties from `JAVA_TOOL_OPTIONS` and relaxed names, and a relative directory is resolved in the unit's working directory. It starts nothing and opens no connection. When it cannot resolve the configuration, or the unit passes the launcher arguments, which `systemctl` does not print exactly, `backup` refuses until `--runtime-state <directory>` names the directory the server uses, or `--runtime-state none` says it keeps none. Without a configured directory, as on a deployment without the tool path, `backup` archives no Runtime state and says so.
- `install --backup <directory>` refuses a backup of another release, a backup file that changed, a backup the control plane has started since, and a backup of another database than the unit's `SPRING_DATASOURCE_URL` names. It copies the release to `<distribution>-releases/<version>`, such as `/opt/qwen-managed-agent-releases/<version>` (`--releases` names another directory; pass the same one to every phase), copies operator files from the installed `config/` with their owner and mode, and points the distribution path at the new release with one rename. The copy's own files, those `SHA256SUMS` lists, are made readable by every account, so the service account can run a release extracted with a restrictive umask. The first run moves the installed directory to `<releases>/<old version>` and puts a link in its place, so the units keep their paths. The previous release stays where it is. An existing `<releases>/<version>`, such as the archive extracted there, is used only when its `SHA256SUMS` is the new distribution's and its files match it, and its listed files are made readable the same way; a copy that fails is removed. A crash between the first run's two renames leaves `<distribution>.upgrade-<suffix>` beside the missing distribution path; every phase then names the `mv -T` that finishes the install. An install killed at another step can leave a partial copy `.<version>.partial-<suffix>` in the releases directory, an `<distribution>.upgrade-<suffix>` link beside the distribution path, or an operator file's `.tmp-<suffix>` copy in the new release's `config/`; the `layout` row lists them, and the next `install` removes them. The suffix is the eight hexadecimal digits `install` generates, so an operator's own `<distribution>.upgrade-backup` is left alone. Run again once the release is installed, as after an interrupted session, `install` says so and points at `start`. - `install --backup <directory>` refuses a backup of another release, a backup file that changed, a backup the control plane has started since, and a backup of another database than the unit's `SPRING_DATASOURCE_URL` names. It copies the release to `<distribution>-releases/<version>`, such as `/opt/qwen-managed-agent-releases/<version>` (`--releases` names another directory; pass the same one to every phase), copies operator files from the installed `config/` with their owner and mode, and points the distribution path at the new release with one rename. The copy's own files, those `SHA256SUMS` lists, are made readable by every account, so the service account can run a release extracted with a restrictive umask. The first run moves the installed directory to `<releases>/<old version>` and puts a link in its place, so the units keep their paths. The previous release stays where it is. An existing `<releases>/<version>`, such as the archive extracted there, is used only when its `SHA256SUMS` is the new distribution's and its files match it, and its listed files are made readable the same way; a copy that fails is removed. A crash between the first run's two renames leaves `<distribution>.upgrade-<suffix>` beside the missing distribution path; every phase then names the `mv -T` that finishes the install. An install killed at another step can leave a partial copy `.<version>.partial-<suffix>` in the releases directory, an `<distribution>.upgrade-<suffix>` link beside the distribution path, or an operator file's `.tmp-<suffix>` copy in the new release's `config/`; the `layout` row lists them, and the next `install` removes them. The suffix is the eight hexadecimal digits `install` generates, so an operator's own `<distribution>.upgrade-backup` is left alone. Run again once the release is installed, as after an interrupted session, `install` says so and points at `start`.
- `start` refuses before `install`, and while the Harness Broker settings still need step 5 below. It starts the Harness, or restarts one that an earlier `start` left running so that it reads what was fixed since, runs `bin/qwen-managed-agent-server --check` as the service account with the unit's environment, starts the control plane only when that passes, and waits for a healthy actuator (`--timeout`, 300 seconds by default; one probe waits at most 5 seconds). Flyway migrates the schema during this start. A control plane that exits before it is healthy is stopped and reported, even one that systemd's `Restart=on-failure` would bring up when it runs it again: an exit during the start that migrates the schema is worth reading in the journal before `start` runs again. One that is still not healthy at the timeout keeps running; read its journal, then run `verify`, or `stop` and `start` again. - `start` refuses before `install`, and while the Harness Broker settings still need step 5 below. It starts the Harness, or restarts one that an earlier `start` left running so that it reads what was fixed since, runs `bin/qwen-managed-agent-server --check` as the service account with the unit's environment, starts the control plane only when that passes, and waits for a healthy actuator (`--timeout`, 300 seconds by default; one probe waits at most 5 seconds). Flyway migrates the schema during this start. A control plane that exits before it is healthy is stopped and reported, even one that systemd's `Restart=on-failure` would bring up when it runs it again: an exit during the start that migrates the schema is worth reading in the journal before `start` runs again. One that is still not healthy at the timeout keeps running; read its journal, then run `verify`, or `stop` and `start` again.
- `start` and `verify` find the Harness, the control plane and its health endpoint the same way, so a port or address set where `server.env` does not show it counts: in a configuration file, on the command line or in `SPRING_APPLICATION_JSON`. The Harness is where `qwen.managed-agent.harness.base-url` says. The control plane is where `server.port`, `server.address` and `server.ssl` bind it, with a wildcard address reached on the loopback interface, and the health endpoint follows Spring Boot's management settings: a `management.server.port` of its own moves it to that port, on `management.server.address` and under `management.server.base-path`. `--server-url <url>` names the control plane instead, for example behind a proxy, and the health endpoint is then `<url>/actuator/health`. A phase checks only the URLs it dials: both probe the health endpoint, and `verify --tenant` also reads the API when the auth mode is `open` and, with `--actor`, the server names a trusted actor header. A port of 0 or below gives no URL to wait on, so the phase refuses until `--server-url` names one; it also refuses a URL `fetch` refuses to dial: a link-local IPv6 address with its zone, a port the Fetch standard blocks (6000, for one), or a `--server-url` without `http://` or `https://`, with a user name or password, which the refusal prints as `***` up to the last `@`, with a query or a fragment, which the paths appended to it would land in, or with any other `@`, the rest of a password that starts with digits, and `check` judges `--server-url` the same way; `start` likewise refuses a Harness URL that is not `http` or `https`, and, while the Harness is enabled, one the server's Harness connector would refuse: anything but an `http` or `https` URL with a host and without credentials, a query or a fragment, as the server reads it with `java.net.URI`, so a host name with an underscore is refused too; when the configuration does not resolve, `start` takes only a plain `http` or `https` URL, a host and port with a path, and leaves the rest to the preflight. When the health endpoint does not report UP, `start` and `verify` say what it answered last; for a certificate this host does not trust, point `NODE_EXTRA_CA_CERTS` at its CA when you run the command. When the configuration does not resolve, or the unit passes the launcher arguments, they use the URLs `server.env` names and print a line that says so. - `start` and `verify` find the Harness, the control plane and its health endpoint the same way, so a port or address set where `server.env` does not show it counts: in a configuration file, on the command line or in `SPRING_APPLICATION_JSON`. The Harness is where `qwen.managed-agent.harness.base-url` says. The control plane is where `server.port`, `server.address` and `server.ssl` bind it, with a wildcard address reached on the loopback interface, and the health endpoint follows Spring Boot's management settings: a `management.server.port` of its own moves it to that port, on `management.server.address` and under `management.server.base-path`. `--server-url <url>` names the control plane instead, for example behind a proxy, and the health endpoint is then `<url>/actuator/health`. A phase checks only the URLs it dials: both probe the health endpoint, and `verify --tenant` also reads the API when the auth mode is `open` and, with `--actor`, the server names a trusted actor header. A port of 0 or below gives no URL to wait on, so the phase refuses until `--server-url` names one; it also refuses a URL `fetch` refuses to dial: a link-local IPv6 address with its zone, a port the Fetch standard blocks (6000, for one), or a `--server-url` without `http://` or `https://`, with a user name or password, which the refusal prints as `***` up to the last `@`, or with a query or a fragment, which the paths appended to it would land in; `check` judges `--server-url` the same way, and an `@` in its path stays valid path text, with printed copies masked up to the last `@` and their control characters replaced; `start` likewise refuses a Harness URL that is not `http` or `https`, and, while the Harness is enabled, one the server's Harness connector would refuse: anything but an `http` or `https` URL with a host and without credentials, a query or a fragment, as the server reads it with `java.net.URI`, so a host name with an underscore is refused too; when the configuration does not resolve, `start` takes only a plain `http` or `https` URL, a host and port with a path, and leaves the rest to the preflight. When the health endpoint does not report UP, `start` and `verify` say what it answered last; for a certificate this host does not trust, point `NODE_EXTRA_CA_CERTS` at its CA when you run the command. When the configuration does not resolve, or the unit passes the launcher arguments, they use the URLs `server.env` names and print a line that says so.
- `verify` runs `--check --online` the same way and reads the actuator, waiting up to `--timeout` (300 seconds by default) for its answer. With `--tenant <id>`, it reads the tenant's most recent Session and its Items, before `--turn` creates one; `--turn` creates a Session and waits for one Turn, which calls the model, polling past a `5xx` answer or a dropped connection; when the Session read is skipped, the `turn` row says so. No request outlasts `--timeout`. A request without an actor lists only Sessions outside Workspaces; `--actor <id>` sends one through the header `QWEN_MANAGED_AGENT_TRUSTED_ACTOR_HEADER` names. `verify` reads the auth mode the way `backup` reads the Runtime state directory, and signed authentication skips both reads; read a Session through the ingress instead. - `verify` runs `--check --online` the same way and reads the actuator, waiting up to `--timeout` (300 seconds by default) for its answer. With `--tenant <id>`, it reads the tenant's most recent Session and its Items, before `--turn` creates one; `--turn` creates a Session and waits for one Turn, which calls the model, polling past a `5xx` answer or a dropped connection; when the Session read is skipped, the `turn` row says so. No request outlasts `--timeout`. A request without an actor lists only Sessions outside Workspaces; `--actor <id>` sends one through the header `QWEN_MANAGED_AGENT_TRUSTED_ACTOR_HEADER` names. `verify` reads the auth mode the way `backup` reads the Runtime state directory, and signed authentication skips both reads; read a Session through the ingress instead.
The command starts and stops units only. It never stops ingress, installs Qwen Code, edits environment or unit files, or deletes a release. When `check` warns that a unit template changed, carry the change over to the installed unit and run `systemctl daemon-reload` before `start`. The command starts and stops units only. It never stops ingress, installs Qwen Code, edits environment or unit files, or deletes a release. When `check` warns that a unit template changed, carry the change over to the installed unit and run `systemctl daemon-reload` before `start`.
@@ -228,7 +228,7 @@ bin/qwen-managed-agent-upgrade verify --tenant <tenant> --turn
- `check` 和 `backup` 从服务端自己的配置中取得 Runtime 状态目录。它们按控制面 unit 运行服务端的方式运行新版本的服务端 jar:使用该 unit 的账号、工作目录和环境;`SPRING_CONFIG_ADDITIONAL_LOCATION` 未设置或为空时,与 launcher 一样默认使用 `config/application.yml`。该 jar 按服务端的方式准备 Spring 环境,因此所有配置文件、import 和 profile 都会生效,`SPRING_APPLICATION_JSON`、`JAVA_TOOL_OPTIONS` 中的系统属性和 relaxed binding 形式的名称也一样;相对路径的目录按 unit 的工作目录解析。它不启动任何组件,也不建立任何连接。如果无法解析配置,或者 unit 给 launcher 传了参数(`systemctl` 打印的参数无法准确还原),`backup` 会拒绝执行,直到用 `--runtime-state <目录>` 指明服务端实际使用的目录,或用 `--runtime-state none` 说明不保存 Runtime 状态。没有配置状态目录时(例如未开启工具路径的部署),`backup` 不归档 Runtime 状态,并明确说明。 - `check` 和 `backup` 从服务端自己的配置中取得 Runtime 状态目录。它们按控制面 unit 运行服务端的方式运行新版本的服务端 jar:使用该 unit 的账号、工作目录和环境;`SPRING_CONFIG_ADDITIONAL_LOCATION` 未设置或为空时,与 launcher 一样默认使用 `config/application.yml`。该 jar 按服务端的方式准备 Spring 环境,因此所有配置文件、import 和 profile 都会生效,`SPRING_APPLICATION_JSON`、`JAVA_TOOL_OPTIONS` 中的系统属性和 relaxed binding 形式的名称也一样;相对路径的目录按 unit 的工作目录解析。它不启动任何组件,也不建立任何连接。如果无法解析配置,或者 unit 给 launcher 传了参数(`systemctl` 打印的参数无法准确还原),`backup` 会拒绝执行,直到用 `--runtime-state <目录>` 指明服务端实际使用的目录,或用 `--runtime-state none` 说明不保存 Runtime 状态。没有配置状态目录时(例如未开启工具路径的部署),`backup` 不归档 Runtime 状态,并明确说明。
- `install --backup <目录>` 拒绝其他版本的备份、内容有变化的备份文件、控制面在备份之后又启动过的备份,以及所导出的数据库与 unit 的 `SPRING_DATASOURCE_URL` 不同的备份。它把新版本复制到 `<分发路径>-releases/<version>`,例如 `/opt/qwen-managed-agent-releases/<version>`(用 `--releases` 指定其他目录,并在每个阶段都传同一个),把已安装版本 `config/` 中的运维文件连同属主和权限复制过去,再用一次 rename 把分发路径指向新版本。副本中属于版本自身的文件(即 `SHA256SUMS` 列出的文件)会设为所有账号可读,所以即使解压时用了严格的 umask,服务账号也能运行该版本。第一次运行会把已安装的目录移到 `<releases>/<旧版本>`,并在原位置放一个链接,因此 unit 中的路径不变。旧版本保留在原处。已存在的 `<releases>/<version>`(例如直接解压到那里的发行包)只有在其 `SHA256SUMS` 与新分发产物相同、且文件与之相符时才会被使用,其中列出的文件同样会设为所有账号可读;复制失败时会删除复制出的内容。如果第一次运行在两次 rename 之间崩溃,分发路径会缺失,旁边留下 `<分发路径>.upgrade-<后缀>`;此后每个阶段都会给出完成安装所需的 `mv -T` 命令。在其他步骤被杀掉的 install,可能在版本目录里留下部分副本 `.<version>.partial-<后缀>`,在分发路径旁留下 `<分发路径>.upgrade-<后缀>` 链接,或在新版本的 `config/` 里留下运维文件的 `.tmp-<后缀>` 副本;`layout` 行会列出它们,下一次 `install` 会删除它们。后缀是 `install` 生成的 8 位十六进制数,因此运维自己的 `<分发路径>.upgrade-backup` 之类不会被删除。版本安装完成后再次运行(例如会话中断后),`install` 会说明已经安装并提示运行 `start`。 - `install --backup <目录>` 拒绝其他版本的备份、内容有变化的备份文件、控制面在备份之后又启动过的备份,以及所导出的数据库与 unit 的 `SPRING_DATASOURCE_URL` 不同的备份。它把新版本复制到 `<分发路径>-releases/<version>`,例如 `/opt/qwen-managed-agent-releases/<version>`(用 `--releases` 指定其他目录,并在每个阶段都传同一个),把已安装版本 `config/` 中的运维文件连同属主和权限复制过去,再用一次 rename 把分发路径指向新版本。副本中属于版本自身的文件(即 `SHA256SUMS` 列出的文件)会设为所有账号可读,所以即使解压时用了严格的 umask,服务账号也能运行该版本。第一次运行会把已安装的目录移到 `<releases>/<旧版本>`,并在原位置放一个链接,因此 unit 中的路径不变。旧版本保留在原处。已存在的 `<releases>/<version>`(例如直接解压到那里的发行包)只有在其 `SHA256SUMS` 与新分发产物相同、且文件与之相符时才会被使用,其中列出的文件同样会设为所有账号可读;复制失败时会删除复制出的内容。如果第一次运行在两次 rename 之间崩溃,分发路径会缺失,旁边留下 `<分发路径>.upgrade-<后缀>`;此后每个阶段都会给出完成安装所需的 `mv -T` 命令。在其他步骤被杀掉的 install,可能在版本目录里留下部分副本 `.<version>.partial-<后缀>`,在分发路径旁留下 `<分发路径>.upgrade-<后缀>` 链接,或在新版本的 `config/` 里留下运维文件的 `.tmp-<后缀>` 副本;`layout` 行会列出它们,下一次 `install` 会删除它们。后缀是 `install` 生成的 8 位十六进制数,因此运维自己的 `<分发路径>.upgrade-backup` 之类不会被删除。版本安装完成后再次运行(例如会话中断后),`install` 会说明已经安装并提示运行 `start`。
- `install` 之前,以及 Harness 的 Broker 配置仍需按下文第 5 步修改时,`start` 会拒绝执行。它先启动 Harness(如果之前某次 `start` 留下了正在运行的 Harness,则重启它,以读入此后修改过的内容),再以服务账号、按 unit 的环境运行 `bin/qwen-managed-agent-server --check`,通过后才启动控制面,并等待 actuator 报告健康(`--timeout`,默认 300 秒;单次探测最多等待 5 秒)。Flyway 在这次启动中迁移 schema。控制面在健康之前退出时会被停止并报告,即使 systemd 的 `Restart=on-failure` 再次运行它就能起来也是如此:迁移 schema 的这次启动中出现了退出,应先查看日志,再重新运行 `start`;超时仍未健康时控制面保持运行,请查看其日志,然后运行 `verify`,或先 `stop` 再重新 `start`。 - `install` 之前,以及 Harness 的 Broker 配置仍需按下文第 5 步修改时,`start` 会拒绝执行。它先启动 Harness(如果之前某次 `start` 留下了正在运行的 Harness,则重启它,以读入此后修改过的内容),再以服务账号、按 unit 的环境运行 `bin/qwen-managed-agent-server --check`,通过后才启动控制面,并等待 actuator 报告健康(`--timeout`,默认 300 秒;单次探测最多等待 5 秒)。Flyway 在这次启动中迁移 schema。控制面在健康之前退出时会被停止并报告,即使 systemd 的 `Restart=on-failure` 再次运行它就能起来也是如此:迁移 schema 的这次启动中出现了退出,应先查看日志,再重新运行 `start`;超时仍未健康时控制面保持运行,请查看其日志,然后运行 `verify`,或先 `stop` 再重新 `start`。
- `start` 和 `verify` 以同样的方式确定 Harness、控制面及其健康端点的位置,因此在 `server.env` 看不到的地方设置的端口或地址也会生效:配置文件、命令行或 `SPRING_APPLICATION_JSON`。Harness 的位置由 `qwen.managed-agent.harness.base-url` 决定。控制面的位置由 `server.port`、`server.address` 和 `server.ssl` 决定,通配地址经回环接口访问;健康端点遵循 Spring Boot 的 management 设置:单独设置的 `management.server.port` 会把它移到该端口,绑定 `management.server.address`,并位于 `management.server.base-path` 之下。`--server-url <url>` 可改为指定控制面的地址,例如位于代理之后,此时健康端点为 `<url>/actuator/health`。每个阶段只检查自己会拨号的 URL:两个阶段都会探测健康端点,auth mode 为 `open`、且给了 `--actor` 时服务端配置了可信 actor header,`verify --tenant` 还会读取 API。端口为 0 或负数时没有可等待的 URL,因此该阶段会拒绝执行,直到用 `--server-url` 指定一个;`fetch` 拒绝拨号的 URL 同样会被拒绝:带 zone 的 IPv6 链路本地地址、Fetch 标准屏蔽的端口(例如 6000),以及不以 `http://` 或 `https://` 开头、带用户名或密码(拒绝提示中最后一个 `@` 之前显示为 `***`)、带 query 或 fragment(拼接在后面的路径会落进去)、或带有其他 `@`(以数字开头的密码的剩余部分)的 `--server-url`,`check` 也以同样方式判定 `--server-url`;同理,`start` 也会拒绝不是 `http` 或 `https` 的 Harness URL;Harness 已启用时,还会拒绝服务端 Harness 连接器不接受的取值,即不是带主机名的 `http` 或 `https` URL,或带凭据、query 或 fragment 的 URL;判定方式与服务端用 `java.net.URI` 读取时一致,所以带下划线的主机名同样会被拒绝;无法解析配置时,`start` 只接受朴素的 `http` 或 `https` URL(主机和端口加路径),其余交给 preflight 判定。健康端点没有报告 UP 时,`start` 和 `verify` 会给出它最后一次的应答;证书不受本机信任时,运行命令时用 `NODE_EXTRA_CA_CERTS` 指向其 CA。无法解析配置,或者 unit 给 launcher 传了参数时,它们使用 `server.env` 中给出的 URL,并打印一行说明。 - `start` 和 `verify` 以同样的方式确定 Harness、控制面及其健康端点的位置,因此在 `server.env` 看不到的地方设置的端口或地址也会生效:配置文件、命令行或 `SPRING_APPLICATION_JSON`。Harness 的位置由 `qwen.managed-agent.harness.base-url` 决定。控制面的位置由 `server.port`、`server.address` 和 `server.ssl` 决定,通配地址经回环接口访问;健康端点遵循 Spring Boot 的 management 设置:单独设置的 `management.server.port` 会把它移到该端口,绑定 `management.server.address`,并位于 `management.server.base-path` 之下。`--server-url <url>` 可改为指定控制面的地址,例如位于代理之后,此时健康端点为 `<url>/actuator/health`。每个阶段只检查自己会拨号的 URL:两个阶段都会探测健康端点,auth mode 为 `open`、且给了 `--actor` 时服务端配置了可信 actor header,`verify --tenant` 还会读取 API。端口为 0 或负数时没有可等待的 URL,因此该阶段会拒绝执行,直到用 `--server-url` 指定一个;`fetch` 拒绝拨号的 URL 同样会被拒绝:带 zone 的 IPv6 链路本地地址、Fetch 标准屏蔽的端口(例如 6000),以及不以 `http://` 或 `https://` 开头、带用户名或密码(拒绝提示中最后一个 `@` 之前显示为 `***`)、带 query 或 fragment(拼接在后面的路径会落进去)的 `--server-url`;`check` 也以同样方式判定 `--server-url`,其路径中的 `@` 是合法的路径文本,打印时仍将最后一个 `@` 之前显示为 `***` 并替换控制字符;同理,`start` 也会拒绝不是 `http` 或 `https` 的 Harness URL;Harness 已启用时,还会拒绝服务端 Harness 连接器不接受的取值,即不是带主机名的 `http` 或 `https` URL,或带凭据、query 或 fragment 的 URL;判定方式与服务端用 `java.net.URI` 读取时一致,所以带下划线的主机名同样会被拒绝;无法解析配置时,`start` 只接受朴素的 `http` 或 `https` URL(主机和端口加路径),其余交给 preflight 判定。健康端点没有报告 UP 时,`start` 和 `verify` 会给出它最后一次的应答;证书不受本机信任时,运行命令时用 `NODE_EXTRA_CA_CERTS` 指向其 CA。无法解析配置,或者 unit 给 launcher 传了参数时,它们使用 `server.env` 中给出的 URL,并打印一行说明。
- `verify` 以同样方式运行 `--check --online` 并读取 actuator,最多等待 `--timeout`(默认 300 秒)让它应答。指定 `--tenant <id>` 时,它在 `--turn` 创建 Session 之前读取该租户最近的 Session 及其 Item;`--turn` 会创建一个 Session 并等待一个 Turn 完成,这会调用模型,轮询遇到 `5xx` 应答或连接中断时会继续;跳过 Session 读取时,`turn` 行会说明这一点。任何请求都不会超过 `--timeout`。不带 actor 的请求只能列出 Workspace 之外的 Session;`--actor <id>` 通过 `QWEN_MANAGED_AGENT_TRUSTED_ACTOR_HEADER` 指定的请求头带上一个 actor。`verify` 按 `backup` 读取 Runtime 状态目录的方式读取认证模式;使用签名认证时两项读取都跳过,请通过入口读取 Session。 - `verify` 以同样方式运行 `--check --online` 并读取 actuator,最多等待 `--timeout`(默认 300 秒)让它应答。指定 `--tenant <id>` 时,它在 `--turn` 创建 Session 之前读取该租户最近的 Session 及其 Item;`--turn` 会创建一个 Session 并等待一个 Turn 完成,这会调用模型,轮询遇到 `5xx` 应答或连接中断时会继续;跳过 Session 读取时,`turn` 行会说明这一点。任何请求都不会超过 `--timeout`。不带 actor 的请求只能列出 Workspace 之外的 Session;`--actor <id>` 通过 `QWEN_MANAGED_AGENT_TRUSTED_ACTOR_HEADER` 指定的请求头带上一个 actor。`verify` 按 `backup` 读取 Runtime 状态目录的方式读取认证模式;使用签名认证时两项读取都跳过,请通过入口读取 Session。
该命令只启动和停止 unit,不会停止入口流量、安装 Qwen Code、修改环境文件或 unit 文件,也不会删除任何版本。`check` 提示 unit 模板有变化时,请在 `start` 之前把变化同步到已安装的 unit,并执行 `systemctl daemon-reload`。 该命令只启动和停止 unit,不会停止入口流量、安装 Qwen Code、修改环境文件或 unit 文件,也不会删除任何版本。`check` 提示 unit 模板有变化时,请在 `start` 之前把变化同步到已安装的 unit,并执行 `systemctl daemon-reload`。
@@ -106,8 +106,8 @@ Options:
--skip-database check, backup: the database is backed up another way --skip-database check, backup: the database is backed up another way
--runtime-state <dir> check, backup: the Runtime state directory, or none, --runtime-state <dir> check, backup: the Runtime state directory, or none,
instead of the one the server's configuration resolves to instead of the one the server's configuration resolves to
--server-url <url> start, verify: control-plane URL (default: the listener --server-url <url> check, start, verify: control-plane URL (default: the
the server's configuration resolves to) listener the server's configuration resolves to)
--timeout <seconds> stop, start, verify: how long to wait --timeout <seconds> stop, start, verify: how long to wait
--actor <id> verify: read Sessions as this actor, through the server's --actor <id> verify: read Sessions as this actor, through the server's
trusted actor header trusted actor header
@@ -533,8 +533,8 @@ export function fetchRefusal(url) {
} }
/** /**
* `url` as a refusal may print it: its user information, a password or a * `url` as a refusal or report row may print it: its user information, a
* token in the user-name position, shows as `***`. * password or a token in the user-name position, shows as `***`.
*/ */
export function withoutCredentials(url) { export function withoutCredentials(url) {
let shown = url; let shown = url;
@@ -552,8 +552,8 @@ export function withoutCredentials(url) {
// Such a value does not parse, unless the password starts with digits, as // Such a value does not parse, unless the password starts with digits, as
// in http://svc:2024#Secret@harness:4170, which the parser and // in http://svc:2024#Secret@harness:4170, which the parser and
// java.net.URI both read as port 2024 with the rest of the password in // java.net.URI both read as port 2024 with the rest of the password in
// the fragment. So everything up to the last @ shows as ***, which only // the fragment. So everything up to the last @ shows as ***, including in
// ever hides more of a value that is refused anyway. // a path that the command dials: the text alone cannot distinguish them.
return shown.replace( return shown.replace(
/^([a-zA-Z][a-zA-Z0-9+.-]*:(?:\/\/)?|\/\/)?[\s\S]*@/, /^([a-zA-Z][a-zA-Z0-9+.-]*:(?:\/\/)?|\/\/)?[\s\S]*@/,
'$1***@', '$1***@',
@@ -1812,8 +1812,8 @@ function planFlags(options, names) {
options.mysqldump !== 'mysqldump' ? options.mysqldump : undefined, options.mysqldump !== 'mysqldump' ? options.mysqldump : undefined,
'skip-database': options.skipDatabase || undefined, 'skip-database': options.skipDatabase || undefined,
'runtime-state': options.runtimeState, 'runtime-state': options.runtimeState,
// check has refused a --server-url that would print a password. // The plan masks credentials and control bytes like every other URL row.
'server-url': options.serverUrl, 'server-url': options.serverUrl && shown(options.serverUrl),
}; };
return names return names
.filter((name) => flags[name] !== undefined) .filter((name) => flags[name] !== undefined)
@@ -2666,19 +2666,15 @@ async function listeners(
* information, so a ? or # starts a query or a fragment, which would take in * information, so a ? or # starts a query or a fragment, which would take in
* the paths appended to the URL: http://proxy.test/?a probes * the paths appended to the URL: http://proxy.test/?a probes
* /?a/actuator/health, and fetch drops a fragment with the path after it. * /?a/actuator/health, and fetch drops a fragment with the path after it.
* An @ left after that is the rest of a password that starts with digits, * An @ in the path remains valid path text; every printed URL masks up to it.
* as in https://svc:12/[email protected], which the parser reads as
* host svc and port 12: dialling it would reach the wrong host, and naming
* it would print the password.
*/ */
function refuseServerUrl(serverUrl) { function refuseServerUrl(serverUrl) {
const reason = const reason =
fetchRefusal(serverUrl) ??
fetchRefusal(`${serverUrl}/actuator/health`) ?? fetchRefusal(`${serverUrl}/actuator/health`) ??
(/[?#]/.test(serverUrl) (/[?#]/.test(serverUrl)
? 'carries a query or a fragment, where the /actuator/health and API paths appended to it would land' ? 'carries a query or a fragment, where the /actuator/health and API paths appended to it would land'
: serverUrl.includes('@') : undefined);
? 'carries an @, as a user name or password would before its host'
: undefined);
if (reason) { if (reason) {
throw new Refusal( throw new Refusal(
`--server-url ${quoted(withoutCredentials(serverUrl))} ${reason}.`, `--server-url ${quoted(withoutCredentials(serverUrl))} ${reason}.`,
@@ -2849,7 +2845,7 @@ function harnessUrlReason(text, url) {
// URI path may, untrimmed. It is narrower than the server's rule, never // URI path may, untrimmed. It is narrower than the server's rule, never
// wider. // wider.
const PLAIN_HTTP_URL = const PLAIN_HTTP_URL =
/^https?:\/\/(?:[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?|\[[0-9A-Fa-f:.]+\])(?::[0-9]{1,5})?(?:\/[A-Za-z0-9\-._~!$&'()*+,;=:@%/]*)?$/i; /^https?:\/\/(?:[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?|\[[0-9A-Fa-f:.]+\])(?::[0-9]{1,5})?(?:\/(?:%[0-9A-Fa-f]{2}|[A-Za-z0-9\-._~!$&'()*+,;=:@/])*)?$/i;
async function phaseStart(system, deployment, options) { async function phaseStart(system, deployment, options) {
refuseUnless(deployment); refuseUnless(deployment);
+64 -30
View File
@@ -2558,33 +2558,46 @@ test('start and verify reach the listeners the server resolves', async (t) => {
]); ]);
// --server-url names the control plane, and the Harness stays where the // --server-url names the control plane, and the Harness stays where the
// configuration puts it. // configuration puts it. An @ in its path is path text, not user information.
await host.stop(); await host.stop();
host.connections = []; host.connections = [];
host.requests = []; host.requests = [];
host.responses['GET /actuator/health'] = { status: 'UP' }; host.responses['GET /prefix@acme/actuator/health'] = { status: 'UP' };
assert.equal( assert.equal(
await host.upgrade('start', '--server-url', 'http://proxy.test:8443/'), await host.upgrade(
'start',
'--server-url',
'http://proxy.test:8443/prefix@acme/',
),
0, 0,
); );
assert.deepEqual([...new Set(host.connections)], ['127.0.0.1:4171']); assert.deepEqual([...new Set(host.connections)], ['127.0.0.1:4171']);
assert.deepEqual(urls(), ['http://proxy.test:8443/actuator/health']); assert.deepEqual(urls(), [
'http://proxy.test:8443/prefix@acme/actuator/health',
]);
host.requests = []; host.requests = [];
host.responses['GET /prefix@acme/v1/agents/sessions'] = {
data: [{ id: 's1' }],
};
host.responses['GET /prefix@acme/v1/agents/sessions/s1'] = { id: 's1' };
host.responses['GET /prefix@acme/v1/agents/sessions/s1/items'] = {
data: [],
};
assert.equal( assert.equal(
await host.upgrade( await host.upgrade(
'verify', 'verify',
'--tenant', '--tenant',
'acme', 'acme',
'--server-url', '--server-url',
'http://proxy.test:8443', 'http://proxy.test:8443/prefix@acme',
), ),
0, 0,
); );
assert.deepEqual(urls(), [ assert.deepEqual(urls(), [
'http://proxy.test:8443/actuator/health', 'http://proxy.test:8443/prefix@acme/actuator/health',
'http://proxy.test:8443/v1/agents/sessions?limit=1', 'http://proxy.test:8443/prefix@acme/v1/agents/sessions?limit=1',
'http://proxy.test:8443/v1/agents/sessions/s1', 'http://proxy.test:8443/prefix@acme/v1/agents/sessions/s1',
'http://proxy.test:8443/v1/agents/sessions/s1/items?limit=20', 'http://proxy.test:8443/prefix@acme/v1/agents/sessions/s1/items?limit=20',
]); ]);
}); });
@@ -2672,6 +2685,7 @@ test('start and verify use server.env when the configuration does not resolve',
for (const harnessUrl of [ for (const harnessUrl of [
'http://127.0.0.1:4172/@acme/harness', 'http://127.0.0.1:4172/@acme/harness',
'http://gateway.example.com/@acme/harness', 'http://gateway.example.com/@acme/harness',
'http://127.0.0.1:4172/a%20b',
]) { ]) {
await writeFile( await writeFile(
env, env,
@@ -2855,8 +2869,9 @@ test('start refuses listeners it cannot reach before it starts anything', async
'http:/\t/\[email protected]:4170', 'http:/\t/\[email protected]:4170',
'http://h\\@x', 'http://h\\@x',
'http:/@127.0.0.1:4170', 'http:/@127.0.0.1:4170',
// A space java.net.URI refuses in a path. // A space and a malformed percent escape java.net.URI refuses in a path.
'http://127.0.0.1:4170/a b', 'http://127.0.0.1:4170/a b',
'http://127.0.0.1:4170/a%zz',
]) { ]) {
host.resolved = { harnessBaseUrl, harnessOrigin: undefined }; host.resolved = { harnessBaseUrl, harnessOrigin: undefined };
assert.equal( assert.equal(
@@ -3325,11 +3340,12 @@ test('no report prints a password a URL carries', async (t) => {
rowsOf(host.output).health, rowsOf(host.output).health,
'FAIL http://***@b/actuator/health is not UP: HTTP 200, status DOWN', 'FAIL http://***@b/actuator/health is not UP: HTTP 200, status DOWN',
); );
// A --server-url with an @ left after the URL parser has read no user // This digit-led password shape is also a valid URL with an @ in its path.
// information is the rest of a password that starts with digits: the // The phases still use it, but no report exposes the text before the @.
// parser reads host svc, port 12, so the phase would dial the wrong host
// and print the password. Each phase refuses it before it dials.
host.resolved = {}; host.resolved = {};
host.responses['GET /[email protected]:8443/actuator/health'] = {
status: 'UP',
};
for (const phase of ['verify', 'start']) { for (const phase of ['verify', 'start']) {
if (phase === 'start') { if (phase === 'start') {
await host.upgrade('stop'); await host.upgrade('stop');
@@ -3340,33 +3356,40 @@ test('no report prints a password a URL carries', async (t) => {
'--server-url', '--server-url',
'https://svc:12/[email protected]:8443', 'https://svc:12/[email protected]:8443',
), ),
1, 0,
phase,
); );
assert.equal( assert.ok(
host.output.at(-1), host.requests.some(
`${phase} refused: --server-url "https://***@cp.example.com:8443" carries an @, as a user name or password would before its host.`, ({ url }) =>
url === 'https://svc:12/[email protected]:8443/actuator/health',
),
phase,
);
assert.ok(
host.output.some((line) =>
line.includes('https://***@cp.example.com:8443/actuator/health'),
),
phase,
); );
assert.ok(!host.output.join('\n').includes('s3cret'), phase); assert.ok(!host.output.join('\n').includes('s3cret'), phase);
assert.equal(
host.requests.filter((request) => request.url.includes('svc')).length,
0,
);
} }
}); });
test('check judges the --server-url its plan repeats', async (t) => { test('check judges the --server-url its plan repeats', async (t) => {
const host = await rig(t); const host = await rig(t);
assert.equal(await host.upgrade('--help'), 0);
assert.match(
host.output.join('\n'),
/--server-url <url>\s+check, start, verify: control-plane URL/,
);
for (const [serverUrl, shown, reason] of [ for (const [serverUrl, shown, reason] of [
[ [
'https://ops:[email protected]', 'https://ops:[email protected]',
'https://***@cp.example.com/', 'https://***@cp.example.com/',
'carries a user name or password, so fetch refuses it', 'carries a user name or password, so fetch refuses it',
], ],
[ ['https://', 'https:', 'is not a URL'],
'https://svc:12/[email protected]',
'https://***@cp.example.com',
'carries an @, as a user name or password would before its host',
],
]) { ]) {
assert.equal(await host.check('--server-url', serverUrl), 1); assert.equal(await host.check('--server-url', serverUrl), 1);
assert.equal( assert.equal(
@@ -3375,14 +3398,25 @@ test('check judges the --server-url its plan repeats', async (t) => {
); );
assert.ok(!host.output.join('\n').includes('s3cret'), serverUrl); assert.ok(!host.output.join('\n').includes('s3cret'), serverUrl);
} }
for (const [serverUrl, planWord] of [
['https://cp.example.com:8443', 'https://cp.example.com:8443'],
['https://svc:12/[email protected]', "'https://***@cp.example.com'"],
]) {
assert.equal(await host.check('--server-url', serverUrl), 0);
const plan = host.output.slice(host.output.indexOf('Plan:')).join('\n');
assert.ok(plan.includes(` start --server-url ${planWord}\n`), plan);
assert.ok(!host.output.join('\n').includes('s3cret'), serverUrl);
}
assert.equal( assert.equal(
await host.check('--server-url', 'https://cp.example.com:8443'), await host.check('--server-url', 'https://cp.example.com/x\u001b[2K'),
0, 0,
); );
const plan = host.output.slice(host.output.indexOf('Plan:')).join('\n'); const plan = host.output.slice(host.output.indexOf('Plan:')).join('\n');
assert.ok(plan.includes('https://cp.example.com/x\uFFFD[2K'), plan);
assert.ok( assert.ok(
plan.includes(' start --server-url https://cp.example.com:8443\n'), // eslint-disable-next-line no-control-regex -- detecting surviving control bytes is the assertion
plan, !host.output.some((line) => /[\u0000-\u001F\u007F-\u009F]/.test(line)),
JSON.stringify(host.output),
); );
}); });