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:
Peter Steinberger
2026-08-09 16:44:25 -07:00
committed by GitHub
parent 3bdfd60caa
commit 055a2dc6ce
39 changed files with 2189 additions and 225 deletions
+10 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+1
View File
@@ -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",
+3
View File
@@ -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)
+2
View File
@@ -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);
}
+2
View File
@@ -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),
});
+2
View File
@@ -0,0 +1,2 @@
export function restorePackageManifest(cwd?: string): Promise<boolean>;
export function preparePackageManifest(cwd?: string): Promise<boolean>;
+110
View File
@@ -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();
}
+59 -11
View File
@@ -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,
+1
View File
@@ -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]],
+74 -1
View File
@@ -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
View File
@@ -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);
},
);
}
+61
View File
@@ -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 {
+2
View File
@@ -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",
+160
View File
@@ -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
View File
@@ -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.
+564
View File
@@ -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,
);
});
});
+280
View File
@@ -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 };
}
+236
View File
@@ -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
View File
@@ -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);
+12 -6
View File
@@ -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");
+11
View File
@@ -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 -4
View File
@@ -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. */
+12
View File
@@ -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);
+5
View File
@@ -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),
};
+10 -3
View File
@@ -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,
+4 -3
View File
@@ -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
View File
@@ -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 {
+36
View File
@@ -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(
+6 -1
View File
@@ -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,
+4
View File
@@ -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;
}
+1 -1
View File
@@ -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 {
+13
View File
@@ -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
View File
@@ -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-"));
+64 -1
View File
@@ -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", () => {
-2
View File
@@ -1,2 +0,0 @@
// Keep legacy UI imports on the package-owned normalization contract.
export { gatewayOriginScope as normalizeGatewayTokenScope } from "@openclaw/gateway-client/browser";
+2 -2
View File
@@ -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 {