mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat: continue dashboard sessions from CLI URLs (#120893)
* feat(cli): ingest session targets * refactor(ui): remove gateway scope shim * docs(cli): document session targets * fix(cli): classify session target failures * fix(cli): keep session target result private * fix(cli): simplify timeout option warning * build: declare session URL contract dependency * fix(cli): parse bare session URL options symmetrically * fix(cli): preserve command-owned URL arguments * build: keep session URL contract build-only * fix: address session URL review findings * test: preserve session key mock exports * fix: keep session URL helpers internal * fix(tui): preserve URL agent for global sessions * fix(tui): keep URL agent input internal * fix(gateway): reconcile websocket protocol owner * fix(attach): preserve global session agent ownership * fix(attach): enforce global owner at grant boundary
This commit is contained in:
committed by
GitHub
parent
3bdfd60caa
commit
055a2dc6ce
@@ -3,8 +3,8 @@
|
||||
71522995185b956a0cc4927a472cc8d1153e5e998874bfd9a750513175174713 module/account-id
|
||||
d768139934447ff3ecf15470dc1fe613d36509fc5b93fc47ef09d20829cefa57 module/account-resolution
|
||||
4fbb1c87e99399f842a20d75d5e35a4b7064a1b7f02115c23f9a2a7cdcfb57ee module/agent-config-primitives
|
||||
66c80a448539e52493abe8bd0e6ca3152d2ff7318cf809fe16336f270d2cacdd module/agent-harness
|
||||
0f254f026d4968ab796096225dfe8ca94655a6ef2d0435eb3c3acba831c0b219 module/agent-harness-runtime
|
||||
0eeb85850d6056f815e231a7f0acbca6fb4115699407b823a9cf4437bf627e69 module/agent-harness
|
||||
e1754867b9fbdd210a21c3d00c3aa5f161517bd2ed10c1601f52e3f39714699a module/agent-harness-runtime
|
||||
d6097cfa1b410f4b5267a56a7bd19c2a33fbaf6642683dae6aec68e998e48f6c module/agent-media-payload
|
||||
3035368684499711a63540fbef74b4aae7d131073367a77a3179e3b9aef16ad8 module/agent-runtime
|
||||
b57a3cb274a9977c48c5387772df7707e50dde9a6132e532e7367a27225dc142 module/agent-scope-runtime
|
||||
@@ -26,7 +26,7 @@ afad33fdaada25984db53504dc6f665ff0478c12f68b79f36a39d28ceb13355e module/channel
|
||||
c2cc71d5070b6071c51248b0648d1ad1a9468d3737df890adc77ec02025e8853 module/channel-config-primitives
|
||||
a6cca5706f3aba6abb2178b175a0986d921ade98c2c05d2a54451e2fb7e16825 module/channel-config-schema
|
||||
37925e2b8c74b4444a14ea85b831ab569df9d46efebe89714527ee638719c100 module/channel-contract
|
||||
265ce5d0983e662e2d5b54b7254226a713463c020ccb96b87672409423e1b2a7 module/channel-core
|
||||
c6427c4fea5bd0a7574af77850445624a80e9988f7129af224a489297af041a3 module/channel-core
|
||||
f4a9870d37f3b4e824bc7b0f634e4eb868ae7dd4c5a9693f0105a6677c2ff5f9 module/channel-dm-policy
|
||||
3f78f6022fc2942f60bc07a554f57ef9f8763db773f10f6425fa940acdbe917a module/channel-entry-contract
|
||||
47cf8765e76c151ae7d2991d41beca62922a8837c1521e10a3fd23f9992c2d7c module/channel-feedback
|
||||
@@ -60,16 +60,16 @@ ab86235fcfff7c7cf0021fafeca6e92afe2c257ccf6a4a38441e0d41532998ff module/config-
|
||||
0d99f5cb8c4978ed760e5fb4e476543759fbd8fd5bf73cc50a50c1d550203826 module/config-mutation
|
||||
1c79d1356d7f41c22a0e85ff43766fc839cf13734435a3dcf7a4739512d513bf module/config-runtime
|
||||
d9e5f2ae27e29a40a6d6084c4d59e4811b4669235faa6f0d6a4fbfdd62e51ca3 module/conversation-runtime
|
||||
2b11c2f1fc406190b1b036316bd173a528c92bbc061b3da1a4e971cdd26c7c97 module/core
|
||||
5bebdf2011850e732ef1f71f5bc7669b7ebc3c4ece653f48c5ee0e3aa4ebc369 module/core
|
||||
2872c791a0b43fad1020a8f595e506d369bed6c57819733919858dddbd96fbaf module/dedupe-runtime
|
||||
ebef0e650ab45e44c9335e2b3e15588c968cea6dadd125364a076f9c50ad1e8c module/device-bootstrap
|
||||
21d86413166ef815581d606f678b6a216a1cc73ffe470b841f5bc4a131bff6df module/diagnostic-runtime
|
||||
2af0c3b8867148d3deaf0b6d112743a42b206014ed1b5a5cf4ef939f1976d420 module/directory-runtime
|
||||
b2fd699e080ec56989f0a8f21f4b93ae87842b70671b918d6733777c44c89b07 module/discord
|
||||
a589c6a22e936d596cdca5ce8d97ea091fd8ddd8d9df9407b57bbf1d683c8e82 module/discord
|
||||
39fe343ed2119de714757c365eef2ccec89c2c82a0876c60a4bef8ce469f8c8a module/error-runtime
|
||||
ce4f1602bf5b5de968ca97cede4f498b6ae709a0a59393b5ce9255cf0e6a9d5e module/extension-shared
|
||||
dd9f6e0fd33cc88b22543c1ee30cc09cf4de4d8f30dff7b7f9cebef885c21543 module/gateway-method-runtime
|
||||
49c009813a6dd34091d2c1c73eefe210af7926c8c490d87484550c668b3e20ac module/gateway-runtime
|
||||
c76bd967ef989feff27494df1e59aab2abef6e01aabf96c222b1ea8b153af1fc module/gateway-runtime
|
||||
1b1c6bd5bfc0cfb0c5bb9bd97f8ac1928750cb87232a3415dd066cc21d7b48b7 module/group-access
|
||||
4a9e9046f39dd661ef98ad9d37b0088eced2aacfe430fef2a93d4e009b297c3f module/health
|
||||
a35bf7d756fc781b860d131bac3710e4d3b2dd4403f7f98313899ddf80b564d3 module/hook-runtime
|
||||
@@ -89,7 +89,7 @@ f74d7295fe716aa140aa0bc9300d6259d71dab826de0808fca6bb02592bf5d6e module/media-m
|
||||
6a52f93107335f88751704352cc01e62add06f854a5b7d765e2a5ee87c0313b6 module/media-store
|
||||
b7e71516842300c041d2423d822da0080881db6ddc6b9b9ce38cabd8d546676b module/media-understanding
|
||||
a206a1486f6a6bed3091795b324e95dd710b4b3b87a4a8f18788e6158a48a922 module/media-understanding-runtime
|
||||
e78370b4f450b6b29751117c5cbd59f3ade9e6e7a12b754b3d805336d770ef69 module/meeting-runtime
|
||||
ffc96fca1e036592ed6b640c4917e4773a168b3d88aab1a11de4a4b4df84ce30 module/meeting-runtime
|
||||
3312468e2e8f3423b765fac6bb17944b800ea2c84acffeb64342c99040b2f482 module/memory-core-host-engine-foundation
|
||||
e3d2db75fab4b4a4d8a77f7db8fc2df2047678d0570385843c68a2478d2be4e5 module/memory-host-core
|
||||
1efa0aadc4261d1c6073058cbf3dcc9fa681424819bdd14333e19b249bbc4b18 module/messaging-targets
|
||||
@@ -103,7 +103,7 @@ b31f5d86904097993a55377fed7973cd298b0e1f49cade7e37a1e28f6e724108 module/plugin-
|
||||
29e893378475bf70f9265e4bb160860f4fb9783dbbd7d5849cde4d10d3450cb6 module/plugin-entry
|
||||
61364b898140f866d23aa282251eb2cbbcaf233dd2c966f0d47ea82e1b31d8dc module/plugin-runtime
|
||||
515ca993ecfdcb462f2e006816fdfc4dfd7752b30fda55b095bc1d443744a231 module/provider-auth
|
||||
03764516436e159b9352c00377856873d34c41b44ef650fd21653de7643d1861 module/provider-catalog-runtime
|
||||
a54b42a90c511494e32245f5f1477a65d7b5ca5aaa9993ddafe6132ae42be7c2 module/provider-catalog-runtime
|
||||
8131147d699394bd06503e2ea2f5f1a50b1594a87dded6d118b74a8d0328c8f6 module/proxy-capture
|
||||
4a698efc36d896c4702de8df831e36b06e85c82aa30bf86c9b1066e6cad4b700 module/question-gateway-runtime
|
||||
1171a76ea0485b36f77c9e12601c44a0e858043140e1d4669bb86d1f9e35930b module/reply-chunking
|
||||
@@ -133,7 +133,7 @@ fb0af0a51ba93e070d8862701c016375cf8576c47be1d25e20537ec86b0bcf73 module/setup
|
||||
8ec6ca8a40d4117c669fbd0f20241d54e7e6954d7cff8c5b4afc67ec9626e216 module/skill-commands-runtime
|
||||
0b09bb506ca970ee39b58953d9c1f0407942b2762f54d89a4cd82c35b9bc6c8b module/speech-settings
|
||||
0b3de0b219e431d26298c83688ffe53c262d58a336ca910e29619b5d0447266e module/ssrf-policy
|
||||
0be0326d5dfb54aea1cc12bd320de91553859aa055b8ec87c11e498812265ab8 module/ssrf-runtime
|
||||
6639ba57aacbb620a2f6ff802ecd94fc90b1969292e0306598546b5b4b9261cc module/ssrf-runtime
|
||||
3855f0a23281d21063762f5b3be2b7485c8bdb3b4cd1692de4f2a17d4eeae496 module/state-paths
|
||||
21fb493eaeb6e46441863c27a3838b52fc13494fff8f7c90c12b72a3d373b16b module/status-helpers
|
||||
f097d0096b21c8a052f0f649b7512ecf2aba4744ae6956f001950e053828b309 module/string-coerce-runtime
|
||||
@@ -145,7 +145,7 @@ d05a2db4a97844950ff3bc30f90f07e7f45600b1219e08a19264174b399817dc module/text-ru
|
||||
dc1a073c59ab61e2789533b777b3f0cb9af689d64a97796b10e8aa82552510db module/tool-results
|
||||
a5eef5a532c439b1489236711f4bc2721abe612a9db3911430e1344c239f9861 module/tool-send
|
||||
cda105b721d498df23a554c6b68be150b8fe66b8b9172185c31a0b3b0646b1dc module/web-media
|
||||
6bbbbcfea4f3e2acf483aac46d4ad0cda70820bdcfec3bac30a3efe06e6d1331 module/webhook-ingress
|
||||
60465458d5674539012dabb6bf7861783798fd80b8111948812f1f451d848fd0 module/webhook-ingress
|
||||
e3a199a9ce0b85d203e9e8a29b500db29c6b7af307e3145d0a311e29d598925b module/webhook-request-guards
|
||||
de59e86e126b75d13251cba7ebbe27b44d9b5588785d98df5ff4d6722374c81f module/widget-html
|
||||
9161b36ec0ab062ea41b363c894fcd672a7727f21cb726739f99f9c184fce69d module/zod
|
||||
|
||||
+32
-1
@@ -8,8 +8,19 @@ title: "Attach CLI"
|
||||
|
||||
`openclaw attach` launches Claude Code with a strict temporary MCP config bound to one Gateway session.
|
||||
|
||||
```sh
|
||||
openclaw attach [target]
|
||||
```
|
||||
|
||||
`target` accepts a Control UI session URL, a compact `host/agent/ref`, a bare
|
||||
short reference, or a literal `agent:...` session key. A URL or host target
|
||||
authoritatively selects that Gateway; a bare reference uses the configured or
|
||||
default Gateway.
|
||||
|
||||
```sh
|
||||
openclaw attach
|
||||
openclaw attach https://gateway.example/dashboard/main/movies-a1166b81
|
||||
openclaw attach movies-a1166b81
|
||||
openclaw attach --session agent:main:telegram:123 --ttl 600000
|
||||
openclaw attach --print-config
|
||||
```
|
||||
@@ -17,10 +28,30 @@ openclaw attach --print-config
|
||||
Options:
|
||||
|
||||
- `--session <key>` binds the grant to a Gateway session. Defaults to the main session.
|
||||
- `--url <url>` selects a Gateway for a bare reference or `--session` key. Do
|
||||
not combine it with a URL target.
|
||||
- `--token <token>` and `--password <password>` provide explicit Gateway auth.
|
||||
- `--tls-fingerprint <sha256>` pins the Gateway TLS certificate.
|
||||
- `--ttl <ms>` requests a positive grant TTL in milliseconds. The Gateway applies its own ceiling.
|
||||
- `--bin <path>` selects the Claude Code binary. Default: `claude`.
|
||||
- `--print-config` writes the temporary `.mcp.json`, prints the launch command and env, and leaves the grant live until TTL expiry (it does not spawn Claude Code or revoke the grant).
|
||||
|
||||
Pass either a positional target or `--session`, not both. Short references are
|
||||
resolved before the scoped attach grant is minted; a missing session is never
|
||||
created implicitly.
|
||||
|
||||
A URL or host target never reuses configured credentials or
|
||||
`OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`. It uses the stored
|
||||
device token for that exact Gateway origin, or explicit `--token`/`--password`
|
||||
credentials. On first contact, pass one of those credentials once, approve the
|
||||
pairing request in that Gateway's Control UI, and retry; see
|
||||
[Devices](/cli/devices). Session URLs must stay credential-free: userinfo and
|
||||
sensitive query or fragment parameters such as `token` and `password` are
|
||||
rejected.
|
||||
|
||||
Target resolution uses the same [session target error matrix](/cli/tui#session-target-errors)
|
||||
as `openclaw tui`.
|
||||
|
||||
The bearer token is passed through environment variables, not argv. OpenClaw launches Claude Code with `--strict-mcp-config --mcp-config <path>` so ambient Claude MCP servers do not join the attached session. Normal launches (without `--print-config`) revoke the grant when the Claude Code process exits.
|
||||
|
||||
See also: [Gateway CLI](/cli/gateway), [MCP CLI](/cli/mcp), and [ACP CLI](/cli/acp).
|
||||
See also: [Control UI URLs](/web/urls), [Devices](/cli/devices), [Gateway CLI](/cli/gateway), [MCP CLI](/cli/mcp), and [ACP CLI](/cli/acp).
|
||||
|
||||
+47
-2
@@ -13,6 +13,22 @@ title: "TUI"
|
||||
Open the terminal UI connected to the Gateway, or run it in local embedded
|
||||
mode.
|
||||
|
||||
```bash
|
||||
openclaw tui [target]
|
||||
```
|
||||
|
||||
`target` can be a Control UI session URL, a compact `host/agent/ref`, a bare
|
||||
short reference such as `movies-a1166b81`, or a literal `agent:...` session key.
|
||||
A URL or host target authoritatively selects that Gateway; a bare reference
|
||||
uses the configured or default Gateway. You can also paste a Control UI URL
|
||||
directly as `openclaw <url>` and place the TUI options after it, for example
|
||||
`openclaw <url> --token <token> --deliver`.
|
||||
|
||||
The bare-URL form accepts `--token`, `--password`, `--tls-fingerprint`,
|
||||
`--deliver`, `--thinking`, `--message`, `--timeout-ms`, and `--history-limit`.
|
||||
Use `openclaw tui <url>` when you need another TUI option; `--local`, `--url`,
|
||||
and `--session` conflict with a session URL.
|
||||
|
||||
Related guide: [TUI](/web/tui)
|
||||
|
||||
## Options
|
||||
@@ -37,8 +53,22 @@ Aliases: `openclaw chat` and `openclaw terminal` invoke this command with
|
||||
## Notes
|
||||
|
||||
- `--local` cannot combine with `--url`, `--token`, `--password`, or `--tls-fingerprint`.
|
||||
- `tui` resolves configured Gateway auth SecretRefs for token/password auth
|
||||
when possible (`env`/`file`/`exec` providers).
|
||||
- Pass only one Gateway target. A URL target cannot combine with `--url`, and
|
||||
any positional target cannot combine with `--session` or local mode.
|
||||
- A URL or host target never reuses configured credentials or
|
||||
`OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`. It uses the stored
|
||||
device token for that exact Gateway origin, or explicit `--token`/`--password`
|
||||
credentials. On first contact, pass one of those credentials once, approve
|
||||
the pairing request in that Gateway's Control UI, and retry; see
|
||||
[Devices](/cli/devices).
|
||||
- Session URLs must stay credential-free. Userinfo and sensitive query or
|
||||
fragment parameters such as `token` and `password` are rejected.
|
||||
- Short references resolve through the Gateway. If a short reference is
|
||||
ambiguous, the CLI prints candidate names and longer ID prefixes without
|
||||
attaching to either session.
|
||||
- With no URL/host target or explicit `--url`, `tui` resolves configured Gateway
|
||||
auth SecretRefs for token/password auth when possible (`env`/`file`/`exec`
|
||||
providers).
|
||||
- With no explicit URL or port, `tui` follows the active local Gateway port
|
||||
recorded by the running Gateway. Explicit `--url`, `OPENCLAW_GATEWAY_URL`,
|
||||
`OPENCLAW_GATEWAY_PORT`, and remote Gateway config keep precedence.
|
||||
@@ -53,12 +83,25 @@ Aliases: `openclaw chat` and `openclaw terminal` invoke this command with
|
||||
- Session [goals](/tools/goal) appear in the footer and can be managed with
|
||||
`/goal`.
|
||||
|
||||
## Session target errors
|
||||
|
||||
| Failure | Recovery |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| The Gateway predates short-link resolution | Copy the full session key from that Gateway's Control UI. |
|
||||
| Session missing or short ref ambiguous | For the configured/local Gateway, run `openclaw sessions list`; for a URL/host target, choose a longer or full key in that Gateway's Control UI. |
|
||||
| Gateway unreachable | The error names the selected origin. For a `*.ts.net` host, connect Tailscale and confirm the Gateway is reachable on the tailnet. |
|
||||
| Stored device token revoked or rotated | Rotate it with `openclaw devices rotate --device <deviceId> --role operator`, then reconnect. |
|
||||
| TLS certificate pin mismatch | The original TLS fingerprint error passes through unchanged; verify the configured or explicit pin before retrying. |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
openclaw chat
|
||||
openclaw tui --local
|
||||
openclaw tui
|
||||
openclaw tui https://gateway.example/dashboard/main/movies-a1166b81
|
||||
openclaw https://gateway.example/dashboard/main/movies-a1166b81 --token <token>
|
||||
openclaw tui movies-a1166b81
|
||||
openclaw tui --url ws://127.0.0.1:18789 --token <token>
|
||||
openclaw tui --session main --deliver
|
||||
openclaw chat --message "Compare my config to the docs and tell me what to fix"
|
||||
@@ -96,4 +139,6 @@ rerun `openclaw config validate`. See [TUI](/web/tui) and
|
||||
|
||||
- [CLI reference](/cli)
|
||||
- [TUI](/web/tui)
|
||||
- [Control UI URLs](/web/urls)
|
||||
- [Devices](/cli/devices)
|
||||
- [Goal](/tools/goal)
|
||||
|
||||
@@ -2072,6 +2072,7 @@
|
||||
"@lit/context": "1.1.6",
|
||||
"@lit/task": "1.0.3",
|
||||
"@mdx-js/mdx": "3.1.1",
|
||||
"@openclaw/session-url-contract": "workspace:*",
|
||||
"@opentelemetry/sdk-node": "0.221.0",
|
||||
"@shikijs/core": "4.3.1",
|
||||
"@shikijs/engine-javascript": "4.3.1",
|
||||
|
||||
Generated
+3
@@ -257,6 +257,9 @@ importers:
|
||||
'@mdx-js/mdx':
|
||||
specifier: 3.1.1
|
||||
version: 3.1.1(supports-color@10.2.2)
|
||||
'@openclaw/session-url-contract':
|
||||
specifier: workspace:*
|
||||
version: link:packages/session-url-contract
|
||||
'@opentelemetry/sdk-node':
|
||||
specifier: 0.221.0
|
||||
version: 0.221.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2)
|
||||
|
||||
@@ -5,9 +5,11 @@ import { fileURLToPath } from "node:url";
|
||||
// Restores every source artifact temporarily rewritten for npm packaging.
|
||||
import { restorePackageChangelog } from "./package-changelog.mjs";
|
||||
import { restorePackageDocsMap } from "./package-docs-map.mjs";
|
||||
import { restorePackageManifest } from "./package-manifest.mjs";
|
||||
|
||||
export async function restorePrepackArtifacts(cwd = process.cwd()) {
|
||||
await restorePackageChangelog(cwd);
|
||||
await restorePackageManifest(cwd);
|
||||
// Release the lifecycle receipt only after every other source mutation settles.
|
||||
await restorePackageDocsMap(cwd);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { writePackageDistInventoryForPublish } from "./lib/package-dist-inventor
|
||||
import { restorePrepackArtifacts } from "./openclaw-postpack.mjs";
|
||||
import { preparePackageChangelog } from "./package-changelog.mjs";
|
||||
import { preparePackageDocsMap } from "./package-docs-map.mjs";
|
||||
import { preparePackageManifest } from "./package-manifest.mjs";
|
||||
import { createPnpmRunnerSpawnSpec } from "./pnpm-runner.mts";
|
||||
const FULL_GIT_COMMIT_RE = /^[0-9a-f]{40}$/iu;
|
||||
const requiredPreparedPathGroups = [
|
||||
@@ -291,6 +292,7 @@ export async function preparePrepackArtifacts(env: NodeJS.ProcessEnv = process.e
|
||||
// changelog is touched, so concurrent packs cannot restore each other's files.
|
||||
await preparePackageDocsMap(process.cwd());
|
||||
try {
|
||||
await preparePackageManifest(process.cwd());
|
||||
await preparePackageChangelog(process.cwd(), {
|
||||
allowUnreleased: resolvePrepackAllowUnreleasedChangelog(env),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export function restorePackageManifest(cwd?: string): Promise<boolean>;
|
||||
export function preparePackageManifest(cwd?: string): Promise<boolean>;
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Temporarily removes private workspace dependencies from the published manifest.
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const PACKAGE_JSON_PATH = "package.json";
|
||||
const BACKUP_PATH = path.join(".artifacts", "package-manifest", "package.json.prepack-backup");
|
||||
|
||||
function preparedPackageManifest(content) {
|
||||
const packageJson = JSON.parse(content);
|
||||
const devDependencies = packageJson.devDependencies;
|
||||
if (!devDependencies || typeof devDependencies !== "object" || Array.isArray(devDependencies)) {
|
||||
return content;
|
||||
}
|
||||
const devDependencyEntries = Object.entries(devDependencies);
|
||||
const publishedDevDependencyEntries = devDependencyEntries.filter(
|
||||
([, spec]) => typeof spec !== "string" || !spec.startsWith("workspace:"),
|
||||
);
|
||||
if (publishedDevDependencyEntries.length === devDependencyEntries.length) {
|
||||
return content;
|
||||
}
|
||||
if (publishedDevDependencyEntries.length === 0) {
|
||||
delete packageJson.devDependencies;
|
||||
} else {
|
||||
packageJson.devDependencies = Object.fromEntries(publishedDevDependencyEntries);
|
||||
}
|
||||
return `${JSON.stringify(packageJson, null, 2)}\n`;
|
||||
}
|
||||
|
||||
/** Restore package.json after prepack removed private workspace dependencies. */
|
||||
export async function restorePackageManifest(cwd = process.cwd()) {
|
||||
const backupPath = path.join(cwd, BACKUP_PATH);
|
||||
if (!existsSync(backupPath)) {
|
||||
return false;
|
||||
}
|
||||
const packageJsonPath = path.join(cwd, PACKAGE_JSON_PATH);
|
||||
const [original, current] = await Promise.all([
|
||||
readFile(backupPath, "utf8"),
|
||||
readFile(packageJsonPath, "utf8"),
|
||||
]);
|
||||
if (current !== original && current !== preparedPackageManifest(original)) {
|
||||
throw new Error(
|
||||
`Refusing to restore ${PACKAGE_JSON_PATH} because it changed after prepack sanitized it.`,
|
||||
);
|
||||
}
|
||||
await writeFile(packageJsonPath, original, "utf8");
|
||||
await rm(backupPath, { force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Remove private workspace dependencies while recording restorable source bytes. */
|
||||
export async function preparePackageManifest(cwd = process.cwd()) {
|
||||
const packageJsonPath = path.join(cwd, PACKAGE_JSON_PATH);
|
||||
const backupPath = path.join(cwd, BACKUP_PATH);
|
||||
const original = await readFile(packageJsonPath, "utf8");
|
||||
const prepared = preparedPackageManifest(original);
|
||||
if (prepared === original) {
|
||||
return false;
|
||||
}
|
||||
await mkdir(path.dirname(backupPath), { recursive: true });
|
||||
try {
|
||||
await writeFile(backupPath, original, { encoding: "utf8", flag: "wx" });
|
||||
} catch (error) {
|
||||
if (error?.code === "EEXIST") {
|
||||
throw new Error(
|
||||
`Another package preparation owns ${PACKAGE_JSON_PATH}; wait for it to finish or run \`node scripts/openclaw-postpack.mjs\` after an interrupted pack.`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await writeFile(packageJsonPath, prepared, "utf8");
|
||||
} catch (error) {
|
||||
try {
|
||||
await restorePackageManifest(cwd);
|
||||
} catch (restoreError) {
|
||||
const failure = new Error(
|
||||
`Writing ${PACKAGE_JSON_PATH} failed and its source state could not be restored.`,
|
||||
{ cause: error },
|
||||
);
|
||||
Object.assign(failure, { restoreError });
|
||||
throw failure;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function main(argv = process.argv.slice(2)) {
|
||||
if (argv.length !== 1 || (argv[0] !== "prepare" && argv[0] !== "restore")) {
|
||||
console.error("Usage: node scripts/package-manifest.mjs <prepare|restore>");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const changed =
|
||||
argv[0] === "prepare" ? await preparePackageManifest() : await restorePackageManifest();
|
||||
console.error(
|
||||
changed
|
||||
? `package-manifest: ${argv[0] === "prepare" ? "sanitized" : "restored"} package.json.`
|
||||
: `package-manifest: no ${argv[0] === "prepare" ? "sanitation" : "cleanup"} needed.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
await main();
|
||||
}
|
||||
@@ -60,6 +60,10 @@ type DocsMapLifecycle = {
|
||||
preparePackageDocsMap: (cwd: string) => Promise<unknown>;
|
||||
restorePackageDocsMap: (cwd: string) => Promise<unknown>;
|
||||
};
|
||||
type PackageManifestLifecycle = {
|
||||
preparePackageManifest: (cwd: string) => Promise<unknown>;
|
||||
restorePackageManifest: (cwd: string) => Promise<unknown>;
|
||||
};
|
||||
type PackageOptions = RunOptions & {
|
||||
allowUnreleasedChangelog?: unknown;
|
||||
extractAiRuntime?: (tarballPath: string, destination: string) => Promise<unknown>;
|
||||
@@ -69,8 +73,10 @@ type PackageOptions = RunOptions & {
|
||||
prepareBundledAiRuntime?: typeof prepareBundledAiRuntimePackage;
|
||||
prepareChangelog?: (cwd: string) => Promise<unknown>;
|
||||
prepareDocsMap?: (cwd: string) => Promise<unknown>;
|
||||
prepareManifest?: (cwd: string) => Promise<unknown>;
|
||||
restoreChangelog?: (cwd: string) => Promise<unknown>;
|
||||
restoreDocsMap?: (cwd: string) => Promise<unknown>;
|
||||
restoreManifest?: (cwd: string) => Promise<unknown>;
|
||||
runCaptureImpl?: RunImpl;
|
||||
runImpl?: CommandRunner;
|
||||
};
|
||||
@@ -84,6 +90,14 @@ function isDocsMapLifecycle(value: unknown): value is DocsMapLifecycle {
|
||||
);
|
||||
}
|
||||
|
||||
function isPackageManifestLifecycle(value: unknown): value is PackageManifestLifecycle {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.preparePackageManifest === "function" &&
|
||||
typeof value.restorePackageManifest === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string) {
|
||||
return isRecord(error) && error.code === code;
|
||||
}
|
||||
@@ -779,15 +793,21 @@ export async function prepareBundledAiRuntimePackage(
|
||||
async function restorePackageSourceArtifacts(
|
||||
sourceDir: string,
|
||||
restoreDocsMap: (cwd: string) => Promise<unknown>,
|
||||
restoreManifest: (cwd: string) => Promise<unknown>,
|
||||
restoreChangelog: (cwd: string) => Promise<unknown>,
|
||||
) {
|
||||
await restoreChangelog(sourceDir);
|
||||
await restoreManifest(sourceDir);
|
||||
// Release the lifecycle receipt only after every other source mutation settles.
|
||||
await restoreDocsMap(sourceDir);
|
||||
}
|
||||
|
||||
async function loadSourceDocsMapLifecycle(sourceDir: string) {
|
||||
const modulePath = path.join(sourceDir, "scripts", "package-docs-map.mjs");
|
||||
async function loadSourcePackageLifecycle(
|
||||
sourceDir: string,
|
||||
moduleName: string,
|
||||
validate: (value: unknown) => boolean,
|
||||
) {
|
||||
const modulePath = path.join(sourceDir, "scripts", moduleName);
|
||||
try {
|
||||
await fs.access(modulePath);
|
||||
} catch (error) {
|
||||
@@ -797,13 +817,10 @@ async function loadSourceDocsMapLifecycle(sourceDir: string) {
|
||||
throw error;
|
||||
}
|
||||
const lifecycle: unknown = await import(pathToFileURL(modulePath).href);
|
||||
if (!isDocsMapLifecycle(lifecycle)) {
|
||||
throw new Error(`source package docs-map lifecycle is invalid: ${modulePath}`);
|
||||
if (!validate(lifecycle)) {
|
||||
throw new Error(`source package lifecycle is invalid: ${modulePath}`);
|
||||
}
|
||||
return {
|
||||
preparePackageDocsMap: lifecycle.preparePackageDocsMap,
|
||||
restorePackageDocsMap: lifecycle.restorePackageDocsMap,
|
||||
};
|
||||
return lifecycle;
|
||||
}
|
||||
|
||||
function packagePreparationRestoreError(error: unknown, restoreError: unknown) {
|
||||
@@ -831,7 +848,11 @@ export async function packOpenClawPackageForDocker(
|
||||
const sourceDocsMapLifecycle =
|
||||
packageOptions.prepareDocsMap && packageOptions.restoreDocsMap
|
||||
? null
|
||||
: await loadSourceDocsMapLifecycle(sourcePath);
|
||||
: ((await loadSourcePackageLifecycle(
|
||||
sourcePath,
|
||||
"package-docs-map.mjs",
|
||||
isDocsMapLifecycle,
|
||||
)) as DocsMapLifecycle | null);
|
||||
const prepareDocsMap =
|
||||
packageOptions.prepareDocsMap ??
|
||||
sourceDocsMapLifecycle?.preparePackageDocsMap ??
|
||||
@@ -840,6 +861,22 @@ export async function packOpenClawPackageForDocker(
|
||||
packageOptions.restoreDocsMap ??
|
||||
sourceDocsMapLifecycle?.restorePackageDocsMap ??
|
||||
(async () => false);
|
||||
const sourceManifestLifecycle =
|
||||
packageOptions.prepareManifest && packageOptions.restoreManifest
|
||||
? null
|
||||
: ((await loadSourcePackageLifecycle(
|
||||
sourcePath,
|
||||
"package-manifest.mjs",
|
||||
isPackageManifestLifecycle,
|
||||
)) as PackageManifestLifecycle | null);
|
||||
const prepareManifest =
|
||||
packageOptions.prepareManifest ??
|
||||
sourceManifestLifecycle?.preparePackageManifest ??
|
||||
(async () => false);
|
||||
const restoreManifest =
|
||||
packageOptions.restoreManifest ??
|
||||
sourceManifestLifecycle?.restorePackageManifest ??
|
||||
(async () => false);
|
||||
const prepareBundledAiRuntime =
|
||||
packageOptions.prepareBundledAiRuntime ?? prepareBundledAiRuntimePackage;
|
||||
const packTool = packageOptions.pnpmPack ? "pnpm" : "npm";
|
||||
@@ -850,10 +887,16 @@ export async function packOpenClawPackageForDocker(
|
||||
// This receipt is the package lifecycle lock; acquire it before touching CHANGELOG.md.
|
||||
await prepareDocsMap(sourcePath);
|
||||
try {
|
||||
await prepareManifest(sourcePath);
|
||||
await prepareChangelog(sourcePath);
|
||||
} catch (error) {
|
||||
try {
|
||||
await restorePackageSourceArtifacts(sourcePath, restoreDocsMap, restoreChangelog);
|
||||
await restorePackageSourceArtifacts(
|
||||
sourcePath,
|
||||
restoreDocsMap,
|
||||
restoreManifest,
|
||||
restoreChangelog,
|
||||
);
|
||||
} catch (restoreError) {
|
||||
throw packagePreparationRestoreError(error, restoreError);
|
||||
}
|
||||
@@ -886,7 +929,12 @@ export async function packOpenClawPackageForDocker(
|
||||
try {
|
||||
await cleanupBundledAiRuntime();
|
||||
} finally {
|
||||
await restorePackageSourceArtifacts(sourcePath, restoreDocsMap, restoreChangelog);
|
||||
await restorePackageSourceArtifacts(
|
||||
sourcePath,
|
||||
restoreDocsMap,
|
||||
restoreManifest,
|
||||
restoreChangelog,
|
||||
);
|
||||
}
|
||||
}
|
||||
// pnpm reports an absolute destination path. The directory was emptied before packing,
|
||||
|
||||
@@ -2044,6 +2044,7 @@ const EXACT_TOOLING_TARGETS = new Map<string, string[]>([
|
||||
["scripts/lib/failed-trailer.mts", ["run-oxlint", "run-tsgo", "run-vitest", "changed-lanes"]],
|
||||
["scripts/docker-e2e-rerun.mts", ["docker-e2e-helper-cli"]],
|
||||
["scripts/openclaw-postpack.mjs", [TOOLING_VITEST_CONFIG]],
|
||||
["scripts/package-manifest.mjs", ["test/openclaw-prepack.test.ts"]],
|
||||
["scripts/openclaw-npm-prepublish-verify.ts", ["test/openclaw-npm-prepublish-verify.test.ts"]],
|
||||
["scripts/lib/docker-e2e-scenarios.mts", [dockerE2e, pluginPrerelease]],
|
||||
["scripts/e2e/kitchen-sink-rpc-walk.mts", ["kitchen-sink-rpc-walk", pluginPrerelease]],
|
||||
|
||||
@@ -9,6 +9,10 @@ const gatewayCalls: Array<{
|
||||
method: string;
|
||||
params: Record<string, unknown>;
|
||||
mode?: string;
|
||||
url?: string;
|
||||
token?: string;
|
||||
useStoredDeviceAuth?: boolean;
|
||||
requiredStoredDeviceAuthScopes?: string[];
|
||||
hasDeviceIdentityKey: boolean;
|
||||
}> = [];
|
||||
|
||||
@@ -21,13 +25,31 @@ function gatewayParams(params: unknown): Record<string, unknown> {
|
||||
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway: vi.fn(
|
||||
async (p: { method: string; params: Record<string, unknown>; mode?: string }) => {
|
||||
async (p: {
|
||||
method: string;
|
||||
params: Record<string, unknown>;
|
||||
mode?: string;
|
||||
url?: string;
|
||||
token?: string;
|
||||
useStoredDeviceAuth?: boolean;
|
||||
requiredStoredDeviceAuthScopes?: string[];
|
||||
}) => {
|
||||
gatewayCalls.push({
|
||||
method: p.method,
|
||||
params: gatewayParams(p.params),
|
||||
mode: p.mode,
|
||||
url: p.url,
|
||||
token: p.token,
|
||||
useStoredDeviceAuth: p.useStoredDeviceAuth,
|
||||
requiredStoredDeviceAuthScopes: p.requiredStoredDeviceAuthScopes,
|
||||
hasDeviceIdentityKey: "deviceIdentity" in p,
|
||||
});
|
||||
if (p.method === "sessions.resolve") {
|
||||
return { ok: true, key: "agent:ops:thread:resolved" };
|
||||
}
|
||||
if (p.method === "agents.list") {
|
||||
return { defaultId: "main", mainKey: "main", scope: "global", agents: [] };
|
||||
}
|
||||
if (p.method === "attach.grant") {
|
||||
const sessionKey = (p.params.sessionKey as string) ?? "agent:main:main";
|
||||
return {
|
||||
@@ -49,6 +71,8 @@ vi.mock("../gateway/call.js", () => ({
|
||||
return {};
|
||||
},
|
||||
),
|
||||
GatewayStoredDeviceAuthUnavailableError: class extends Error {},
|
||||
GatewayTransportError: class extends Error {},
|
||||
}));
|
||||
|
||||
const logs: string[] = [];
|
||||
@@ -111,6 +135,55 @@ describe("openclaw attach (action)", () => {
|
||||
expect(grant?.hasDeviceIdentityKey).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves a URL target before granting on the same origin", async () => {
|
||||
await runAttach(
|
||||
"https://gateway.example/base/dashboard/ops/movies-a1166b81",
|
||||
"--token",
|
||||
"explicit-token",
|
||||
"--print-config",
|
||||
);
|
||||
|
||||
const resolve = gatewayCalls.find((call) => call.method === "sessions.resolve");
|
||||
expect(resolve).toMatchObject({
|
||||
url: "wss://gateway.example/base",
|
||||
token: "explicit-token",
|
||||
useStoredDeviceAuth: true,
|
||||
requiredStoredDeviceAuthScopes: ["operator.read"],
|
||||
params: { shortId: "a1166b81", slugHint: "movies" },
|
||||
});
|
||||
expect(gatewayCalls.find((call) => call.method === "attach.grant")).toMatchObject({
|
||||
url: "wss://gateway.example/base",
|
||||
token: "explicit-token",
|
||||
useStoredDeviceAuth: true,
|
||||
requiredStoredDeviceAuthScopes: ["operator.admin"],
|
||||
params: { sessionKey: "agent:ops:thread:resolved" },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a global-scope URL main session when granting attach access", async () => {
|
||||
await runAttach(
|
||||
"https://gateway.example/base/dashboard/ops",
|
||||
"--token",
|
||||
"explicit-token",
|
||||
"--print-config",
|
||||
);
|
||||
|
||||
expect(gatewayCalls.find((call) => call.method === "agents.list")).toMatchObject({
|
||||
url: "wss://gateway.example/base",
|
||||
token: "explicit-token",
|
||||
useStoredDeviceAuth: true,
|
||||
requiredStoredDeviceAuthScopes: ["operator.read"],
|
||||
params: {},
|
||||
});
|
||||
expect(gatewayCalls.find((call) => call.method === "attach.grant")).toMatchObject({
|
||||
url: "wss://gateway.example/base",
|
||||
token: "explicit-token",
|
||||
useStoredDeviceAuth: true,
|
||||
requiredStoredDeviceAuthScopes: ["operator.admin"],
|
||||
params: { sessionKey: "global", agentId: "ops" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a non-positive --ttl before minting", async () => {
|
||||
await runAttach("--ttl", "-5", "--print-config");
|
||||
expect(exitCode).toBe(1);
|
||||
|
||||
+158
-111
@@ -3,14 +3,14 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { constants as osConstants, tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
GATEWAY_CLIENT_MODES,
|
||||
GATEWAY_CLIENT_NAMES,
|
||||
} from "../../packages/gateway-protocol/src/client-info.js";
|
||||
import { getRuntimeConfig } from "../config/io.js";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import { parseStrictPositiveInteger } from "../infra/parse-finite-number.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import {
|
||||
callSessionTargetGateway,
|
||||
resolveSessionTarget,
|
||||
type SessionTargetGateway,
|
||||
} from "./session-target.js";
|
||||
|
||||
type AttachGrant = {
|
||||
sessionKey: string;
|
||||
@@ -34,7 +34,12 @@ export async function registerAttachCli(program: Command, _argv: string[] = proc
|
||||
program
|
||||
.command("attach")
|
||||
.description("Attach Claude Code to a gateway session with scoped MCP tools")
|
||||
.argument("[target]", "Control UI URL, host/agent/ref, short ref, or agent:... key")
|
||||
.option("--session <key>", "Gateway session key to bind (default: main session)")
|
||||
.option("--url <url>", "Gateway WebSocket URL")
|
||||
.option("--token <token>", "Gateway token (if required)")
|
||||
.option("--password <password>", "Gateway password (if required)")
|
||||
.option("--tls-fingerprint <sha256>", "Expected Gateway TLS certificate fingerprint")
|
||||
.option(
|
||||
"--ttl <ms>",
|
||||
"Grant TTL in positive base-10 integer milliseconds (default: gateway policy)",
|
||||
@@ -47,121 +52,163 @@ export async function registerAttachCli(program: Command, _argv: string[] = proc
|
||||
)
|
||||
.addHelpText(
|
||||
"after",
|
||||
"\nExamples:\n openclaw attach Attach Claude Code to the main session\n openclaw attach --session agent:main:telegram:123 --ttl 600000\n openclaw attach --print-config Set up the grant + config and print how to launch it yourself\n",
|
||||
"\nExamples:\n openclaw attach Attach Claude Code to the main session\n openclaw attach movies-a1166b81 Attach to a short session reference\n openclaw attach --session agent:main:telegram:123 --ttl 600000\n openclaw attach --print-config Set up the grant + config and print how to launch it yourself\n",
|
||||
)
|
||||
.action(async (opts: { session?: string; ttl?: string; bin: string; printConfig: boolean }) => {
|
||||
let ttlMs: number | undefined;
|
||||
if (opts.ttl !== undefined) {
|
||||
ttlMs = parseStrictPositiveInteger(opts.ttl);
|
||||
if (ttlMs === undefined) {
|
||||
defaultRuntime.error(
|
||||
`--ttl must be a positive integer of milliseconds. Got: ${JSON.stringify(opts.ttl)}`,
|
||||
);
|
||||
.action(
|
||||
async (
|
||||
target: string | undefined,
|
||||
opts: {
|
||||
session?: string;
|
||||
url?: string;
|
||||
token?: string;
|
||||
password?: string;
|
||||
tlsFingerprint?: string;
|
||||
ttl?: string;
|
||||
bin: string;
|
||||
printConfig: boolean;
|
||||
},
|
||||
) => {
|
||||
if (target && opts.session) {
|
||||
throw new Error("pass one session target: use either the positional target or --session");
|
||||
}
|
||||
let ttlMs: number | undefined;
|
||||
if (opts.ttl !== undefined) {
|
||||
ttlMs = parseStrictPositiveInteger(opts.ttl);
|
||||
if (ttlMs === undefined) {
|
||||
defaultRuntime.error(
|
||||
`--ttl must be a positive integer of milliseconds. Got: ${JSON.stringify(opts.ttl)}`,
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const cfg = getRuntimeConfig();
|
||||
const resolved = target
|
||||
? await resolveSessionTarget({
|
||||
raw: target,
|
||||
gateway: {
|
||||
config: cfg,
|
||||
url: opts.url,
|
||||
token: opts.token,
|
||||
password: opts.password,
|
||||
tlsFingerprint: opts.tlsFingerprint,
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
const gateway: SessionTargetGateway = resolved?.gateway ?? {
|
||||
config: cfg,
|
||||
url: opts.url,
|
||||
token: opts.token,
|
||||
password: opts.password,
|
||||
tlsFingerprint: opts.tlsFingerprint,
|
||||
};
|
||||
const globalAgentId =
|
||||
resolved?.sessionKey === "global" && resolved.parsed.kind === "url"
|
||||
? resolved.parsed.agentId
|
||||
: undefined;
|
||||
const granted = (await callSessionTargetGateway({
|
||||
gateway,
|
||||
method: "attach.grant",
|
||||
request: {
|
||||
sessionKey: resolved?.sessionKey ?? opts.session,
|
||||
...(globalAgentId ? { agentId: globalAgentId } : {}),
|
||||
ttlMs,
|
||||
},
|
||||
requiredScope: "operator.admin",
|
||||
})) as Partial<AttachGrant> | null;
|
||||
if (
|
||||
!granted ||
|
||||
typeof granted.token !== "string" ||
|
||||
typeof granted.sessionKey !== "string" ||
|
||||
typeof granted.expiresAtMs !== "number" ||
|
||||
!Number.isFinite(granted.expiresAtMs) ||
|
||||
!granted.mcpConfig?.mcpServers ||
|
||||
typeof granted.env !== "object" ||
|
||||
granted.env === null
|
||||
) {
|
||||
defaultRuntime.error("attach.grant returned an unexpected response from the gateway.");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const grant = granted as AttachGrant;
|
||||
|
||||
const cfg = getRuntimeConfig();
|
||||
const granted = (await callGateway({
|
||||
config: cfg,
|
||||
method: "attach.grant",
|
||||
params: { sessionKey: opts.session, ttlMs },
|
||||
mode: GATEWAY_CLIENT_MODES.CLI,
|
||||
clientName: GATEWAY_CLIENT_NAMES.CLI,
|
||||
})) as Partial<AttachGrant> | null;
|
||||
if (
|
||||
!granted ||
|
||||
typeof granted.token !== "string" ||
|
||||
typeof granted.sessionKey !== "string" ||
|
||||
typeof granted.expiresAtMs !== "number" ||
|
||||
!Number.isFinite(granted.expiresAtMs) ||
|
||||
!granted.mcpConfig?.mcpServers ||
|
||||
typeof granted.env !== "object" ||
|
||||
granted.env === null
|
||||
) {
|
||||
defaultRuntime.error("attach.grant returned an unexpected response from the gateway.");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const grant = granted as AttachGrant;
|
||||
const { path: configPath, cleanup } = writeClaudeMcpConfig(grant.mcpConfig);
|
||||
const expiresAt = new Date(grant.expiresAtMs).toISOString();
|
||||
const claudeArgs = ["--strict-mcp-config", "--mcp-config", configPath];
|
||||
|
||||
const { path: configPath, cleanup } = writeClaudeMcpConfig(grant.mcpConfig);
|
||||
const expiresAt = new Date(grant.expiresAtMs).toISOString();
|
||||
const claudeArgs = ["--strict-mcp-config", "--mcp-config", configPath];
|
||||
if (opts.printConfig) {
|
||||
defaultRuntime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
sessionKey: grant.sessionKey,
|
||||
expiresAt,
|
||||
env: grant.env,
|
||||
configPath,
|
||||
launch: [opts.bin, ...claudeArgs],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
defaultRuntime.log(
|
||||
`Grant is live until ${expiresAt} and auto-expires; it is not revoked here. Launch with the env above, then delete ${configPath} when done.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let revokePromise: Promise<void> | undefined;
|
||||
const revokeOnce = () =>
|
||||
(revokePromise ??= (async () => {
|
||||
try {
|
||||
await callSessionTargetGateway({
|
||||
gateway,
|
||||
method: "attach.revoke",
|
||||
request: { token: grant.token },
|
||||
requiredScope: "operator.admin",
|
||||
});
|
||||
} catch (error) {
|
||||
defaultRuntime.error(
|
||||
`Warning: failed to revoke attach grant; it remains live until ${expiresAt}. ${String(error)}`,
|
||||
);
|
||||
}
|
||||
cleanup();
|
||||
})());
|
||||
|
||||
if (opts.printConfig) {
|
||||
defaultRuntime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
sessionKey: grant.sessionKey,
|
||||
expiresAt,
|
||||
env: grant.env,
|
||||
configPath,
|
||||
launch: [opts.bin, ...claudeArgs],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
`Attaching Claude Code to session ${grant.sessionKey} (grant expires ${expiresAt})…`,
|
||||
);
|
||||
defaultRuntime.log(
|
||||
`Grant is live until ${expiresAt} and auto-expires; it is not revoked here. Launch with the env above, then delete ${configPath} when done.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const child = spawn(opts.bin, claudeArgs, {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, ...grant.env },
|
||||
});
|
||||
|
||||
let revokePromise: Promise<void> | undefined;
|
||||
const revokeOnce = () =>
|
||||
(revokePromise ??= (async () => {
|
||||
try {
|
||||
await callGateway({
|
||||
config: cfg,
|
||||
method: "attach.revoke",
|
||||
params: { token: grant.token },
|
||||
mode: GATEWAY_CLIENT_MODES.CLI,
|
||||
clientName: GATEWAY_CLIENT_NAMES.CLI,
|
||||
});
|
||||
} catch (error) {
|
||||
defaultRuntime.error(
|
||||
`Warning: failed to revoke attach grant; it remains live until ${expiresAt}. ${String(error)}`,
|
||||
);
|
||||
}
|
||||
cleanup();
|
||||
})());
|
||||
const onSigint = () => {};
|
||||
const onSigterm = () => child.kill("SIGTERM");
|
||||
const finish = (code: number) => {
|
||||
process.off("SIGINT", onSigint);
|
||||
process.off("SIGTERM", onSigterm);
|
||||
defaultRuntime.exit(code);
|
||||
};
|
||||
|
||||
defaultRuntime.log(
|
||||
`Attaching Claude Code to session ${grant.sessionKey} (grant expires ${expiresAt})…`,
|
||||
);
|
||||
const child = spawn(opts.bin, claudeArgs, {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, ...grant.env },
|
||||
});
|
||||
|
||||
const onSigint = () => {};
|
||||
const onSigterm = () => child.kill("SIGTERM");
|
||||
const finish = (code: number) => {
|
||||
process.off("SIGINT", onSigint);
|
||||
process.off("SIGTERM", onSigterm);
|
||||
defaultRuntime.exit(code);
|
||||
};
|
||||
|
||||
child.on("error", (error) => {
|
||||
void (async () => {
|
||||
defaultRuntime.error(`Failed to launch '${opts.bin}': ${String(error)}`);
|
||||
await revokeOnce();
|
||||
finish(1);
|
||||
})();
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
void (async () => {
|
||||
await revokeOnce();
|
||||
const signalCode = signal
|
||||
? 128 + ((osConstants.signals as Record<string, number>)[signal] ?? 0)
|
||||
: null;
|
||||
finish(signalCode ?? code ?? 0);
|
||||
})();
|
||||
});
|
||||
process.on("SIGINT", onSigint);
|
||||
process.on("SIGTERM", onSigterm);
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
void (async () => {
|
||||
defaultRuntime.error(`Failed to launch '${opts.bin}': ${String(error)}`);
|
||||
await revokeOnce();
|
||||
finish(1);
|
||||
})();
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
void (async () => {
|
||||
await revokeOnce();
|
||||
const signalCode = signal
|
||||
? 128 + ((osConstants.signals as Record<string, number>)[signal] ?? 0)
|
||||
: null;
|
||||
finish(signalCode ?? code ?? 0);
|
||||
})();
|
||||
});
|
||||
process.on("SIGINT", onSigint);
|
||||
process.on("SIGTERM", onSigterm);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildProgram } from "./program.js";
|
||||
import {
|
||||
callGateway,
|
||||
configureCommand,
|
||||
ensureConfigReady,
|
||||
runSystemAgentWithInference,
|
||||
@@ -66,6 +67,66 @@ describe("cli program (smoke)", () => {
|
||||
expect(options?.forceProcessExitOnReturn).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves a positional tui short reference before launch", async () => {
|
||||
callGateway.mockResolvedValue({ ok: true, key: "agent:main:thread:resolved" });
|
||||
|
||||
await runProgram(["tui", "movies-a1166b81"]);
|
||||
|
||||
expect(callGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "sessions.resolve",
|
||||
params: { shortId: "a1166b81", slugHint: "movies" },
|
||||
}),
|
||||
);
|
||||
expect(firstMockArg(runTui)).toMatchObject({
|
||||
local: false,
|
||||
session: "agent:main:thread:resolved",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a global-scope URL main session when launching tui", async () => {
|
||||
callGateway.mockResolvedValue({
|
||||
defaultId: "main",
|
||||
mainKey: "main",
|
||||
scope: "global",
|
||||
agents: [],
|
||||
});
|
||||
|
||||
await runProgram(["tui", "https://gateway.example/dashboard/ops"]);
|
||||
|
||||
expect(callGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "agents.list",
|
||||
params: {},
|
||||
}),
|
||||
);
|
||||
expect(firstMockArg(runTui)).toMatchObject({
|
||||
local: false,
|
||||
session: "global",
|
||||
agentId: "ops",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves tui agent inference unchanged without a URL agent", async () => {
|
||||
await runProgram(["tui"]);
|
||||
|
||||
expect(firstMockArg(runTui)).not.toHaveProperty("agentId");
|
||||
});
|
||||
|
||||
it("rejects a URL target combined with --url", async () => {
|
||||
await expect(
|
||||
runProgram([
|
||||
"tui",
|
||||
"https://gateway.example/dashboard/main/movies-a1166b81",
|
||||
"--url",
|
||||
"wss://other.example",
|
||||
]),
|
||||
).rejects.toThrow("exit");
|
||||
|
||||
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("pass one target"));
|
||||
expect(runTui).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs setup one-shot requests", async () => {
|
||||
await runProgram(["setup", "--message", "status"]);
|
||||
const options = firstMockArg(runSystemAgentWithInference) as {
|
||||
|
||||
@@ -94,6 +94,8 @@ vi.mock("../commands/system-agent-with-inference.js", () => ({
|
||||
}));
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway: programMocks.callGateway,
|
||||
GatewayStoredDeviceAuthUnavailableError: class extends Error {},
|
||||
GatewayTransportError: class extends Error {},
|
||||
randomIdempotencyKey: () => "idem-test",
|
||||
buildGatewayConnectionDetails: () => ({
|
||||
url: "ws://127.0.0.1:1234",
|
||||
|
||||
@@ -116,6 +116,9 @@ const readLocalOnboardingStateMock = vi.hoisted(() =>
|
||||
const setupWizardCommandMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const runRemoteGatewayInferenceOnboardingMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const runTuiMock = vi.hoisted(() => vi.fn<(opts: unknown) => Promise<void>>(async () => {}));
|
||||
const runTuiCliActionMock = vi.hoisted(() =>
|
||||
vi.fn<(target: string | undefined, opts: unknown) => Promise<void>>(async () => {}),
|
||||
);
|
||||
const probeGatewayConfiguredModelMock = vi.hoisted(() =>
|
||||
vi.fn<
|
||||
() => Promise<{
|
||||
@@ -423,6 +426,10 @@ vi.mock("../tui/tui.js", () => ({
|
||||
runTui: runTuiMock,
|
||||
}));
|
||||
|
||||
vi.mock("./tui-cli.js", () => ({
|
||||
runTuiCliAction: runTuiCliActionMock,
|
||||
}));
|
||||
|
||||
vi.mock("./progress.js", () => ({
|
||||
createCliProgress: createCliProgressMock,
|
||||
}));
|
||||
@@ -2707,6 +2714,159 @@ describe("runCli exit behavior", () => {
|
||||
expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes a bare-root Control UI URL directly to the TUI action", async () => {
|
||||
const target = "https://gateway.example/dashboard/main/movies-a1166b81";
|
||||
|
||||
await withInteractiveTty(() => runCli(["node", "openclaw", target]));
|
||||
|
||||
expect(runTuiCliActionMock).toHaveBeenCalledWith(target, {});
|
||||
expect(buildProgramMock).not.toHaveBeenCalled();
|
||||
expect(tryRouteCliMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["tui", "attach", "logs"])(
|
||||
"leaves an explicit %s URL invocation on the Commander path",
|
||||
async (command) => {
|
||||
const target = "https://gateway.example/dashboard/main/movies-a1166b81";
|
||||
const argv = ["node", "openclaw", command, target];
|
||||
buildProgramMock.mockReturnValueOnce({
|
||||
commands: [{ name: () => command, aliases: () => [] }],
|
||||
parseAsync: commanderParseAsyncMock,
|
||||
});
|
||||
|
||||
await runCli(argv);
|
||||
|
||||
expect(runTuiCliActionMock).not.toHaveBeenCalled();
|
||||
expect(buildProgramMock).toHaveBeenCalledTimes(1);
|
||||
expect(commanderParseAsyncMock).toHaveBeenCalledWith(argv);
|
||||
},
|
||||
);
|
||||
|
||||
it("leaves plugin-owned URL arguments on the plugin command path", async () => {
|
||||
const target = "https://gateway.example/dashboard/main/movies-a1166b81";
|
||||
const argv = ["node", "openclaw", "googlemeet", target];
|
||||
buildProgramMock.mockReturnValueOnce({ commands: [], parseAsync: commanderParseAsyncMock });
|
||||
|
||||
await runCli(argv);
|
||||
|
||||
expect(runTuiCliActionMock).not.toHaveBeenCalled();
|
||||
expect(buildProgramMock).toHaveBeenCalledTimes(1);
|
||||
expect(commanderParseAsyncMock).toHaveBeenCalledWith(argv);
|
||||
});
|
||||
|
||||
it("does not steal a URL argument from an unowned command", async () => {
|
||||
const target = "https://gateway.example/dashboard/main/movies-a1166b81";
|
||||
|
||||
await expect(runCli(["node", "openclaw", "unknown-owner", target])).rejects.toThrow(
|
||||
"Unknown command: openclaw unknown-owner",
|
||||
);
|
||||
|
||||
expect(runTuiCliActionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "after the URL",
|
||||
args: [
|
||||
"https://gateway.example/dashboard/main/movies-a1166b81",
|
||||
"--token",
|
||||
"direct-token",
|
||||
"--password=direct-password",
|
||||
"--tls-fingerprint",
|
||||
"sha256:direct",
|
||||
"--deliver",
|
||||
"--message",
|
||||
"continue here",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "before the URL with split values",
|
||||
args: [
|
||||
"--token",
|
||||
"direct-token",
|
||||
"--password",
|
||||
"direct-password",
|
||||
"--tls-fingerprint",
|
||||
"sha256:direct",
|
||||
"https://gateway.example/dashboard/main/movies-a1166b81",
|
||||
"--deliver",
|
||||
"--message",
|
||||
"continue here",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "before the URL with inline values",
|
||||
args: [
|
||||
"--token=direct-token",
|
||||
"--password=direct-password",
|
||||
"--tls-fingerprint=sha256:direct",
|
||||
"--message=continue here",
|
||||
"https://gateway.example/dashboard/main/movies-a1166b81",
|
||||
"--deliver",
|
||||
],
|
||||
},
|
||||
])("forwards bare-root TUI options $label without an environment handoff", async ({ args }) => {
|
||||
const target = "https://gateway.example/dashboard/main/movies-a1166b81";
|
||||
await withEnvAsync(
|
||||
{
|
||||
OPENCLAW_GATEWAY_TOKEN: "ambient-token",
|
||||
OPENCLAW_GATEWAY_PASSWORD: "ambient-password",
|
||||
},
|
||||
() => withInteractiveTty(() => runCli(["node", "openclaw", ...args])),
|
||||
);
|
||||
|
||||
expect(runTuiCliActionMock).toHaveBeenCalledWith(target, {
|
||||
token: "direct-token",
|
||||
password: "direct-password",
|
||||
tlsFingerprint: "sha256:direct",
|
||||
deliver: true,
|
||||
message: "continue here",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["unknown inline option", ["--typo=do-not-print-me"]],
|
||||
["unknown split option", ["--typo", "do-not-print-me"]],
|
||||
["option terminator", ["--"]],
|
||||
])("rejects a pre-URL %s without reflecting values", async (_label, prefix) => {
|
||||
const target = "https://gateway.example/dashboard/main/movies-a1166b81";
|
||||
let error: unknown;
|
||||
try {
|
||||
await runCli(["node", "openclaw", ...prefix, target]);
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(String(error)).not.toContain("do-not-print-me");
|
||||
expect(runTuiCliActionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a missing pre-URL direct option value before command discovery", async () => {
|
||||
const target = "https://gateway.example/dashboard/main/movies-a1166b81";
|
||||
|
||||
await expect(runCli(["node", "openclaw", "--token", target])).rejects.toThrow(
|
||||
"--token requires a value",
|
||||
);
|
||||
expect(runTuiCliActionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not claim a bare session ref as root-command sugar", async () => {
|
||||
await expect(runCli(["node", "openclaw", "movies-a1166b81"])).rejects.toThrow(
|
||||
"Unknown command: openclaw movies-a1166b81",
|
||||
);
|
||||
|
||||
expect(runTuiCliActionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not claim host shorthand as root-command sugar", async () => {
|
||||
await expect(runCli(["node", "openclaw", "gateway.example/main/a1166b81"])).rejects.toThrow(
|
||||
"Unknown command: openclaw gateway.example/main/a1166b81",
|
||||
);
|
||||
|
||||
expect(runTuiCliActionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suggests close known commands for unowned command roots before proxy startup", async () => {
|
||||
await expect(runCli(["node", "openclaw", "upate"])).rejects.toThrow(
|
||||
"Did you mean this?\n openclaw update",
|
||||
|
||||
+27
-8
@@ -8,11 +8,8 @@ import type { Command as CommanderCommand, Option as CommanderOption } from "com
|
||||
import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
isLoopbackAddress,
|
||||
isSecureWebSocketUrl,
|
||||
normalizeWebSocketProtocol,
|
||||
} from "../gateway/net.js";
|
||||
import { isLoopbackAddress, isSecureWebSocketUrl } from "../gateway/net.js";
|
||||
import { normalizeWebSocketProtocol } from "../gateway/websocket-protocol.js";
|
||||
import {
|
||||
consumeRootOptionToken,
|
||||
FLAG_TERMINATOR,
|
||||
@@ -1195,6 +1192,12 @@ async function runCliWithPreparedOutputMode(
|
||||
if (shouldEnsureCliPath(normalizedArgv)) {
|
||||
ensureOpenClawCliOnPath();
|
||||
}
|
||||
// Cheap import gate only. Session-ref owns the authoritative URL/options parse.
|
||||
const mayContainBareSessionUrl = normalizedArgv.slice(2).some((arg) => arg.includes("://"));
|
||||
const bareSessionInvocation =
|
||||
!isHelpOrVersionInvocation && mayContainBareSessionUrl
|
||||
? (await import("./session-ref.js")).parseBareSessionInvocation(normalizedArgv)
|
||||
: null;
|
||||
|
||||
// Activate operator-managed proxy routing for network-capable commands.
|
||||
// Local Gateway/control-plane commands keep direct loopback access while
|
||||
@@ -1240,6 +1243,7 @@ async function runCliWithPreparedOutputMode(
|
||||
}
|
||||
if (
|
||||
!isHelpOrVersionInvocation &&
|
||||
!bareSessionInvocation &&
|
||||
normalizedInvocation.primary &&
|
||||
!isKnownBuiltInCommandRoot(normalizedInvocation.primary)
|
||||
) {
|
||||
@@ -1308,9 +1312,11 @@ async function runCliWithPreparedOutputMode(
|
||||
};
|
||||
if (!isHelpOrVersionInvocation && shouldStartProxyForCli(normalizedArgv)) {
|
||||
const config = await withConsoleLogsRoutedToStderr(readBestEffortCliConfig);
|
||||
const unownedPrimary = await resolveUnownedCliPrimary({ argv: normalizedArgv, config });
|
||||
if (unownedPrimary) {
|
||||
throw new Error(await resolveUnownedCliPrimaryMessage({ primary: unownedPrimary, config }));
|
||||
if (!bareSessionInvocation) {
|
||||
const unownedPrimary = await resolveUnownedCliPrimary({ argv: normalizedArgv, config });
|
||||
if (unownedPrimary) {
|
||||
throw new Error(await resolveUnownedCliPrimaryMessage({ primary: unownedPrimary, config }));
|
||||
}
|
||||
}
|
||||
await replaceStartedProxy(config?.proxy ?? undefined);
|
||||
}
|
||||
@@ -1358,6 +1364,19 @@ async function runCliWithPreparedOutputMode(
|
||||
// invocation can still fail validation and must honor the console style.
|
||||
await installConsoleCapture();
|
||||
|
||||
if (bareSessionInvocation) {
|
||||
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
||||
console.error(
|
||||
"OpenClaw TUI needs an interactive TTY. Use `openclaw agent --local ...` for automation.",
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const { runTuiCliAction } = await import("./tui-cli.js");
|
||||
await runTuiCliAction(bareSessionInvocation.target, bareSessionInvocation.options);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reject unowned command roots before help/version routing, so that
|
||||
// `openclaw <typo> --help` surfaces the same Unknown command error as
|
||||
// `openclaw <typo>` instead of silently showing generic top-level help.
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { GatewayStoredDeviceAuthUnavailableError, GatewayTransportError } from "../gateway/call.js";
|
||||
import { GatewayClientRequestError } from "../gateway/client.js";
|
||||
|
||||
const callGatewayMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../gateway/call.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../gateway/call.js")>();
|
||||
return { ...actual, callGateway: callGatewayMock };
|
||||
});
|
||||
|
||||
import {
|
||||
parseBareSessionInvocation,
|
||||
parseSessionTargetInput,
|
||||
SessionTargetParseError,
|
||||
} from "./session-ref.js";
|
||||
import { resolveSessionTarget } from "./session-target.js";
|
||||
|
||||
function gatewayTransportError(params: {
|
||||
url: string;
|
||||
message: string;
|
||||
reason?: string;
|
||||
kind?: "closed" | "timeout";
|
||||
}): GatewayTransportError {
|
||||
return new GatewayTransportError({
|
||||
kind: params.kind ?? "closed",
|
||||
message: params.message,
|
||||
reason: params.reason,
|
||||
connectionDetails: {
|
||||
url: params.url,
|
||||
urlSource: "cli --url",
|
||||
message: `Gateway target: ${params.url}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("session target parsing", () => {
|
||||
beforeEach(() => {
|
||||
callGatewayMock.mockReset();
|
||||
delete process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS;
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
input: "https://Gateway.Example/dashboard/Ops/",
|
||||
expected: {
|
||||
kind: "url",
|
||||
origin: "wss://gateway.example",
|
||||
basePath: "",
|
||||
agentId: "ops",
|
||||
ref: { kind: "main" },
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "https://Gateway.Example/base/dashboard/Ops/movies-A1166B81/",
|
||||
expected: {
|
||||
kind: "url",
|
||||
origin: "wss://gateway.example",
|
||||
basePath: "/base",
|
||||
agentId: "ops",
|
||||
ref: { kind: "short", shortId: "a1166b81", slugHint: "movies" },
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "wss://gateway.example/base/chat/ops/telegram/123?view=compact#messages",
|
||||
expected: {
|
||||
kind: "url",
|
||||
origin: "wss://gateway.example",
|
||||
basePath: "/base",
|
||||
agentId: "ops",
|
||||
ref: { kind: "literal", sessionKey: "agent:ops:telegram:123" },
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "https://gateway.example/tenant/chat/dashboard/ops/movies-a1166b81",
|
||||
expected: {
|
||||
kind: "url",
|
||||
origin: "wss://gateway.example",
|
||||
basePath: "/tenant/chat",
|
||||
agentId: "ops",
|
||||
ref: { kind: "short", shortId: "a1166b81", slugHint: "movies" },
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "wss://gateway.example/dashboard/ops/~key/release-deadbeef",
|
||||
expected: {
|
||||
kind: "url",
|
||||
origin: "wss://gateway.example",
|
||||
basePath: "",
|
||||
agentId: "ops",
|
||||
ref: { kind: "literal", sessionKey: "agent:ops:release-deadbeef" },
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "Gateway.Example/Ops/movies-A1166B81/",
|
||||
expected: {
|
||||
kind: "url",
|
||||
origin: "wss://gateway.example",
|
||||
basePath: "",
|
||||
agentId: "ops",
|
||||
ref: { kind: "short", shortId: "a1166b81", slugHint: "movies" },
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "MOVIES-A1166B81",
|
||||
expected: {
|
||||
kind: "ref",
|
||||
ref: { kind: "short", shortId: "a1166b81", slugHint: "MOVIES" },
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "A1166B81",
|
||||
expected: { kind: "ref", ref: { kind: "short", shortId: "a1166b81" } },
|
||||
},
|
||||
{
|
||||
input: "AGENT:Ops:Telegram:123",
|
||||
expected: {
|
||||
kind: "ref",
|
||||
ref: { kind: "literal", sessionKey: "agent:ops:telegram:123" },
|
||||
},
|
||||
},
|
||||
])("parses $input", ({ input, expected }) => {
|
||||
expect(parseSessionTargetInput(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"",
|
||||
"not-a-session",
|
||||
"deadbee",
|
||||
"1234567890abcdef1234567890abcdef0",
|
||||
"main",
|
||||
"https://gateway.example/dashboard",
|
||||
"https://gateway.example/DASHBOARD/main/deadbeef",
|
||||
"https://gateway.example/dashboard/main/%zz",
|
||||
"ftp://gateway.example/dashboard/main/deadbeef",
|
||||
"gateway.example/main",
|
||||
])("rejects %j with the typed accepted-forms error", (input) => {
|
||||
expect(() => parseSessionTargetInput(input)).toThrow(SessionTargetParseError);
|
||||
expect(() => parseSessionTargetInput(input)).toThrow("Accepted session targets:");
|
||||
});
|
||||
|
||||
it("rejects credentials without echoing them", () => {
|
||||
const secret = "do-not-print-me";
|
||||
let error: unknown;
|
||||
try {
|
||||
parseSessionTargetInput(
|
||||
`https://user:${secret}@gateway.example/dashboard/main/movies-a1166b81`,
|
||||
);
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
expect(String(error)).toContain("must not contain credentials");
|
||||
expect(String(error)).not.toContain(secret);
|
||||
});
|
||||
|
||||
it("rejects credential query and fragment parameters without echoing them", () => {
|
||||
for (const suffix of ["?token=do-not-print-me", "#password=do-not-print-me"]) {
|
||||
let error: unknown;
|
||||
try {
|
||||
parseSessionTargetInput(`https://gateway.example/dashboard/main/movies-a1166b81${suffix}`);
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
expect(String(error)).toContain("must not contain credentials");
|
||||
expect(String(error)).not.toContain("do-not-print-me");
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces the canonical plaintext WebSocket security gate", () => {
|
||||
expect(() =>
|
||||
parseSessionTargetInput("ws://gateway.example/dashboard/main/movies-a1166b81"),
|
||||
).toThrow("SECURITY ERROR: Gateway URL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bare-root session URL options", () => {
|
||||
const target = "https://gateway.example/dashboard/main/movies-a1166b81";
|
||||
const argv = (...args: string[]) => ["node", "openclaw", ...args];
|
||||
|
||||
it.each([
|
||||
["--token", "token"],
|
||||
["--password", "password"],
|
||||
["--tls-fingerprint", "tlsFingerprint"],
|
||||
["--thinking", "thinking"],
|
||||
["--message", "message"],
|
||||
["--timeout-ms", "timeoutMs"],
|
||||
["--history-limit", "historyLimit"],
|
||||
] as const)("parses %s symmetrically before and after the URL", (flag, key) => {
|
||||
for (const args of [
|
||||
[flag, "sentinel", target],
|
||||
[`${flag}=sentinel`, target],
|
||||
[target, flag, "sentinel"],
|
||||
[target, `${flag}=sentinel`],
|
||||
]) {
|
||||
expect(parseBareSessionInvocation(argv(...args))).toEqual({
|
||||
target,
|
||||
options: { [key]: "sentinel" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("parses boolean options on either side and preserves root globals", () => {
|
||||
expect(
|
||||
parseBareSessionInvocation(
|
||||
argv(
|
||||
"--no-color",
|
||||
"--profile",
|
||||
"work",
|
||||
"--deliver",
|
||||
target,
|
||||
"--log-level=debug",
|
||||
"--token=direct-token",
|
||||
),
|
||||
),
|
||||
).toEqual({ target, options: { deliver: true, token: "direct-token" } });
|
||||
expect(parseBareSessionInvocation(argv(target, "--deliver"))).toEqual({
|
||||
target,
|
||||
options: { deliver: true },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["bare ref", ["movies-a1166b81"]],
|
||||
["host shorthand", ["gateway.example/main/a1166b81"]],
|
||||
])("does not claim %s", (_label, args) => {
|
||||
expect(parseBareSessionInvocation(argv(...args))).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["tui", "attach", "logs", "googlemeet", "unowned-command"])(
|
||||
"leaves an explicit %s command's URL argument to its owner",
|
||||
(command) => {
|
||||
expect(parseBareSessionInvocation(argv(command, target))).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["split before", ["--token", target]],
|
||||
["split after", [target, "--token"]],
|
||||
["inline before", ["--token=", target]],
|
||||
["inline after", [target, "--token="]],
|
||||
])("rejects a missing value %s", (_label, args) => {
|
||||
expect(() => parseBareSessionInvocation(argv(...args))).toThrow("--token requires a value");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["inline before", ["--typo=do-not-print-me", target]],
|
||||
["inline after", [target, "--typo=do-not-print-me"]],
|
||||
["split before", ["--typo", "do-not-print-me", target]],
|
||||
["split after", [target, "--typo", "do-not-print-me"]],
|
||||
])("rejects an unknown option %s without reflecting its value", (_label, args) => {
|
||||
let error: unknown;
|
||||
try {
|
||||
parseBareSessionInvocation(argv(...args));
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
expect(String(error)).toContain("Unsupported bare session URL option: --typo");
|
||||
expect(String(error)).not.toContain("do-not-print-me");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["terminator before", ["--", target], "Unsupported bare session URL option: --"],
|
||||
["terminator after", [target, "--"], "Unsupported bare session URL option: --"],
|
||||
["extra after", [target, "do-not-print-me"], "Unexpected extra argument"],
|
||||
["second URL", [target, "https://secret.example/path"], "Unexpected extra argument"],
|
||||
])("rejects %s without reflecting extra values", (_label, args, expected) => {
|
||||
let error: unknown;
|
||||
try {
|
||||
parseBareSessionInvocation(argv(...args));
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
expect(String(error)).toContain(expected);
|
||||
expect(String(error)).not.toContain("do-not-print-me");
|
||||
expect(String(error)).not.toContain("secret.example");
|
||||
});
|
||||
});
|
||||
|
||||
describe("session target resolution", () => {
|
||||
beforeEach(() => {
|
||||
callGatewayMock.mockReset();
|
||||
});
|
||||
|
||||
it("resolves a stale-agent URL short reference without scoping UUID lookup", async () => {
|
||||
callGatewayMock.mockResolvedValue({ ok: true, key: "agent:research:thread:full-key" });
|
||||
|
||||
const result = await resolveSessionTarget({
|
||||
raw: "https://gateway.example/base/dashboard/ops/movies-a1166b81",
|
||||
gateway: { token: "explicit-token" },
|
||||
});
|
||||
|
||||
expect(result.sessionKey).toBe("agent:research:thread:full-key");
|
||||
expect(callGatewayMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "wss://gateway.example/base",
|
||||
token: "explicit-token",
|
||||
method: "sessions.resolve",
|
||||
params: { shortId: "a1166b81", slugHint: "movies" },
|
||||
useStoredDeviceAuth: true,
|
||||
requiredStoredDeviceAuthScopes: ["operator.read"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves a bare literal key without forcing an explicit gateway", async () => {
|
||||
callGatewayMock.mockResolvedValue({ ok: true, key: "agent:ops:telegram:123" });
|
||||
|
||||
await resolveSessionTarget({ raw: "agent:ops:telegram:123" });
|
||||
|
||||
expect(callGatewayMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: undefined,
|
||||
method: "sessions.resolve",
|
||||
params: { key: "agent:ops:telegram:123" },
|
||||
}),
|
||||
);
|
||||
expect(callGatewayMock.mock.calls[0]?.[0]).not.toHaveProperty("useStoredDeviceAuth");
|
||||
});
|
||||
|
||||
it("uses gateway-advertised routing for URL main sessions without requiring an existing row", async () => {
|
||||
callGatewayMock.mockResolvedValue({
|
||||
defaultId: "main",
|
||||
mainKey: "workspace",
|
||||
scope: "per-sender",
|
||||
agents: [],
|
||||
});
|
||||
|
||||
const result = await resolveSessionTarget({
|
||||
raw: "https://gateway.example/dashboard/ops",
|
||||
});
|
||||
|
||||
expect(result.sessionKey).toBe("agent:ops:workspace");
|
||||
expect(callGatewayMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "wss://gateway.example",
|
||||
method: "agents.list",
|
||||
params: {},
|
||||
requiredStoredDeviceAuthScopes: ["operator.read"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the canonical global key for a global-scope URL main session", async () => {
|
||||
callGatewayMock.mockResolvedValue({
|
||||
defaultId: "main",
|
||||
mainKey: "main",
|
||||
scope: "global",
|
||||
agents: [],
|
||||
});
|
||||
|
||||
const result = await resolveSessionTarget({
|
||||
raw: "https://gateway.example/dashboard/ops",
|
||||
});
|
||||
|
||||
expect(result.sessionKey).toBe("global");
|
||||
});
|
||||
|
||||
it("rejects a second explicit URL", async () => {
|
||||
await expect(
|
||||
resolveSessionTarget({
|
||||
raw: "https://gateway.example/dashboard/main/movies-a1166b81",
|
||||
gateway: { url: "wss://other.example" },
|
||||
}),
|
||||
).rejects.toThrow("pass one target");
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prints bounded ambiguity candidates without listing or describing", async () => {
|
||||
callGatewayMock.mockResolvedValue({
|
||||
ok: false,
|
||||
candidates: [
|
||||
{
|
||||
key: "agent:main:thread:12345678-0aaa-4000-8000-000000000001",
|
||||
displayName: "Alpha",
|
||||
},
|
||||
{
|
||||
key: "agent:main:thread:12345678-0bbb-4000-8000-000000000002",
|
||||
displayName: "Beta",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(resolveSessionTarget({ raw: "12345678" })).rejects.toThrow(
|
||||
/Alpha\s+123456780aaa4000[\s\S]*Beta\s+123456780bbb4000/u,
|
||||
);
|
||||
expect(callGatewayMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports old gateways without falling back to sessions.list", async () => {
|
||||
callGatewayMock.mockRejectedValue(
|
||||
new GatewayClientRequestError({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "invalid sessions.resolve params: at root: unexpected property 'shortId'",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveSessionTarget({
|
||||
raw: "movies-a1166b81",
|
||||
gateway: { url: "wss://gateway.example" },
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"This gateway predates short-link resolution; pass the full session key. Choose a full session key from that gateway's Control UI (https://gateway.example).",
|
||||
);
|
||||
expect(callGatewayMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("preserves not-found text and adds the sessions list recovery", async () => {
|
||||
callGatewayMock.mockRejectedValue(
|
||||
new GatewayClientRequestError({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "No session found: a1166b81",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(resolveSessionTarget({ raw: "a1166b81" })).rejects.toThrow(
|
||||
/No session found: a1166b81[\s\S]*openclaw sessions list/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("sends remote not-found recovery to the target Control UI, not the local session store", async () => {
|
||||
callGatewayMock.mockRejectedValue(
|
||||
new GatewayClientRequestError({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "No session found: a1166b81",
|
||||
}),
|
||||
);
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await resolveSessionTarget({ raw: "gateway.example/main/a1166b81" });
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
expect(String(error)).toContain("that gateway's Control UI (https://gateway.example)");
|
||||
expect(String(error)).not.toContain("sessions list --url");
|
||||
});
|
||||
|
||||
it("turns structured pairing and revoked-token failures into actions", async () => {
|
||||
callGatewayMock.mockRejectedValueOnce(
|
||||
new GatewayClientRequestError({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "connect failed",
|
||||
details: { code: "PAIRING_REQUIRED" },
|
||||
}),
|
||||
);
|
||||
await expect(resolveSessionTarget({ raw: "gateway.example/main/a1166b81" })).rejects.toThrow(
|
||||
"openclaw devices approve --latest",
|
||||
);
|
||||
|
||||
callGatewayMock.mockRejectedValueOnce(
|
||||
new GatewayClientRequestError({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "connect failed",
|
||||
details: { code: "AUTH_DEVICE_TOKEN_MISMATCH" },
|
||||
}),
|
||||
);
|
||||
await expect(resolveSessionTarget({ raw: "gateway.example/main/a1166b81" })).rejects.toThrow(
|
||||
"openclaw devices rotate --device <deviceId> --role operator",
|
||||
);
|
||||
});
|
||||
|
||||
it("classifies legacy close reasons before adding reachability hints", async () => {
|
||||
callGatewayMock.mockRejectedValueOnce(
|
||||
gatewayTransportError({
|
||||
url: "wss://gateway.example",
|
||||
message: "gateway closed (1008): pairing required",
|
||||
reason: "pairing required",
|
||||
}),
|
||||
);
|
||||
let pairingError: unknown;
|
||||
try {
|
||||
await resolveSessionTarget({ raw: "gateway.example/main/a1166b81" });
|
||||
} catch (caught) {
|
||||
pairingError = caught;
|
||||
}
|
||||
expect(String(pairingError)).toContain("openclaw devices approve --latest");
|
||||
expect(String(pairingError)).not.toContain("Could not reach gateway");
|
||||
|
||||
callGatewayMock.mockRejectedValueOnce(
|
||||
gatewayTransportError({
|
||||
url: "wss://gateway.example",
|
||||
message: "gateway closed (1008): device token mismatch",
|
||||
reason: "device token mismatch",
|
||||
}),
|
||||
);
|
||||
let tokenError: unknown;
|
||||
try {
|
||||
await resolveSessionTarget({ raw: "gateway.example/main/a1166b81" });
|
||||
} catch (caught) {
|
||||
tokenError = caught;
|
||||
}
|
||||
expect(String(tokenError)).toContain(
|
||||
"openclaw devices rotate --device <deviceId> --role operator",
|
||||
);
|
||||
expect(String(tokenError)).not.toContain("Could not reach gateway");
|
||||
});
|
||||
|
||||
it("explains how to bootstrap auth when no origin token exists", async () => {
|
||||
callGatewayMock.mockRejectedValue(
|
||||
new GatewayStoredDeviceAuthUnavailableError("No stored device auth"),
|
||||
);
|
||||
|
||||
await expect(resolveSessionTarget({ raw: "gateway.example/main/a1166b81" })).rejects.toThrow(
|
||||
"Pass --token or --password once",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
code: "ECONNREFUSED",
|
||||
target: "claw.example.ts.net/main/a1166b81",
|
||||
expected: /Could not reach gateway wss:\/\/claw\.example\.ts\.net[\s\S]*Tailscale/u,
|
||||
},
|
||||
{
|
||||
code: "ENOTFOUND",
|
||||
target: "gateway.example/main/a1166b81",
|
||||
expected: /Could not reach gateway wss:\/\/gateway\.example[\s\S]*tailnet or SSH tunnel/u,
|
||||
},
|
||||
])("names unreachable origins for $code", async ({ code, target, expected }) => {
|
||||
callGatewayMock.mockRejectedValue(Object.assign(new Error(`connect ${code}`), { code }));
|
||||
|
||||
await expect(resolveSessionTarget({ raw: target })).rejects.toThrow(expected);
|
||||
});
|
||||
|
||||
it("uses transport connection details for configured-remote bare refs", async () => {
|
||||
callGatewayMock.mockRejectedValue(
|
||||
gatewayTransportError({
|
||||
kind: "timeout",
|
||||
url: "wss://claw.example.ts.net/base",
|
||||
message: "gateway timeout after 10000ms",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveSessionTarget({
|
||||
raw: "a1166b81",
|
||||
gateway: {
|
||||
config: {
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
remote: { url: "wss://claw.example.ts.net/base" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
/Could not reach gateway wss:\/\/claw\.example\.ts\.net\/base[\s\S]*Tailscale/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not mask TLS fingerprint mismatch errors", async () => {
|
||||
const mismatch = gatewayTransportError({
|
||||
url: "wss://gateway.example",
|
||||
message: "gateway tls fingerprint mismatch",
|
||||
reason: "gateway tls fingerprint mismatch",
|
||||
});
|
||||
callGatewayMock.mockRejectedValue(mismatch);
|
||||
|
||||
await expect(resolveSessionTarget({ raw: "gateway.example/main/a1166b81" })).rejects.toBe(
|
||||
mismatch,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import { isSensitiveUrlQueryParamName } from "@openclaw/net-policy/redact-sensitive-url";
|
||||
import {
|
||||
type ControlUiSessionPathTarget,
|
||||
parseControlUiSessionPath,
|
||||
} from "@openclaw/session-url-contract/parse";
|
||||
import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js";
|
||||
import { buildGatewayConnectionDetailsWithResolvers } from "../gateway/connection-details.js";
|
||||
import { normalizeWebSocketProtocol } from "../gateway/websocket-protocol.js";
|
||||
import { consumeRootOptionToken, FLAG_TERMINATOR } from "../infra/cli-root-options.js";
|
||||
import { parseAgentSessionKey } from "../routing/session-key.js";
|
||||
|
||||
const SESSION_TARGET_HELP =
|
||||
"Accepted session targets: https://host[/base]/{chat|dashboard}/<agent>[/<ref>], <host>/<agent>/<ref>, or a bare <slug>-<shortid>, <shortid>, or agent:... key.";
|
||||
|
||||
export class SessionTargetParseError extends Error {
|
||||
constructor() {
|
||||
super(SESSION_TARGET_HELP);
|
||||
this.name = "SessionTargetParseError";
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionTargetRef =
|
||||
| { kind: "main" }
|
||||
| { kind: "short"; shortId: string; slugHint?: string }
|
||||
| { kind: "literal"; sessionKey: string };
|
||||
|
||||
export type SessionTargetInput =
|
||||
| {
|
||||
kind: "url";
|
||||
origin: string;
|
||||
basePath: string;
|
||||
agentId: string;
|
||||
ref: SessionTargetRef;
|
||||
}
|
||||
| { kind: "ref"; ref: Exclude<SessionTargetRef, { kind: "main" }> };
|
||||
|
||||
const BARE_SESSION_TUI_VALUE_OPTIONS = {
|
||||
"--token": "token",
|
||||
"--password": "password",
|
||||
"--tls-fingerprint": "tlsFingerprint",
|
||||
"--thinking": "thinking",
|
||||
"--message": "message",
|
||||
"--timeout-ms": "timeoutMs",
|
||||
"--history-limit": "historyLimit",
|
||||
} as const;
|
||||
|
||||
export type BareSessionTuiOptions = Partial<
|
||||
Record<
|
||||
(typeof BARE_SESSION_TUI_VALUE_OPTIONS)[keyof typeof BARE_SESSION_TUI_VALUE_OPTIONS],
|
||||
string
|
||||
>
|
||||
> & { deliver?: boolean };
|
||||
|
||||
function refFromPathTarget(target: ControlUiSessionPathTarget): SessionTargetRef {
|
||||
if (target.kind === "main") {
|
||||
return { kind: "main" };
|
||||
}
|
||||
if (target.kind === "short") {
|
||||
return {
|
||||
kind: "short",
|
||||
shortId: target.shortId,
|
||||
...(target.slugHint ? { slugHint: target.slugHint } : {}),
|
||||
};
|
||||
}
|
||||
return { kind: "literal", sessionKey: target.sessionKey };
|
||||
}
|
||||
|
||||
function parseControlPath(pathname: string): {
|
||||
basePath: string;
|
||||
target: ControlUiSessionPathTarget;
|
||||
} {
|
||||
const direct = parseControlUiSessionPath(pathname);
|
||||
if (direct) {
|
||||
return { basePath: "", target: direct };
|
||||
}
|
||||
const segments = pathname.split("/");
|
||||
for (let index = segments.length - 1; index > 0; index -= 1) {
|
||||
if (segments[index] !== "chat" && segments[index] !== "dashboard") {
|
||||
continue;
|
||||
}
|
||||
const basePath = segments.slice(0, index).join("/");
|
||||
const target = parseControlUiSessionPath(pathname, basePath);
|
||||
if (target) {
|
||||
return { basePath, target };
|
||||
}
|
||||
}
|
||||
throw new SessionTargetParseError();
|
||||
}
|
||||
|
||||
function rejectUrlCredentials(url: URL): void {
|
||||
const fragmentParams = new URLSearchParams(url.hash.replace(/^#/u, ""));
|
||||
const sensitiveParam = [...url.searchParams.keys(), ...fragmentParams.keys()].some(
|
||||
isSensitiveUrlQueryParamName,
|
||||
);
|
||||
if (url.username || url.password || sensitiveParam) {
|
||||
throw new Error(
|
||||
"Session URLs must not contain credentials. Pass --token or --password instead.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseSessionUrl(raw: string): SessionTargetInput {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
throw new SessionTargetParseError();
|
||||
}
|
||||
rejectUrlCredentials(url);
|
||||
if (!["http:", "https:", "ws:", "wss:"].includes(url.protocol)) {
|
||||
throw new SessionTargetParseError();
|
||||
}
|
||||
url.protocol = normalizeWebSocketProtocol(url.protocol);
|
||||
const parsed = parseControlPath(url.pathname);
|
||||
const gatewayUrl = `${url.origin}${parsed.basePath}`;
|
||||
// Keep the established plaintext transport gate and its operator guidance canonical.
|
||||
buildGatewayConnectionDetailsWithResolvers({ config: {}, url: gatewayUrl });
|
||||
return {
|
||||
kind: "url",
|
||||
origin: url.origin,
|
||||
basePath: parsed.basePath,
|
||||
agentId: parsed.target.agentId,
|
||||
ref: refFromPathTarget(parsed.target),
|
||||
};
|
||||
}
|
||||
|
||||
function parseHostShorthand(raw: string): SessionTargetInput | null {
|
||||
const normalized = raw.endsWith("/") ? raw.slice(0, -1) : raw;
|
||||
const parts = normalized.split("/");
|
||||
if (parts.length !== 3 || parts.some((part) => !part)) {
|
||||
return null;
|
||||
}
|
||||
let host: URL;
|
||||
try {
|
||||
host = new URL(`wss://${parts[0]}`);
|
||||
} catch {
|
||||
throw new SessionTargetParseError();
|
||||
}
|
||||
rejectUrlCredentials(host);
|
||||
if (host.pathname !== "/" || host.search || host.hash) {
|
||||
throw new SessionTargetParseError();
|
||||
}
|
||||
const target = parseControlUiSessionPath(`/dashboard/${parts[1]}/${parts[2]}`);
|
||||
if (!target) {
|
||||
throw new SessionTargetParseError();
|
||||
}
|
||||
return {
|
||||
kind: "url",
|
||||
origin: host.origin,
|
||||
basePath: "",
|
||||
agentId: target.agentId,
|
||||
ref: refFromPathTarget(target),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSessionTargetInput(raw: string): SessionTargetInput {
|
||||
const value = raw.trim();
|
||||
if (!value) {
|
||||
throw new SessionTargetParseError();
|
||||
}
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//iu.test(value)) {
|
||||
return parseSessionUrl(value);
|
||||
}
|
||||
const agentKey = parseAgentSessionKey(value);
|
||||
if (agentKey) {
|
||||
return {
|
||||
kind: "ref",
|
||||
ref: {
|
||||
kind: "literal",
|
||||
sessionKey: `agent:${agentKey.agentId}:${agentKey.rest}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const shorthand = parseHostShorthand(value);
|
||||
if (shorthand) {
|
||||
return shorthand;
|
||||
}
|
||||
const short = parseControlUiSessionPath(`/dashboard/main/${value}`);
|
||||
if (short?.kind === "short") {
|
||||
return {
|
||||
kind: "ref",
|
||||
ref: {
|
||||
kind: "short",
|
||||
shortId: short.shortId,
|
||||
...(short.slugHint ? { slugHint: short.slugHint } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new SessionTargetParseError();
|
||||
}
|
||||
|
||||
export type BareSessionInvocation = {
|
||||
target: string;
|
||||
options: BareSessionTuiOptions;
|
||||
};
|
||||
|
||||
function isSessionUrlInputCandidate(raw: string): boolean {
|
||||
return /^(?:https?|wss?):\/\//iu.test(raw.trim());
|
||||
}
|
||||
|
||||
function findBareSessionUrlIndex(argv: readonly string[]): number {
|
||||
for (let index = 2; index < argv.length; index += 1) {
|
||||
const rootConsumed = consumeRootOptionToken(argv, index);
|
||||
if (rootConsumed > 0) {
|
||||
index += rootConsumed - 1;
|
||||
continue;
|
||||
}
|
||||
const arg = argv[index];
|
||||
if (arg && isSessionUrlInputCandidate(arg)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function bareSessionOptionError(flag: string): Error {
|
||||
return new Error(
|
||||
`Unsupported bare session URL option: ${sanitizeTerminalText(flag)}. Use \`openclaw tui <url> --help\` for the full option list.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Parse the complete bare-root URL invocation before generic command discovery can see secrets. */
|
||||
export function parseBareSessionInvocation(argv: readonly string[]): BareSessionInvocation | null {
|
||||
const targetIndex = findBareSessionUrlIndex(argv);
|
||||
if (targetIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
const options: BareSessionTuiOptions = {};
|
||||
for (let index = 2; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (!arg) {
|
||||
continue;
|
||||
}
|
||||
if (index === targetIndex) {
|
||||
continue;
|
||||
}
|
||||
if (arg === FLAG_TERMINATOR) {
|
||||
throw bareSessionOptionError(FLAG_TERMINATOR);
|
||||
}
|
||||
const rootConsumed = consumeRootOptionToken(argv, index);
|
||||
if (rootConsumed > 0) {
|
||||
index += rootConsumed - 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--deliver") {
|
||||
options.deliver = true;
|
||||
continue;
|
||||
}
|
||||
const equalsIndex = arg.indexOf("=");
|
||||
const flag = equalsIndex === -1 ? arg : arg.slice(0, equalsIndex);
|
||||
const optionKey =
|
||||
BARE_SESSION_TUI_VALUE_OPTIONS[flag as keyof typeof BARE_SESSION_TUI_VALUE_OPTIONS];
|
||||
if (!optionKey) {
|
||||
if (!arg.startsWith("-")) {
|
||||
// A positional before the URL is an explicit core/plugin command owner.
|
||||
// Leave its URL argument untouched for Commander and plugin routing.
|
||||
if (index < targetIndex) {
|
||||
return null;
|
||||
}
|
||||
throw new Error(
|
||||
"Unexpected extra argument for bare session URL. Use `openclaw tui <url> --help` for the full option list.",
|
||||
);
|
||||
}
|
||||
throw bareSessionOptionError(flag);
|
||||
}
|
||||
const value = equalsIndex === -1 ? argv[index + 1] : arg.slice(equalsIndex + 1);
|
||||
if (
|
||||
!value ||
|
||||
value === FLAG_TERMINATOR ||
|
||||
(equalsIndex === -1 && (index + 1 === targetIndex || value.startsWith("-")))
|
||||
) {
|
||||
throw new Error(`${flag} requires a value.`);
|
||||
}
|
||||
options[optionKey] = value;
|
||||
if (equalsIndex === -1) {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return { target: argv[targetIndex] ?? "", options };
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import {
|
||||
GATEWAY_CLIENT_MODES,
|
||||
GATEWAY_CLIENT_NAMES,
|
||||
} from "../../packages/gateway-protocol/src/client-info.js";
|
||||
import { classifyGatewayConnectFailure } from "../../packages/gateway-protocol/src/connect-error-details.js";
|
||||
import type { AgentsListResult } from "../../packages/gateway-protocol/src/index.js";
|
||||
import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js";
|
||||
import { resolveCanonicalMainSessionKey } from "../config/sessions/main-session-key.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
callGateway,
|
||||
GatewayStoredDeviceAuthUnavailableError,
|
||||
GatewayTransportError,
|
||||
} from "../gateway/call.js";
|
||||
import { GatewayClientRequestError } from "../gateway/client.js";
|
||||
import { projectGatewayUrlForDiagnostics } from "../gateway/connection-details.js";
|
||||
import {
|
||||
parseSessionTargetInput,
|
||||
SessionTargetParseError,
|
||||
type SessionTargetInput,
|
||||
} from "./session-ref.js";
|
||||
|
||||
export type SessionTargetGateway = {
|
||||
config?: OpenClawConfig;
|
||||
url?: string;
|
||||
token?: string;
|
||||
password?: string;
|
||||
tlsFingerprint?: string;
|
||||
};
|
||||
|
||||
type ResolvedSessionTarget = {
|
||||
sessionKey: string;
|
||||
gateway: SessionTargetGateway;
|
||||
parsed: SessionTargetInput;
|
||||
};
|
||||
|
||||
type SessionsResolveResult =
|
||||
| { ok: true; key: string }
|
||||
| { ok: false; candidates?: Array<{ key: string; displayName?: string }> };
|
||||
|
||||
function gatewayUrlForTarget(target: SessionTargetInput): string | undefined {
|
||||
return target.kind === "url" ? `${target.origin}${target.basePath}` : undefined;
|
||||
}
|
||||
|
||||
export async function callSessionTargetGateway<T>(params: {
|
||||
gateway: SessionTargetGateway;
|
||||
method: string;
|
||||
request?: unknown;
|
||||
requiredScope: "operator.read" | "operator.admin";
|
||||
shortRef?: boolean;
|
||||
}): Promise<T> {
|
||||
const explicitUrl = params.gateway.url?.trim() || undefined;
|
||||
try {
|
||||
return await callGateway<T>({
|
||||
config: params.gateway.config,
|
||||
url: explicitUrl,
|
||||
token: params.gateway.token,
|
||||
password: params.gateway.password,
|
||||
tlsFingerprint: params.gateway.tlsFingerprint,
|
||||
method: params.method,
|
||||
params: params.request,
|
||||
mode: GATEWAY_CLIENT_MODES.CLI,
|
||||
clientName: GATEWAY_CLIENT_NAMES.CLI,
|
||||
...(explicitUrl
|
||||
? {
|
||||
useStoredDeviceAuth: true,
|
||||
requiredStoredDeviceAuthScopes: [params.requiredScope],
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
} catch (error) {
|
||||
throw shapeTargetError(error, explicitUrl, params.shortRef === true);
|
||||
}
|
||||
}
|
||||
|
||||
function candidateId(key: string): string {
|
||||
const uuid = key.match(/([0-9a-f]{8}-[0-9a-f-]{27})$/iu)?.[1]?.replaceAll("-", "");
|
||||
return (uuid ?? key).slice(0, 16);
|
||||
}
|
||||
|
||||
function formatAmbiguousCandidates(
|
||||
candidates: Array<{ key: string; displayName?: string }>,
|
||||
gatewayUrl: string | undefined,
|
||||
): string {
|
||||
const rows = candidates.map((candidate) => ({
|
||||
name: sanitizeTerminalText(candidate.displayName?.trim() || "(unnamed)")
|
||||
.replace(/\s+/gu, " ")
|
||||
.slice(0, 40),
|
||||
id: candidateId(candidate.key),
|
||||
}));
|
||||
const width = Math.max("SESSION".length, ...rows.map((row) => row.name.length));
|
||||
return [
|
||||
"Session reference is ambiguous:",
|
||||
`${"SESSION".padEnd(width)} ID PREFIX`,
|
||||
...rows.map((row) => `${row.name.padEnd(width)} ${row.id}`),
|
||||
`Pass a longer reference. ${sessionsListHint(gatewayUrl)}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function sessionsListHint(gatewayUrl: string | undefined): string {
|
||||
return gatewayUrl
|
||||
? `Choose a full session key from that gateway's Control UI (${controlUiBaseUrl(gatewayUrl)}).`
|
||||
: "Run `openclaw sessions list` to choose a full session key.";
|
||||
}
|
||||
|
||||
function controlUiBaseUrl(gatewayUrl: string): string {
|
||||
const url = new URL(gatewayUrl);
|
||||
url.protocol =
|
||||
url.protocol === "wss:" ? "https:" : url.protocol === "ws:" ? "http:" : url.protocol;
|
||||
return sanitizeTerminalText(url.toString().replace(/\/$/u, ""));
|
||||
}
|
||||
|
||||
function isPriorGatewayShortIdRejection(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof GatewayClientRequestError &&
|
||||
error.gatewayCode === "INVALID_REQUEST" &&
|
||||
error.message.includes("invalid sessions.resolve params:") &&
|
||||
error.message.includes("unexpected property 'shortId'")
|
||||
);
|
||||
}
|
||||
|
||||
function unreachableTargetError(error: Error, gatewayUrl: string | undefined): Error {
|
||||
if (!gatewayUrl) {
|
||||
return error;
|
||||
}
|
||||
const hostname = new URL(gatewayUrl).hostname;
|
||||
const displayGatewayUrl = projectGatewayUrlForDiagnostics(gatewayUrl);
|
||||
const tailscaleHint = hostname.endsWith(".ts.net")
|
||||
? " For this .ts.net host, check that Tailscale is connected and the gateway is reachable on your tailnet."
|
||||
: "";
|
||||
return new Error(
|
||||
`${error.message}\nCould not reach gateway ${displayGatewayUrl}. Check whether the gateway is down and whether its tailnet or SSH tunnel is reachable.${tailscaleHint}`,
|
||||
);
|
||||
}
|
||||
|
||||
function shapeTargetError(
|
||||
error: unknown,
|
||||
gatewayUrl: string | undefined,
|
||||
shortRef: boolean,
|
||||
): Error {
|
||||
if (shortRef && isPriorGatewayShortIdRejection(error)) {
|
||||
return new Error(
|
||||
`This gateway predates short-link resolution; pass the full session key. ${sessionsListHint(gatewayUrl)}`,
|
||||
);
|
||||
}
|
||||
if (error instanceof GatewayStoredDeviceAuthUnavailableError && gatewayUrl) {
|
||||
return new Error(
|
||||
`No stored device auth for ${gatewayUrl}. Pass --token or --password once, approve the pairing request in that gateway's Control UI (Settings > Devices), then retry.`,
|
||||
);
|
||||
}
|
||||
if (!(error instanceof Error)) {
|
||||
return new Error(String(error));
|
||||
}
|
||||
// A pin mismatch names the precise trust failure and must never be reclassified as transport.
|
||||
if (/tls fingerprint/iu.test(error.message)) {
|
||||
return error;
|
||||
}
|
||||
if (
|
||||
error instanceof GatewayClientRequestError &&
|
||||
error.gatewayCode === "INVALID_REQUEST" &&
|
||||
error.message.includes("No session found")
|
||||
) {
|
||||
return new Error(`${error.message}\n${sessionsListHint(gatewayUrl)}`);
|
||||
}
|
||||
const failure = classifyGatewayConnectFailure({
|
||||
...(error instanceof GatewayClientRequestError ? { details: error.details } : {}),
|
||||
...(error instanceof GatewayTransportError ? { reason: error.reason } : {}),
|
||||
message: error.message,
|
||||
});
|
||||
if (failure.kind === "unreachable") {
|
||||
const effectiveGatewayUrl =
|
||||
gatewayUrl ??
|
||||
(error instanceof GatewayTransportError ? error.connectionDetails.url : undefined);
|
||||
return unreachableTargetError(error, effectiveGatewayUrl);
|
||||
}
|
||||
return failure.remediation ? new Error(`${failure.userMessage}\n${failure.remediation}`) : error;
|
||||
}
|
||||
|
||||
export async function resolveSessionTarget(params: {
|
||||
raw: string;
|
||||
gateway?: SessionTargetGateway;
|
||||
requiredScope?: "operator.read" | "operator.admin";
|
||||
}): Promise<ResolvedSessionTarget> {
|
||||
const parsed = parseSessionTargetInput(params.raw);
|
||||
const targetUrl = gatewayUrlForTarget(parsed);
|
||||
if (targetUrl && params.gateway?.url) {
|
||||
throw new Error("pass one target: use either the session URL or --url, not both");
|
||||
}
|
||||
const gateway: SessionTargetGateway = {
|
||||
...params.gateway,
|
||||
url: targetUrl ?? params.gateway?.url,
|
||||
};
|
||||
if (parsed.ref.kind === "main") {
|
||||
if (parsed.kind !== "url") {
|
||||
throw new SessionTargetParseError();
|
||||
}
|
||||
const agents = await callSessionTargetGateway<AgentsListResult>({
|
||||
gateway,
|
||||
method: "agents.list",
|
||||
request: {},
|
||||
requiredScope: params.requiredScope ?? "operator.read",
|
||||
});
|
||||
return {
|
||||
parsed,
|
||||
gateway,
|
||||
sessionKey: resolveCanonicalMainSessionKey({
|
||||
agentId: parsed.agentId,
|
||||
mainKey: agents.mainKey,
|
||||
sessionScope: agents.scope,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const ref = parsed.ref;
|
||||
const request =
|
||||
ref.kind === "short"
|
||||
? {
|
||||
shortId: ref.shortId,
|
||||
...(ref.slugHint ? { slugHint: ref.slugHint } : {}),
|
||||
}
|
||||
: { key: ref.sessionKey };
|
||||
const result = await callSessionTargetGateway<SessionsResolveResult>({
|
||||
gateway,
|
||||
method: "sessions.resolve",
|
||||
request,
|
||||
requiredScope: params.requiredScope ?? "operator.read",
|
||||
shortRef: ref.kind === "short",
|
||||
});
|
||||
if (result.ok) {
|
||||
return { parsed, gateway, sessionKey: result.key };
|
||||
}
|
||||
if (result.candidates?.length) {
|
||||
throw new Error(formatAmbiguousCandidates(result.candidates, gateway.url));
|
||||
}
|
||||
throw new Error(`No session found.\n${sessionsListHint(gateway.url)}`);
|
||||
}
|
||||
+91
-37
@@ -6,8 +6,96 @@ import { theme } from "../../packages/terminal-core/src/theme.js";
|
||||
import { parseStrictPositiveInteger } from "../infra/parse-finite-number.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { parseTimeoutMs } from "./parse-timeout.js";
|
||||
import { resolveSessionTarget } from "./session-target.js";
|
||||
import { addTuiOptions } from "./tui-cli-options.js";
|
||||
|
||||
type TuiCliOptions = {
|
||||
local?: boolean;
|
||||
url?: string;
|
||||
token?: string;
|
||||
password?: string;
|
||||
tlsFingerprint?: string;
|
||||
session?: string;
|
||||
deliver?: boolean;
|
||||
thinking?: string;
|
||||
message?: string;
|
||||
timeoutMs?: string;
|
||||
historyLimit?: string;
|
||||
};
|
||||
|
||||
export async function runTuiCliAction(
|
||||
target: string | undefined,
|
||||
opts: TuiCliOptions,
|
||||
invokedSubcommand = "tui",
|
||||
): Promise<void> {
|
||||
const invokedAsLocalAlias = invokedSubcommand === "terminal" || invokedSubcommand === "chat";
|
||||
const isLocal = Boolean(opts.local) || invokedAsLocalAlias;
|
||||
if (target && isLocal) {
|
||||
throw new Error(
|
||||
"a session target cannot be combined with --local, openclaw chat, or openclaw terminal",
|
||||
);
|
||||
}
|
||||
if (isLocal && (opts.url || opts.token || opts.password || opts.tlsFingerprint)) {
|
||||
throw new Error(
|
||||
"--local cannot be combined with --url, --token, --password, or --tls-fingerprint",
|
||||
);
|
||||
}
|
||||
if (target && opts.session) {
|
||||
throw new Error("pass one session target: use either the positional target or --session");
|
||||
}
|
||||
const timeoutMs = parseTimeoutMs(opts.timeoutMs);
|
||||
if (opts.timeoutMs !== undefined && timeoutMs === undefined) {
|
||||
defaultRuntime.error(`warning: invalid --timeout-ms "${opts.timeoutMs}"; ignoring`);
|
||||
}
|
||||
const historyLimit = parseStrictPositiveInteger(opts.historyLimit ?? "200");
|
||||
if (historyLimit === undefined) {
|
||||
throw new Error("--history-limit must be a positive integer.");
|
||||
}
|
||||
if (!isLocal && historyLimit > CHAT_HISTORY_MAX_ENTRIES) {
|
||||
throw new Error(`--history-limit must be at most ${CHAT_HISTORY_MAX_ENTRIES}.`);
|
||||
}
|
||||
|
||||
const resolved = target
|
||||
? await resolveSessionTarget({
|
||||
raw: target,
|
||||
requiredScope: "operator.admin",
|
||||
gateway: {
|
||||
url: opts.url,
|
||||
token: opts.token,
|
||||
password: opts.password,
|
||||
tlsFingerprint: opts.tlsFingerprint,
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
const { runTui } = await import("../tui/tui.js");
|
||||
await runTui({
|
||||
local: isLocal,
|
||||
...(resolved?.gateway.url
|
||||
? {
|
||||
boundGateway: {
|
||||
url: resolved.gateway.url,
|
||||
token: resolved.gateway.token,
|
||||
password: resolved.gateway.password,
|
||||
tlsFingerprint: resolved.gateway.tlsFingerprint,
|
||||
},
|
||||
}
|
||||
: {
|
||||
url: opts.url,
|
||||
token: opts.token,
|
||||
password: opts.password,
|
||||
tlsFingerprint: opts.tlsFingerprint,
|
||||
}),
|
||||
session: resolved?.sessionKey ?? opts.session,
|
||||
...(resolved?.parsed.kind === "url" ? { agentId: resolved.parsed.agentId } : {}),
|
||||
deliver: Boolean(opts.deliver),
|
||||
thinking: opts.thinking,
|
||||
message: opts.message,
|
||||
timeoutMs,
|
||||
historyLimit,
|
||||
forceProcessExitOnReturn: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Attach the `tui` command plus its `terminal`/`chat` aliases to the root CLI. */
|
||||
export function registerTuiCli(program: Command) {
|
||||
const command = program
|
||||
@@ -15,6 +103,7 @@ export function registerTuiCli(program: Command) {
|
||||
.alias("terminal")
|
||||
.alias("chat")
|
||||
.description("Open a terminal UI connected to the Gateway")
|
||||
.argument("[target]", "Control UI URL, host/agent/ref, short ref, or agent:... key")
|
||||
.option("--local", "Run against the local embedded agent runtime", false);
|
||||
addTuiOptions(command)
|
||||
.option("--session <key>", 'Session key (default: "main", or "global" when scope is global)')
|
||||
@@ -27,47 +116,12 @@ export function registerTuiCli(program: Command) {
|
||||
"after",
|
||||
() => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/tui", "docs.openclaw.ai/cli/tui")}\n`,
|
||||
)
|
||||
.action(async (opts, cmd) => {
|
||||
.action(async (target: string | undefined, opts: TuiCliOptions, cmd: Command) => {
|
||||
try {
|
||||
// `cmd.name()` always returns the canonical subcommand name (`tui`).
|
||||
// Use the parsed parent args to see which alias the user actually typed.
|
||||
const invokedSubcommand = cmd.parent?.args[0];
|
||||
const invokedAsLocalAlias =
|
||||
invokedSubcommand === "terminal" || invokedSubcommand === "chat";
|
||||
const isLocal = Boolean(opts.local) || invokedAsLocalAlias;
|
||||
if (isLocal && (opts.url || opts.token || opts.password || opts.tlsFingerprint)) {
|
||||
throw new Error(
|
||||
"--local cannot be combined with --url, --token, --password, or --tls-fingerprint",
|
||||
);
|
||||
}
|
||||
const timeoutMs = parseTimeoutMs(opts.timeoutMs);
|
||||
if (opts.timeoutMs !== undefined && timeoutMs === undefined) {
|
||||
defaultRuntime.error(
|
||||
`warning: invalid --timeout-ms "${String(opts.timeoutMs)}"; ignoring`,
|
||||
);
|
||||
}
|
||||
const historyLimit = parseStrictPositiveInteger(opts.historyLimit ?? "200");
|
||||
if (historyLimit === undefined) {
|
||||
throw new Error("--history-limit must be a positive integer.");
|
||||
}
|
||||
if (!isLocal && historyLimit > CHAT_HISTORY_MAX_ENTRIES) {
|
||||
throw new Error(`--history-limit must be at most ${CHAT_HISTORY_MAX_ENTRIES}.`);
|
||||
}
|
||||
const { runTui } = await import("../tui/tui.js");
|
||||
await runTui({
|
||||
local: isLocal,
|
||||
url: opts.url as string | undefined,
|
||||
token: opts.token as string | undefined,
|
||||
password: opts.password as string | undefined,
|
||||
tlsFingerprint: opts.tlsFingerprint as string | undefined,
|
||||
session: opts.session as string | undefined,
|
||||
deliver: Boolean(opts.deliver),
|
||||
thinking: opts.thinking as string | undefined,
|
||||
message: opts.message as string | undefined,
|
||||
timeoutMs,
|
||||
historyLimit,
|
||||
forceProcessExitOnReturn: true,
|
||||
});
|
||||
await runTuiCliAction(target, opts, invokedSubcommand);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
|
||||
@@ -171,12 +171,18 @@ vi.mock("../tasks/task-registry.maintenance.js", () => ({
|
||||
getInspectableTaskAuditFindings: statusSummaryMocks.getInspectableTaskAuditFindings,
|
||||
}));
|
||||
|
||||
vi.mock("../routing/session-key.js", () => ({
|
||||
LEGACY_IMPLICIT_AGENT_ID: "main",
|
||||
normalizeAgentId: vi.fn((value: string) => value),
|
||||
normalizeMainKey: vi.fn((value?: string) => value ?? "main"),
|
||||
parseAgentSessionKey: vi.fn(() => null),
|
||||
}));
|
||||
vi.mock("../routing/session-key.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../routing/session-key.js")>(
|
||||
"../routing/session-key.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
LEGACY_IMPLICIT_AGENT_ID: "main",
|
||||
normalizeAgentId: vi.fn((value: string) => value),
|
||||
normalizeMainKey: vi.fn((value?: string) => value ?? "main"),
|
||||
parseAgentSessionKey: vi.fn(() => null),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../version.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../version.js")>("../version.js");
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { buildAgentMainSessionKey } from "../../routing/session-key.js";
|
||||
import type { SessionScope } from "./types.js";
|
||||
|
||||
/** Resolves the configured main session identity for one agent and session scope. */
|
||||
export function resolveCanonicalMainSessionKey(params: {
|
||||
agentId: string;
|
||||
mainKey?: string | undefined;
|
||||
sessionScope?: SessionScope;
|
||||
}): string {
|
||||
return params.sessionScope === "global" ? "global" : buildAgentMainSessionKey(params);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
resolveAgentIdFromSessionKey,
|
||||
} from "../../routing/session-key.js";
|
||||
import type { OpenClawConfig } from "../types.openclaw.js";
|
||||
import { resolveCanonicalMainSessionKey } from "./main-session-key.js";
|
||||
import type { SessionScope } from "./types.js";
|
||||
|
||||
const FALLBACK_DEFAULT_AGENT_ID = "main";
|
||||
@@ -18,10 +19,11 @@ function buildMainSessionKey(agentId: string, mainKey?: string): string {
|
||||
|
||||
/** Resolves the configured main session key, honoring global session scope. */
|
||||
export function resolveMainSessionKey(cfg: OpenClawConfig): string {
|
||||
if (cfg?.session?.scope === "global") {
|
||||
return "global";
|
||||
}
|
||||
return buildMainSessionKey(resolveDefaultAgentId(cfg), cfg.session?.mainKey);
|
||||
return resolveCanonicalMainSessionKey({
|
||||
agentId: resolveDefaultAgentId(cfg),
|
||||
mainKey: cfg.session?.mainKey,
|
||||
sessionScope: cfg.session?.scope,
|
||||
});
|
||||
}
|
||||
|
||||
/** Stable fingerprint for the config values that canonicalize chat session keys. */
|
||||
|
||||
@@ -53,6 +53,18 @@ describe("mcp-grant-store", () => {
|
||||
expect(a.token).not.toBe(b.token);
|
||||
});
|
||||
|
||||
it("binds a separate agent owner only to the canonical global session", () => {
|
||||
const global = mintAttachGrant({ sessionKey: "global", agentId: " ops ", nowMs: T0 });
|
||||
const scoped = mintAttachGrant({
|
||||
sessionKey: "agent:main:telegram:1",
|
||||
agentId: "ops",
|
||||
nowMs: T0,
|
||||
});
|
||||
|
||||
expect(global.agentId).toBe("ops");
|
||||
expect(scoped.agentId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("revokes by token", () => {
|
||||
const g = mintAttachGrant({ sessionKey: "agent:main:x", nowMs: T0 });
|
||||
expect(revokeAttachGrant(g.token)).toBe(true);
|
||||
|
||||
@@ -67,6 +67,8 @@ interface McpAttachGrant {
|
||||
readonly token: string;
|
||||
/** The openclaw session this grant is bound to; tool scope is resolved for this key. */
|
||||
readonly sessionKey: string;
|
||||
/** Explicit agent owner for canonical global sessions, whose key cannot encode one. */
|
||||
readonly agentId?: string;
|
||||
/** Absolute expiry (ms epoch). */
|
||||
readonly expiresAtMs: number;
|
||||
/** Absolute mint time (ms epoch). */
|
||||
@@ -119,6 +121,7 @@ function clampTtlMs(ttlMs: number | undefined): number {
|
||||
|
||||
export function mintAttachGrant(params: {
|
||||
sessionKey: string;
|
||||
agentId?: string;
|
||||
ttlMs?: number;
|
||||
nowMs?: number;
|
||||
}): McpAttachGrant {
|
||||
@@ -126,12 +129,14 @@ export function mintAttachGrant(params: {
|
||||
if (!sessionKey) {
|
||||
throw new Error("mintAttachGrant: sessionKey is required");
|
||||
}
|
||||
const agentId = sessionKey === "global" ? params.agentId?.trim() || undefined : undefined;
|
||||
const nowMs = params.nowMs ?? Date.now();
|
||||
// Mint sweeps stale entries so abandoned grants do not accumulate.
|
||||
sweepExpiredAttachGrants(nowMs);
|
||||
const grant: McpAttachGrant = {
|
||||
token: crypto.randomBytes(32).toString("hex"),
|
||||
sessionKey,
|
||||
...(agentId ? { agentId } : {}),
|
||||
issuedAtMs: nowMs,
|
||||
expiresAtMs: nowMs + clampTtlMs(params.ttlMs),
|
||||
};
|
||||
|
||||
@@ -62,6 +62,7 @@ type McpRequestContext = McpLoopbackRequestContext;
|
||||
type McpLoopbackRequestAuth = {
|
||||
senderIsOwner: boolean;
|
||||
boundSessionKey?: string;
|
||||
boundAgentId?: string;
|
||||
boundContext?: McpLoopbackRequestContext;
|
||||
boundCaptureKey?: string;
|
||||
boundGrantToken?: string;
|
||||
@@ -149,7 +150,11 @@ function resolveMcpSender(params: {
|
||||
}
|
||||
const grant = grantToken ? resolveAttachGrant(grantToken) : undefined;
|
||||
if (grant) {
|
||||
return { senderIsOwner: false, boundSessionKey: grant.sessionKey };
|
||||
return {
|
||||
senderIsOwner: false,
|
||||
boundSessionKey: grant.sessionKey,
|
||||
...(grant.agentId ? { boundAgentId: grant.agentId } : {}),
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -279,6 +284,7 @@ export function validateMcpLoopbackRequest(params: {
|
||||
return {
|
||||
senderIsOwner: sender.senderIsOwner,
|
||||
boundSessionKey: sender.boundSessionKey,
|
||||
boundAgentId: sender.boundAgentId,
|
||||
boundContext: sender.boundContext,
|
||||
boundCaptureKey: sender.boundCaptureKey,
|
||||
boundGrantToken: sender.boundGrantToken,
|
||||
@@ -416,11 +422,12 @@ export function resolveMcpRequestContext(
|
||||
// session, channel, capability, or ownership headers.
|
||||
return structuredClone(auth.boundContext);
|
||||
}
|
||||
// Grant-authenticated callers get only their server-bound session; spoofable
|
||||
// delivery/action headers stay reserved for the gateway-launched loopback client.
|
||||
// Grant-authenticated callers get only their server-bound session and optional
|
||||
// global-session agent owner; spoofable delivery/action headers stay reserved.
|
||||
if (auth.boundSessionKey) {
|
||||
return {
|
||||
sessionKey: auth.boundSessionKey,
|
||||
agentId: auth.boundAgentId,
|
||||
sessionId: undefined,
|
||||
messageProvider: undefined,
|
||||
clientCaps: undefined,
|
||||
|
||||
@@ -1141,8 +1141,8 @@ describe("mcp loopback server", () => {
|
||||
expect(getScopedToolsCall(1).clientCaps).toBeUndefined();
|
||||
});
|
||||
|
||||
it("binds an attach grant's session and ignores ALL spoofed context headers (no scope-shop)", async () => {
|
||||
const grant = mintAttachGrant({ sessionKey: "agent:main:attach-host" });
|
||||
it("binds an attach grant's session owner and ignores ALL spoofed context headers", async () => {
|
||||
const grant = mintAttachGrant({ sessionKey: "global", agentId: "ops" });
|
||||
const port = await getFreePortBlockWithPermissionFallback({
|
||||
offsets: [0],
|
||||
fallbackBase: 53_000,
|
||||
@@ -1168,7 +1168,8 @@ describe("mcp loopback server", () => {
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const call = getScopedToolsCall(0);
|
||||
expect(call.sessionKey).toBe("agent:main:attach-host");
|
||||
expect(call.sessionKey).toBe("global");
|
||||
expect(call.agentId).toBe("ops");
|
||||
expect(call.senderIsOwner).toBe(false);
|
||||
expect(call.surface).toBe("loopback");
|
||||
expect(call.messageProvider).toBeUndefined();
|
||||
|
||||
+1
-5
@@ -19,6 +19,7 @@ import {
|
||||
type NetworkInterfacesSnapshot,
|
||||
} from "../infra/network-interfaces.js";
|
||||
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
|
||||
import { normalizeWebSocketProtocol } from "./websocket-protocol.js";
|
||||
|
||||
/** Pick the primary non-internal IPv4 address, preferring common LAN interface names. */
|
||||
export function pickPrimaryLanIPv4(): string | undefined {
|
||||
@@ -467,11 +468,6 @@ export function isPrivateOrLoopbackHost(host: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Normalize HTTP aliases accepted by WebSocket clients to their WebSocket protocol. */
|
||||
export function normalizeWebSocketProtocol(protocol: string): string {
|
||||
return protocol === "https:" ? "wss:" : protocol === "http:" ? "ws:" : protocol;
|
||||
}
|
||||
|
||||
function parseHostForAddressChecks(
|
||||
host: string,
|
||||
): { isLocalhost: boolean; unbracketedHost: string } | null {
|
||||
|
||||
@@ -62,6 +62,42 @@ describe("attach gateway methods", () => {
|
||||
expect(resolveAttachGrant(body.token)?.sessionKey).toBe("agent:main:attach-method");
|
||||
});
|
||||
|
||||
it("preserves explicit ownership only for canonical global sessions", async () => {
|
||||
const respond = vi.fn();
|
||||
await expectDefined(
|
||||
attachHandlers["attach.grant"],
|
||||
'attachHandlers["attach.grant"] test invariant',
|
||||
)({
|
||||
params: { sessionKey: "global", agentId: "ops" },
|
||||
respond,
|
||||
context: { getRuntimeConfig: () => ({}) },
|
||||
} as unknown as GatewayRequestHandlerOptions);
|
||||
|
||||
const grant = resolveAttachGrant(
|
||||
(expectDefined(respond.mock.calls[0], "respond call invariant")[1] as { token: string })
|
||||
.token,
|
||||
);
|
||||
expect(grant).toMatchObject({ sessionKey: "global", agentId: "ops" });
|
||||
|
||||
const scopedRespond = vi.fn();
|
||||
await expectDefined(
|
||||
attachHandlers["attach.grant"],
|
||||
'attachHandlers["attach.grant"] test invariant',
|
||||
)({
|
||||
params: { sessionKey: "agent:main:attach-method", agentId: "ops" },
|
||||
respond: scopedRespond,
|
||||
context: { getRuntimeConfig: () => ({}) },
|
||||
} as unknown as GatewayRequestHandlerOptions);
|
||||
const scopedGrant = resolveAttachGrant(
|
||||
(
|
||||
expectDefined(scopedRespond.mock.calls[0], "scoped respond call invariant")[1] as {
|
||||
token: string;
|
||||
}
|
||||
).token,
|
||||
);
|
||||
expect(scopedGrant?.agentId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects attach grants for reserved harness sessions", async () => {
|
||||
const respond = vi.fn();
|
||||
await expectDefined(
|
||||
|
||||
@@ -33,6 +33,7 @@ export const attachHandlers: GatewayRequestHandlers = {
|
||||
const grantParams = paramRecord(params);
|
||||
const cfg = context.getRuntimeConfig();
|
||||
const sessionKey = readString(grantParams, "sessionKey") ?? resolveMainSessionKey(cfg);
|
||||
const agentId = sessionKey === "global" ? readString(grantParams, "agentId") : undefined;
|
||||
const harnessEntry = isAgentHarnessSessionKey(sessionKey)
|
||||
? resolveSessionEntryAccessTarget({ cfg, sessionKey }).entry
|
||||
: undefined;
|
||||
@@ -57,7 +58,11 @@ export const attachHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const grant = mintAttachGrant({ sessionKey, ttlMs: readPositiveNumber(grantParams, "ttlMs") });
|
||||
const grant = mintAttachGrant({
|
||||
sessionKey,
|
||||
...(agentId ? { agentId } : {}),
|
||||
ttlMs: readPositiveNumber(grantParams, "ttlMs"),
|
||||
});
|
||||
respond(true, {
|
||||
sessionKey: grant.sessionKey,
|
||||
token: grant.token,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Map the HTTP aliases accepted by WebSocket clients onto their canonical schemes. */
|
||||
export function normalizeWebSocketProtocol(protocol: string): string {
|
||||
return protocol === "https:" ? "wss:" : protocol === "http:" ? "ws:" : protocol;
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import type { OpenClawConfig } from "../config/types.js";
|
||||
import { normalizeSecretInputString, resolveSecretInputRef } from "../config/types.secrets.js";
|
||||
import { materializeGatewayAuthSecretRefs } from "../gateway/auth-config-utils.js";
|
||||
import { assertExplicitGatewayAuthModeWhenBothConfigured } from "../gateway/auth-mode-policy.js";
|
||||
import { normalizeWebSocketProtocol } from "../gateway/net.js";
|
||||
import { normalizeWebSocketProtocol } from "../gateway/websocket-protocol.js";
|
||||
import { resolveAdvertisedLanHost } from "../infra/advertised-lan-host.js";
|
||||
import { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js";
|
||||
import {
|
||||
|
||||
@@ -328,11 +328,24 @@ describe("resolveInitialTuiAgentId", () => {
|
||||
cfg,
|
||||
fallbackAgentId: "main",
|
||||
initialSessionInput: "agent:main:incident",
|
||||
agentId: "ops",
|
||||
cwd: "/tmp/openclaw/projects/ops/src",
|
||||
}),
|
||||
).toBe("main");
|
||||
});
|
||||
|
||||
it("keeps an explicit global-session agent ahead of workspace inference", () => {
|
||||
expect(
|
||||
resolveInitialTuiAgentId({
|
||||
cfg,
|
||||
fallbackAgentId: "main",
|
||||
initialSessionInput: "global",
|
||||
agentId: "ops",
|
||||
cwd: "/tmp/openclaw",
|
||||
}),
|
||||
).toBe("ops");
|
||||
});
|
||||
|
||||
it("falls back when cwd has no matching workspace", () => {
|
||||
expect(
|
||||
resolveInitialTuiAgentId({
|
||||
|
||||
+23
-16
@@ -15,6 +15,7 @@ import type { CommandEntry } from "../../packages/gateway-protocol/src/index.js"
|
||||
import { resolveAgentIdByWorkspacePath, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { normalizeThinkLevel } from "../auto-reply/thinking.shared.js";
|
||||
import { getRuntimeConfig, type OpenClawConfig } from "../config/config.js";
|
||||
import { resolveCanonicalMainSessionKey } from "../config/sessions/main-session-key.js";
|
||||
import { resolveCurrentOpenClawCliInvocation } from "../infra/openclaw-cli-invocation.js";
|
||||
import { tryProcessCwd } from "../infra/safe-cwd.js";
|
||||
import { registerUncaughtExceptionHandler } from "../infra/unhandled-rejections.js";
|
||||
@@ -28,7 +29,6 @@ import {
|
||||
resolveTrustedWindowsCmdExe,
|
||||
} from "../process/windows-command.js";
|
||||
import {
|
||||
buildAgentMainSessionKey,
|
||||
normalizeAgentId,
|
||||
normalizeMainKey,
|
||||
parseAgentSessionKey,
|
||||
@@ -91,6 +91,8 @@ const SESSION_SUBSCRIPTION_MAX_ATTEMPTS = 5;
|
||||
const SESSION_SUBSCRIPTION_RETRY_DELAY_MS = 25;
|
||||
|
||||
type RunTuiOptions = TuiOptions & {
|
||||
/** Explicit owner for a global session key, which cannot carry an agent prefix itself. */
|
||||
agentId?: string;
|
||||
backend?: TuiBackend;
|
||||
submitBurstWindowMs?: number;
|
||||
ctrlCExitWindowMs?: number;
|
||||
@@ -167,12 +169,10 @@ export function resolveTuiSessionKey(params: {
|
||||
}) {
|
||||
const trimmed = (params.raw ?? "").trim();
|
||||
if (!trimmed) {
|
||||
if (params.sessionScope === "global") {
|
||||
return "global";
|
||||
}
|
||||
return buildAgentMainSessionKey({
|
||||
return resolveCanonicalMainSessionKey({
|
||||
agentId: params.currentAgentId,
|
||||
mainKey: params.sessionMainKey,
|
||||
sessionScope: params.sessionScope,
|
||||
});
|
||||
}
|
||||
const parsed = parseAgentSessionKey(trimmed);
|
||||
@@ -195,11 +195,12 @@ export function resolveInitialTuiAgentId(params: {
|
||||
cfg: OpenClawConfig;
|
||||
fallbackAgentId: string;
|
||||
initialSessionInput?: string;
|
||||
agentId?: string;
|
||||
cwd?: string;
|
||||
}) {
|
||||
const parsed = parseAgentSessionKey((params.initialSessionInput ?? "").trim());
|
||||
if (parsed?.agentId) {
|
||||
return normalizeAgentId(parsed.agentId);
|
||||
const explicitAgentId = resolveExplicitInitialTuiAgentId(params);
|
||||
if (explicitAgentId) {
|
||||
return explicitAgentId;
|
||||
}
|
||||
|
||||
const cwd = params.cwd ?? tryProcessCwd();
|
||||
@@ -211,6 +212,15 @@ export function resolveInitialTuiAgentId(params: {
|
||||
return normalizeAgentId(params.fallbackAgentId);
|
||||
}
|
||||
|
||||
function resolveExplicitInitialTuiAgentId(params: {
|
||||
initialSessionInput?: string;
|
||||
agentId?: string;
|
||||
}): string | null {
|
||||
const parsed = parseAgentSessionKey((params.initialSessionInput ?? "").trim());
|
||||
const explicitAgentId = parsed?.agentId ?? params.agentId?.trim();
|
||||
return explicitAgentId ? normalizeAgentId(explicitAgentId) : null;
|
||||
}
|
||||
|
||||
export function resolveGatewayDisconnectState(
|
||||
input: {
|
||||
details?: unknown;
|
||||
@@ -598,10 +608,15 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
|
||||
const sessionScope = (config.session?.scope ?? "per-sender") as SessionScope;
|
||||
const sessionMainKey = normalizeMainKey(config.session?.mainKey);
|
||||
const agentDefaultId = resolveDefaultAgentId(config);
|
||||
const initialSessionAgentId = resolveExplicitInitialTuiAgentId({
|
||||
initialSessionInput,
|
||||
agentId: opts.agentId,
|
||||
});
|
||||
let currentAgentId = resolveInitialTuiAgentId({
|
||||
cfg: config,
|
||||
fallbackAgentId: agentDefaultId,
|
||||
initialSessionInput,
|
||||
agentId: opts.agentId,
|
||||
});
|
||||
const agentNames = new Map<string, string>();
|
||||
let currentSessionKey = "";
|
||||
@@ -1248,14 +1263,6 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
|
||||
},
|
||||
};
|
||||
|
||||
const initialSessionAgentId = (() => {
|
||||
if (!initialSessionInput) {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseAgentSessionKey(initialSessionInput);
|
||||
return parsed ? normalizeAgentId(parsed.agentId) : null;
|
||||
})();
|
||||
|
||||
const sessionActions = createSessionActions({
|
||||
client,
|
||||
chatLog,
|
||||
|
||||
@@ -473,6 +473,7 @@ describe("package-openclaw-for-docker", () => {
|
||||
const originalPackageJson = `${JSON.stringify(
|
||||
{
|
||||
dependencies: { "@openclaw/ai": "workspace:*", "dep-a": "1.2.3" },
|
||||
devDependencies: { "@openclaw/session-url-contract": "workspace:*" },
|
||||
files: ["dist"],
|
||||
name: "openclaw",
|
||||
version: "2026.6.17",
|
||||
@@ -518,8 +519,10 @@ describe("package-openclaw-for-docker", () => {
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
|
||||
bundleDependencies: string[];
|
||||
dependencies: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
};
|
||||
expect(packageJson.dependencies["@openclaw/ai"]).toBe("2026.6.17");
|
||||
expect(packageJson.devDependencies?.["@openclaw/session-url-contract"]).toBe("workspace:*");
|
||||
expect(packageJson.bundleDependencies).toContain("@openclaw/ai");
|
||||
expect(fs.existsSync(path.join(installedAiPath, "original-marker"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(installedAiPath, "runtime.js"))).toBe(true);
|
||||
@@ -540,6 +543,60 @@ describe("package-openclaw-for-docker", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses the source manifest lifecycle for ignore-scripts package artifacts", async () => {
|
||||
const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-docker-manifest-source-"));
|
||||
const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-docker-manifest-output-"));
|
||||
const scriptsDir = path.join(sourceDir, "scripts");
|
||||
const packageJsonPath = path.join(sourceDir, "package.json");
|
||||
const originalPackageJson = `${JSON.stringify(
|
||||
{
|
||||
devDependencies: {
|
||||
"@openclaw/session-url-contract": "workspace:*",
|
||||
vitest: "4.1.10",
|
||||
},
|
||||
name: "openclaw",
|
||||
version: "2026.8.1",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
fs.mkdirSync(scriptsDir);
|
||||
fs.copyFileSync(
|
||||
path.join(process.cwd(), "scripts", "package-manifest.mjs"),
|
||||
path.join(scriptsDir, "package-manifest.mjs"),
|
||||
);
|
||||
fs.writeFileSync(packageJsonPath, originalPackageJson);
|
||||
|
||||
try {
|
||||
const tarball = await packOpenClawPackageForDocker(sourceDir, outputDir, {
|
||||
...skipDocsMapLifecycle,
|
||||
prepareBundledAiRuntime: skipBundledAiRuntime,
|
||||
prepareChangelog: async () => {},
|
||||
restoreChangelog: async () => {},
|
||||
runCaptureImpl: async () => {
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
|
||||
devDependencies?: Record<string, string>;
|
||||
};
|
||||
expect(packageJson.devDependencies).toEqual({ vitest: "4.1.10" });
|
||||
const packedPath = path.join(outputDir, "openclaw-2026.8.1.tgz");
|
||||
fs.writeFileSync(packedPath, "package");
|
||||
return `${path.basename(packedPath)}\n`;
|
||||
},
|
||||
});
|
||||
|
||||
expect(tarball).toBe(path.join(outputDir, "openclaw-2026.8.1.tgz"));
|
||||
expect(fs.readFileSync(packageJsonPath, "utf8")).toBe(originalPackageJson);
|
||||
expect(
|
||||
fs.existsSync(
|
||||
path.join(sourceDir, ".artifacts", "package-manifest", "package.json.prepack-backup"),
|
||||
),
|
||||
).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(sourceDir, { recursive: true, force: true });
|
||||
fs.rmSync(outputDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves pre-AI-workspace package sources unchanged", async () => {
|
||||
const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-docker-legacy-source-"));
|
||||
const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-docker-legacy-output-"));
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
// OpenClaw prepack tests validate package prepack output.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as tar from "tar";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
@@ -123,6 +130,62 @@ describe("collectSourcePackWorkspaceDependencyErrors", () => {
|
||||
}),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("omits build-only workspace dependencies from direct pnpm pack manifests", () => {
|
||||
const rootDir = tempDirs.make("openclaw-direct-pack-manifest-");
|
||||
const packDir = path.join(rootDir, "pack");
|
||||
const extractDir = path.join(rootDir, "extract");
|
||||
const scriptsDir = path.join(rootDir, "scripts");
|
||||
const originalPackageJson = `${JSON.stringify(
|
||||
{
|
||||
name: "openclaw-direct-pack-manifest",
|
||||
version: "2099.1.2-test.0",
|
||||
scripts: {
|
||||
prepack: "node scripts/package-manifest.mjs prepare",
|
||||
postpack: "node scripts/package-manifest.mjs restore",
|
||||
},
|
||||
devDependencies: {
|
||||
"@openclaw/session-url-contract": "workspace:*",
|
||||
vitest: "4.1.10",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
mkdirSync(packDir);
|
||||
mkdirSync(extractDir);
|
||||
mkdirSync(scriptsDir);
|
||||
writeFileSync(path.join(rootDir, "package.json"), originalPackageJson);
|
||||
copyFileSync(
|
||||
path.join(process.cwd(), "scripts", "package-manifest.mjs"),
|
||||
path.join(scriptsDir, "package-manifest.mjs"),
|
||||
);
|
||||
|
||||
const packed = spawnSync("pnpm", ["pack", "--silent", "--pack-destination", packDir], {
|
||||
cwd: rootDir,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
expect(packed.status, packed.stderr).toBe(0);
|
||||
const tarballs = readdirSync(packDir).filter((entry) => entry.endsWith(".tgz"));
|
||||
expect(tarballs).toHaveLength(1);
|
||||
const tarballName = tarballs[0];
|
||||
if (!tarballName) {
|
||||
throw new Error("pnpm pack did not produce the expected tarball");
|
||||
}
|
||||
tar.x({ cwd: extractDir, file: path.join(packDir, tarballName), sync: true });
|
||||
|
||||
const packedPackageJson = JSON.parse(
|
||||
readFileSync(path.join(extractDir, "package", "package.json"), "utf8"),
|
||||
) as { devDependencies?: Record<string, string> };
|
||||
expect(packedPackageJson.devDependencies).toEqual({ vitest: "4.1.10" });
|
||||
expect(readFileSync(path.join(rootDir, "package.json"), "utf8")).toBe(originalPackageJson);
|
||||
expect(
|
||||
existsSync(
|
||||
path.join(rootDir, ".artifacts", "package-manifest", "package.json.prepack-backup"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePrepackAllowUnreleasedChangelog", () => {
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// Keep legacy UI imports on the package-owned normalization contract.
|
||||
export { gatewayOriginScope as normalizeGatewayTokenScope } from "@openclaw/gateway-client/browser";
|
||||
@@ -1,4 +1,4 @@
|
||||
import { normalizeGatewayTokenScope } from "../../app/gateway-scope.ts";
|
||||
import { gatewayOriginScope } from "@openclaw/gateway-client/browser";
|
||||
import { normalizeAgentId } from "../../lib/sessions/session-key.ts";
|
||||
import { normalizeOptionalString } from "../../lib/string-coerce.ts";
|
||||
import { getSafeLocalStorage } from "../../local-storage.ts";
|
||||
@@ -18,7 +18,7 @@ type PersistedPreferences = {
|
||||
};
|
||||
|
||||
function storageKey(gatewayUrl: string): string {
|
||||
return `${STORAGE_KEY_PREFIX}${normalizeGatewayTokenScope(gatewayUrl)}`;
|
||||
return `${STORAGE_KEY_PREFIX}${gatewayOriginScope(gatewayUrl)}`;
|
||||
}
|
||||
|
||||
function normalizePreference(value: unknown): NewSessionPreference | null {
|
||||
|
||||
Reference in New Issue
Block a user