@totalreclaw/totalreclaw 3.3.1-rc.8 → 3.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +268 -1
- package/SKILL.md +29 -23
- package/api-client.ts +18 -11
- package/claims-helper.ts +47 -1
- package/config.ts +108 -4
- package/confirm-indexed.ts +191 -0
- package/crypto.ts +10 -2
- package/dist/api-client.js +226 -0
- package/dist/billing-cache.js +100 -0
- package/dist/claims-helper.js +624 -0
- package/dist/config.js +297 -0
- package/dist/confirm-indexed.js +127 -0
- package/dist/consolidation.js +258 -0
- package/dist/contradiction-sync.js +1034 -0
- package/dist/crypto.js +138 -0
- package/dist/digest-sync.js +361 -0
- package/dist/download-ux.js +63 -0
- package/dist/embedder-cache.js +185 -0
- package/dist/embedder-loader.js +121 -0
- package/dist/embedder-network.js +301 -0
- package/dist/embedding.js +141 -0
- package/dist/extractor.js +1225 -0
- package/dist/first-run.js +103 -0
- package/dist/fs-helpers.js +725 -0
- package/dist/gateway-url.js +197 -0
- package/dist/generate-mnemonic.js +13 -0
- package/dist/hot-cache-wrapper.js +101 -0
- package/dist/import-adapters/base-adapter.js +64 -0
- package/dist/import-adapters/chatgpt-adapter.js +238 -0
- package/dist/import-adapters/claude-adapter.js +114 -0
- package/dist/import-adapters/gemini-adapter.js +201 -0
- package/dist/import-adapters/index.js +26 -0
- package/dist/import-adapters/mcp-memory-adapter.js +219 -0
- package/dist/import-adapters/mem0-adapter.js +158 -0
- package/dist/import-adapters/types.js +1 -0
- package/dist/index.js +5388 -0
- package/dist/llm-client.js +687 -0
- package/dist/llm-profile-reader.js +346 -0
- package/dist/lsh.js +62 -0
- package/dist/onboarding-cli.js +750 -0
- package/dist/pair-cli.js +344 -0
- package/dist/pair-crypto.js +359 -0
- package/dist/pair-http.js +404 -0
- package/dist/pair-page.js +826 -0
- package/dist/pair-qr.js +107 -0
- package/dist/pair-remote-client.js +410 -0
- package/dist/pair-session-store.js +566 -0
- package/dist/pin.js +556 -0
- package/dist/qa-bug-report.js +301 -0
- package/dist/relay-headers.js +44 -0
- package/dist/reranker.js +409 -0
- package/dist/retype-setscope.js +368 -0
- package/dist/semantic-dedup.js +75 -0
- package/dist/subgraph-search.js +289 -0
- package/dist/subgraph-store.js +694 -0
- package/dist/tool-gating.js +58 -0
- package/download-ux.ts +91 -0
- package/embedder-cache.ts +230 -0
- package/embedder-loader.ts +189 -0
- package/embedder-network.ts +350 -0
- package/embedding.ts +118 -27
- package/fs-helpers.ts +277 -0
- package/gateway-url.ts +57 -9
- package/index.ts +469 -250
- package/llm-client.ts +4 -3
- package/lsh.ts +7 -2
- package/onboarding-cli.ts +114 -1
- package/package.json +24 -5
- package/pair-cli.ts +76 -8
- package/pair-crypto.ts +34 -24
- package/pair-page.ts +28 -17
- package/pair-qr.ts +152 -0
- package/pair-remote-client.ts +540 -0
- package/pin.ts +31 -0
- package/qa-bug-report.ts +84 -2
- package/relay-headers.ts +50 -0
- package/reranker.ts +40 -0
- package/retype-setscope.ts +69 -8
- package/skill.json +1 -1
- package/subgraph-search.ts +4 -3
- package/subgraph-store.ts +15 -10
package/llm-client.ts
CHANGED
|
@@ -908,6 +908,7 @@ async function chatCompletionAnthropic(
|
|
|
908
908
|
// ---------------------------------------------------------------------------
|
|
909
909
|
|
|
910
910
|
// Embeddings are now generated locally via @huggingface/transformers
|
|
911
|
-
// (Harrier-OSS-v1-270M ONNX model). No API key needed.
|
|
912
|
-
//
|
|
913
|
-
|
|
911
|
+
// (Harrier-OSS-v1-270M ONNX model). No API key needed. The native deps +
|
|
912
|
+
// model are lazy-fetched from a pinned GitHub Release on first call —
|
|
913
|
+
// see embedding.ts + embedder-loader.ts.
|
|
914
|
+
export { generateEmbedding, getEmbeddingDims, getEmbeddingModelId, configureEmbedder } from './embedding.js';
|
package/lsh.ts
CHANGED
|
@@ -7,10 +7,15 @@
|
|
|
7
7
|
* Default parameters: 32 bits per table, 20 tables.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
// Lazy-load WASM
|
|
10
|
+
// Lazy-load WASM via createRequire. The shipped `dist/index.js` is ESM-only
|
|
11
|
+
// (`"type":"module"`) so the bare `require` global is undefined at runtime.
|
|
12
|
+
// See issue #124 for the bug this avoids; matches the pattern in
|
|
13
|
+
// claims-helper / consolidation / digest-sync / pin / retype-setscope.
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
const requireWasm = createRequire(import.meta.url);
|
|
11
16
|
let _WasmLshHasher: typeof import('@totalreclaw/core')['WasmLshHasher'] | null = null;
|
|
12
17
|
function getWasmLshHasher() {
|
|
13
|
-
if (!_WasmLshHasher) _WasmLshHasher =
|
|
18
|
+
if (!_WasmLshHasher) _WasmLshHasher = requireWasm('@totalreclaw/core').WasmLshHasher;
|
|
14
19
|
return _WasmLshHasher!;
|
|
15
20
|
}
|
|
16
21
|
|
package/onboarding-cli.ts
CHANGED
|
@@ -59,6 +59,23 @@ import {
|
|
|
59
59
|
// has one place to grow into later).
|
|
60
60
|
// ---------------------------------------------------------------------------
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* 3.3.1-rc.18 (issue #95) — deprecation warning for the interactive
|
|
64
|
+
* phrase-print branch. Emitted to STDERR (never stdout) so it is visible
|
|
65
|
+
* to humans but does not pollute any pipe consuming the wizard's output.
|
|
66
|
+
*
|
|
67
|
+
* The phrase-print branch will be REMOVED in the next RC after rc.18.
|
|
68
|
+
* Users running on a TTY can still complete the flow in rc.18; agents
|
|
69
|
+
* MUST use `--pair-only` or the `totalreclaw_pair` tool today.
|
|
70
|
+
*/
|
|
71
|
+
export const PHRASE_PRINT_DEPRECATION_WARNING =
|
|
72
|
+
'\nDEPRECATION (issue #95): the interactive `openclaw totalreclaw onboard` flow\n' +
|
|
73
|
+
' prints your recovery phrase to this terminal. This is being removed in the\n' +
|
|
74
|
+
' next release candidate. For agent / scripted invocation, use:\n' +
|
|
75
|
+
' openclaw totalreclaw onboard --pair-only\n' +
|
|
76
|
+
' which emits ONLY {pair_url, pin} JSON and routes the phrase through the\n' +
|
|
77
|
+
' browser flow (never on stdout).\n\n';
|
|
78
|
+
|
|
62
79
|
export const COPY = {
|
|
63
80
|
welcome:
|
|
64
81
|
'\nTotalReclaw — Secure onboarding\n\n' +
|
|
@@ -403,6 +420,11 @@ export async function runOnboardingWizard(deps: WizardDeps): Promise<WizardResul
|
|
|
403
420
|
}
|
|
404
421
|
|
|
405
422
|
if (choice === 'generate') {
|
|
423
|
+
// 3.3.1-rc.18 (issue #95) — deprecation banner on stderr ONLY.
|
|
424
|
+
// The phrase-print branch is scheduled for removal in the RC after
|
|
425
|
+
// rc.18; we keep it functional in rc.18 for back-compat with users
|
|
426
|
+
// running the wizard on a real TTY today.
|
|
427
|
+
io.stderr.write(PHRASE_PRINT_DEPRECATION_WARNING);
|
|
406
428
|
io.stdout.write(COPY.generateWarning);
|
|
407
429
|
io.stdout.write(COPY.importRemoteLimitation);
|
|
408
430
|
const mnemonic = genMnemonic();
|
|
@@ -674,6 +696,22 @@ export async function runNonInteractiveOnboard(
|
|
|
674
696
|
* --emit-phrase Include the plaintext phrase in the JSON payload
|
|
675
697
|
* (NOT recommended — the phrase lives in
|
|
676
698
|
* credentials.json; prefer reading it there).
|
|
699
|
+
*
|
|
700
|
+
* 3.3.1-rc.18 — `onboard` accepts:
|
|
701
|
+
* --pair-only Phrase-safe agent-shell flag (issue #95).
|
|
702
|
+
* Delegates to the pair flow and emits a single
|
|
703
|
+
* line of JSON `{v, pair_url, pin, expires_at_ms}`
|
|
704
|
+
* to stdout. Phrase NEVER touches stdout, stderr,
|
|
705
|
+
* or the logger in this mode. Use this for any
|
|
706
|
+
* agent-driven setup; it is the recommended path
|
|
707
|
+
* when a container-based agent does not have the
|
|
708
|
+
* `totalreclaw_pair` tool injected.
|
|
709
|
+
*
|
|
710
|
+
* Requires `pairSessionsPath` + `renderPairingUrl`
|
|
711
|
+
* to be supplied to `registerOnboardingCli`. If
|
|
712
|
+
* absent, `--pair-only` exits non-zero with a
|
|
713
|
+
* clear message instead of falling through to the
|
|
714
|
+
* phrase-print branch.
|
|
677
715
|
*/
|
|
678
716
|
export function registerOnboardingCli(
|
|
679
717
|
program: import('commander').Command,
|
|
@@ -683,6 +721,10 @@ export function registerOnboardingCli(
|
|
|
683
721
|
logger: { info(msg: string): void; warn(msg: string): void; error(msg: string): void };
|
|
684
722
|
/** Caller-supplied helper for scope-address derivation. Optional — when absent, JSON output omits `scope_address`. */
|
|
685
723
|
deriveScopeAddress?: (mnemonic: string) => Promise<string | undefined>;
|
|
724
|
+
/** Caller-supplied path to the pair-session store. Required for `--pair-only`. */
|
|
725
|
+
pairSessionsPath?: string;
|
|
726
|
+
/** Caller-supplied URL renderer for the pair flow. Required for `--pair-only`. */
|
|
727
|
+
renderPairingUrl?: (session: import('./pair-session-store.js').PairSession) => string;
|
|
686
728
|
},
|
|
687
729
|
): void {
|
|
688
730
|
const tr = program
|
|
@@ -690,12 +732,13 @@ export function registerOnboardingCli(
|
|
|
690
732
|
.description('TotalReclaw encrypted memory — secure onboarding + status');
|
|
691
733
|
|
|
692
734
|
tr.command('onboard')
|
|
693
|
-
.description('Interactive onboarding: generate or import a recovery phrase (runs locally, no LLM)')
|
|
735
|
+
.description('Interactive onboarding: generate or import a recovery phrase (runs locally, no LLM). For agent-driven setup prefer --pair-only.')
|
|
694
736
|
.option('--non-interactive', 'Exit non-zero if any input would be prompted for (agent-driven use)')
|
|
695
737
|
.option('--json', 'Emit the result as a structured JSON payload. Only valid with --non-interactive.')
|
|
696
738
|
.option('--mode <mode>', 'generate | restore — skip the menu prompt')
|
|
697
739
|
.option('--phrase <phrase>', 'Recovery phrase for --mode restore. `-` reads from stdin.')
|
|
698
740
|
.option('--emit-phrase', 'Include the plaintext phrase in the JSON payload (not recommended). Default: false.')
|
|
741
|
+
.option('--pair-only', 'Phrase-safe agent-invocation mode (issue #95). Emits ONLY {v,pair_url,pin,expires_at_ms} JSON to stdout via the pair flow. Phrase never touches stdout/stderr/logger. RECOMMENDED for any agent or scripted invocation.')
|
|
699
742
|
.action(async (...actionArgs: unknown[]) => {
|
|
700
743
|
// commander: (options, cmd)
|
|
701
744
|
const cliOpts = (actionArgs[0] ?? {}) as {
|
|
@@ -704,8 +747,70 @@ export function registerOnboardingCli(
|
|
|
704
747
|
mode?: string;
|
|
705
748
|
phrase?: string;
|
|
706
749
|
emitPhrase?: boolean;
|
|
750
|
+
pairOnly?: boolean;
|
|
707
751
|
};
|
|
708
752
|
|
|
753
|
+
// ---------------------------------------------------------------
|
|
754
|
+
// 3.3.1-rc.18 — `--pair-only` (issue #95)
|
|
755
|
+
//
|
|
756
|
+
// Phrase-safe agent-shell flag. Delegates to the pair flow and
|
|
757
|
+
// emits a single line of JSON `{v, pair_url, pin, expires_at_ms}`
|
|
758
|
+
// to stdout. By construction:
|
|
759
|
+
// - The pair flow is x25519-only — pair-crypto.ts does NOT
|
|
760
|
+
// import @scure/bip39 and never touches a recovery phrase.
|
|
761
|
+
// - No interactive prompts, no readline, no @scure/bip39 import
|
|
762
|
+
// in this code path. Phrase never enters stdout/stderr/logger.
|
|
763
|
+
// - Stays silent on status transitions (the runPairCli
|
|
764
|
+
// `pair-only` output mode suppresses banners, spinners, and
|
|
765
|
+
// all human-readable copy).
|
|
766
|
+
//
|
|
767
|
+
// This MUST be the path agents take when they need to set up
|
|
768
|
+
// TotalReclaw via a shell. The interactive phrase-print branch
|
|
769
|
+
// below is deprecated for that use case and emits a warning when
|
|
770
|
+
// the user falls through to it.
|
|
771
|
+
// ---------------------------------------------------------------
|
|
772
|
+
if (cliOpts.pairOnly) {
|
|
773
|
+
if (!opts.pairSessionsPath || !opts.renderPairingUrl) {
|
|
774
|
+
process.stderr.write(
|
|
775
|
+
'--pair-only is unavailable: this OpenClaw build did not wire the pair flow into the onboard CLI. ' +
|
|
776
|
+
'Use `openclaw totalreclaw pair generate --url-pin-only` instead.\n',
|
|
777
|
+
);
|
|
778
|
+
process.exit(1);
|
|
779
|
+
}
|
|
780
|
+
// Resolve mode. --mode restore is incompatible with --pair-only
|
|
781
|
+
// since pair flow's "import" mode runs in the browser, not in
|
|
782
|
+
// the CLI. Default to 'generate' silently.
|
|
783
|
+
const pairMode = cliOpts.mode === 'restore' || cliOpts.mode === 'import' ? 'import' : 'generate';
|
|
784
|
+
|
|
785
|
+
// Lazy import — keeps pair-cli + qrcode-terminal off the
|
|
786
|
+
// onboarding hot path when --pair-only is not used.
|
|
787
|
+
const { runPairCli, defaultRenderQr, buildDefaultPairCliIo } = await import('./pair-cli.js');
|
|
788
|
+
const io = buildDefaultPairCliIo();
|
|
789
|
+
try {
|
|
790
|
+
const outcome = await runPairCli(pairMode, {
|
|
791
|
+
sessionsPath: opts.pairSessionsPath,
|
|
792
|
+
renderPairingUrl: opts.renderPairingUrl,
|
|
793
|
+
renderQr: defaultRenderQr,
|
|
794
|
+
io,
|
|
795
|
+
outputMode: 'pair-only',
|
|
796
|
+
});
|
|
797
|
+
if (outcome.status !== 'completed') {
|
|
798
|
+
process.exit(outcome.status === 'canceled' ? 130 : 1);
|
|
799
|
+
}
|
|
800
|
+
process.exit(0);
|
|
801
|
+
} catch (err) {
|
|
802
|
+
// CRITICAL: this catch MUST NOT include the phrase, the
|
|
803
|
+
// mnemonic, or any user secret in the message. The pair flow
|
|
804
|
+
// does not produce phrase material, so this is structurally
|
|
805
|
+
// safe — but defense-in-depth: emit a fixed error string.
|
|
806
|
+
opts.logger.error(
|
|
807
|
+
`pair-only delegation crashed: ${err instanceof Error ? err.message : String(err)}`,
|
|
808
|
+
);
|
|
809
|
+
process.stderr.write('--pair-only failed (see logs).\n');
|
|
810
|
+
process.exit(2);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
709
814
|
if (cliOpts.nonInteractive) {
|
|
710
815
|
// Non-interactive path — no readline, no prompts.
|
|
711
816
|
const mode: 'generate' | 'restore' | null =
|
|
@@ -744,10 +849,18 @@ export function registerOnboardingCli(
|
|
|
744
849
|
});
|
|
745
850
|
|
|
746
851
|
if (cliOpts.json) {
|
|
852
|
+
// 3.3.1-rc.18 (issue #95) — emit deprecation on stderr when
|
|
853
|
+
// the JSON payload is about to include the plaintext phrase.
|
|
854
|
+
// stderr is intentional: stdout must remain a single
|
|
855
|
+
// machine-parseable JSON line.
|
|
856
|
+
if (cliOpts.emitPhrase && result.ok && result.mnemonic) {
|
|
857
|
+
process.stderr.write(PHRASE_PRINT_DEPRECATION_WARNING);
|
|
858
|
+
}
|
|
747
859
|
process.stdout.write(JSON.stringify(result) + '\n');
|
|
748
860
|
} else {
|
|
749
861
|
if (result.ok) {
|
|
750
862
|
if (result.mnemonic) {
|
|
863
|
+
process.stderr.write(PHRASE_PRINT_DEPRECATION_WARNING);
|
|
751
864
|
process.stderr.write(
|
|
752
865
|
'WARNING: --emit-phrase was set. The plaintext recovery phrase was returned.\n' +
|
|
753
866
|
'For agent-driven flows, prefer reading ~/.totalreclaw/credentials.json directly ' +
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@totalreclaw/totalreclaw",
|
|
3
|
-
"version": "3.3.1
|
|
3
|
+
"version": "3.3.1",
|
|
4
4
|
"description": "End-to-end encrypted, agent-portable memory for OpenClaw and any LLM-agent runtime. XChaCha20-Poly1305 with protobuf v4 + on-chain Memory Taxonomy v1 (claim / preference / directive / commitment / episode / summary).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -33,11 +33,22 @@
|
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@totalreclaw/client": "^1.2.0",
|
|
35
35
|
"@totalreclaw/core": "^2.1.1",
|
|
36
|
+
"@types/qrcode": "^1.5.6",
|
|
37
|
+
"@types/ws": "^8.5.12",
|
|
38
|
+
"qrcode": "^1.5.4",
|
|
39
|
+
"qrcode-terminal": "^0.12.0",
|
|
40
|
+
"ws": "^8.18.3"
|
|
41
|
+
},
|
|
42
|
+
"//": "@huggingface/transformers + onnxruntime-node deliberately omitted from runtime deps. They are heavy native bundles (~700 MB peak install RAM) that OOM-killed the OpenClaw gateway on small VPS during `openclaw plugins install` in rc.21 (issue: 3.7 GB Hetzner host). rc.22 ships them via a lazy GitHub-Releases bundle (`embedder-v1.tar.gz`) downloaded on first call to embed(). See `embedder-network.ts` + `scripts/build-embedder-bundle.mjs`. The dev-deps below are for type-checking + bundle generation only; npm install of the plugin tarball never installs them.",
|
|
43
|
+
"devDependencies": {
|
|
36
44
|
"@huggingface/transformers": "^4.0.1",
|
|
37
45
|
"onnxruntime-node": "^1.24.0",
|
|
38
|
-
"
|
|
46
|
+
"typescript": "^5.5.0"
|
|
39
47
|
},
|
|
48
|
+
"main": "./dist/index.js",
|
|
49
|
+
"types": "./dist/index.d.ts",
|
|
40
50
|
"files": [
|
|
51
|
+
"dist/",
|
|
41
52
|
"*.ts",
|
|
42
53
|
"import-adapters/",
|
|
43
54
|
"!**/*.test.ts",
|
|
@@ -50,13 +61,21 @@
|
|
|
50
61
|
"skill.json"
|
|
51
62
|
],
|
|
52
63
|
"scripts": {
|
|
53
|
-
"
|
|
64
|
+
"build": "rm -rf dist && tsc -p tsconfig.json --noCheck",
|
|
65
|
+
"verify-tarball": "node ../scripts/verify-tarball.mjs",
|
|
66
|
+
"test": "npx tsx manifest-shape.test.ts && npx tsx config-schema.test.ts && npx tsx config.test.ts && npx tsx relay-headers.test.ts && npx tsx scope-address-visible.test.ts && npx tsx llm-profile-reader.test.ts && npx tsx llm-client.test.ts && npx tsx llm-client-retry.test.ts && npx tsx gateway-url.test.ts && npx tsx retype-setscope.test.ts && npx tsx tool-gating.test.ts && npx tsx onboarding-noninteractive.test.ts && npx tsx pair-cli-json.test.ts && npx tsx pair-qr.test.ts && npx tsx pair-remote-client.test.ts && npx tsx qa-bug-report.test.ts && npx tsx nonce-serialization.test.ts && npx tsx phrase-safety-registry.test.ts && npx tsx test_issue_92_onnx_download_ux.test.ts && npx tsx onboard-pair-only.test.ts && npx tsx import-time-smoke.test.ts && npx tsx install-staging-cleanup.test.ts && npx tsx partial-install-detection.test.ts && npx tsx install-reload-idempotency.test.ts && npx tsx json-stdout-cleanliness.test.ts",
|
|
67
|
+
"smoke:dist": "npx tsx dist-esm-smoke.test.ts",
|
|
54
68
|
"check-scanner": "node ../scripts/check-scanner.mjs",
|
|
55
|
-
"
|
|
69
|
+
"check-version-drift": "node ../scripts/check-version-drift.mjs",
|
|
70
|
+
"sync-version": "node ../scripts/sync-version.mjs",
|
|
71
|
+
"preinstall": "node -e \"try{require('fs').writeFileSync('.tr-partial-install','');}catch{}\"",
|
|
72
|
+
"postinstall": "node -e \"try{require('fs').unlinkSync('.tr-partial-install');}catch{}\"",
|
|
73
|
+
"prepack": "npm run build",
|
|
74
|
+
"prepublishOnly": "node ../scripts/check-scanner.mjs && node ../scripts/check-version-drift.mjs"
|
|
56
75
|
},
|
|
57
76
|
"openclaw": {
|
|
58
77
|
"extensions": [
|
|
59
|
-
"./index.
|
|
78
|
+
"./dist/index.js"
|
|
60
79
|
]
|
|
61
80
|
}
|
|
62
81
|
}
|
package/pair-cli.ts
CHANGED
|
@@ -85,8 +85,22 @@ export interface PairCliOutcome {
|
|
|
85
85
|
* as the session reaches a terminal state — same status-code
|
|
86
86
|
* semantics as 'human' (0 on completed, 1 on expired/rejected/error,
|
|
87
87
|
* 130 on canceled).
|
|
88
|
+
* - 'url-pin': (3.3.1-rc.15, issue #87) headless container-agent fallback.
|
|
89
|
+
* Emits ONLY `{ v, url, pin, expires_at_ms }` — no QR ASCII, no SID,
|
|
90
|
+
* no mode echo. Use when a container-based agent cannot see the
|
|
91
|
+
* `totalreclaw_pair` tool (OpenClaw gateway-to-container tool-injection
|
|
92
|
+
* gap) and must shell out to the CLI. Guarantees zero phrase material
|
|
93
|
+
* on stdout by construction — pair-crypto is x25519-only and the slim
|
|
94
|
+
* payload carries nothing BIP-39-adjacent.
|
|
95
|
+
* - 'pair-only': (3.3.1-rc.18, issue #95) the same surface as 'url-pin',
|
|
96
|
+
* but the URL field is named `pair_url` (matching the spec wording
|
|
97
|
+
* for `openclaw totalreclaw onboard --pair-only`). Used by the
|
|
98
|
+
* onboard CLI's `--pair-only` flag to provide a phrase-safe
|
|
99
|
+
* alternative to the interactive phrase-print path. Emits ONLY
|
|
100
|
+
* `{ v, pair_url, pin, expires_at_ms }`. Same zero-phrase invariant
|
|
101
|
+
* as 'url-pin' — the underlying pair flow does no BIP-39 work.
|
|
88
102
|
*/
|
|
89
|
-
export type PairCliOutputMode = 'human' | 'json';
|
|
103
|
+
export type PairCliOutputMode = 'human' | 'json' | 'url-pin' | 'pair-only';
|
|
90
104
|
|
|
91
105
|
/**
|
|
92
106
|
* JSON payload emitted by runPairCli when outputMode === 'json'. Printed
|
|
@@ -103,6 +117,31 @@ export interface PairCliJsonPayload {
|
|
|
103
117
|
qr_ascii: string;
|
|
104
118
|
}
|
|
105
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Slim payload for outputMode === 'url-pin'. Intentionally a subset of
|
|
122
|
+
* `PairCliJsonPayload` with no QR ASCII, SID, or mode echo. Issue #87.
|
|
123
|
+
*/
|
|
124
|
+
export interface PairCliUrlPinPayload {
|
|
125
|
+
v: 1;
|
|
126
|
+
url: string;
|
|
127
|
+
pin: string;
|
|
128
|
+
expires_at_ms: number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Slim payload for outputMode === 'pair-only'. Same shape as
|
|
133
|
+
* `PairCliUrlPinPayload` but with `pair_url` instead of `url` — the
|
|
134
|
+
* key name matches the spec for `onboard --pair-only` (issue #95).
|
|
135
|
+
* Phrase invariant: zero BIP-39 material on stdout by construction
|
|
136
|
+
* (the pair flow is x25519-only).
|
|
137
|
+
*/
|
|
138
|
+
export interface PairCliPairOnlyPayload {
|
|
139
|
+
v: 1;
|
|
140
|
+
pair_url: string;
|
|
141
|
+
pin: string;
|
|
142
|
+
expires_at_ms: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
106
145
|
// ---------------------------------------------------------------------------
|
|
107
146
|
// Default stdout IO
|
|
108
147
|
// ---------------------------------------------------------------------------
|
|
@@ -213,9 +252,13 @@ export async function runPairCli(
|
|
|
213
252
|
return { status: 'error', error: msg };
|
|
214
253
|
}
|
|
215
254
|
|
|
216
|
-
// 2.
|
|
255
|
+
// 2. Build the URL unconditionally, but only render the QR for modes
|
|
256
|
+
// that actually emit it. url-pin and pair-only modes skip the
|
|
257
|
+
// renderer entirely — no CPU cost, no qrcode-terminal import, no
|
|
258
|
+
// ASCII on stdout.
|
|
217
259
|
const url = deps.renderPairingUrl(session);
|
|
218
|
-
const
|
|
260
|
+
const skipsQr = outputMode === 'url-pin' || outputMode === 'pair-only';
|
|
261
|
+
const qrAscii = skipsQr ? '' : await new Promise<string>((resolve) => {
|
|
219
262
|
// Guard against QR renderers that never fire their callback (shouldn't
|
|
220
263
|
// happen with qrcode-terminal, but defensive): a 10-second timeout
|
|
221
264
|
// returns an empty string so we never hang the pairing flow.
|
|
@@ -241,8 +284,25 @@ export async function runPairCli(
|
|
|
241
284
|
}
|
|
242
285
|
});
|
|
243
286
|
|
|
244
|
-
// 3. Emit the visible surface (JSON first — single
|
|
245
|
-
|
|
287
|
+
// 3. Emit the visible surface (JSON/url-pin/pair-only first — single
|
|
288
|
+
// line — or human copy).
|
|
289
|
+
if (outputMode === 'url-pin') {
|
|
290
|
+
const payload: PairCliUrlPinPayload = {
|
|
291
|
+
v: 1,
|
|
292
|
+
url,
|
|
293
|
+
pin: session.secondaryCode,
|
|
294
|
+
expires_at_ms: session.expiresAtMs,
|
|
295
|
+
};
|
|
296
|
+
stdout.write(JSON.stringify(payload) + '\n');
|
|
297
|
+
} else if (outputMode === 'pair-only') {
|
|
298
|
+
const payload: PairCliPairOnlyPayload = {
|
|
299
|
+
v: 1,
|
|
300
|
+
pair_url: url,
|
|
301
|
+
pin: session.secondaryCode,
|
|
302
|
+
expires_at_ms: session.expiresAtMs,
|
|
303
|
+
};
|
|
304
|
+
stdout.write(JSON.stringify(payload) + '\n');
|
|
305
|
+
} else if (outputMode === 'json') {
|
|
246
306
|
const payload: PairCliJsonPayload = {
|
|
247
307
|
v: 1,
|
|
248
308
|
sid: session.sid,
|
|
@@ -276,7 +336,10 @@ export async function runPairCli(
|
|
|
276
336
|
canceled = true;
|
|
277
337
|
});
|
|
278
338
|
|
|
279
|
-
// 5. Poll
|
|
339
|
+
// 5. Poll — status transitions only surface in human mode; json /
|
|
340
|
+
// url-pin / pair-only modes stay silent after the single payload
|
|
341
|
+
// line so agents parsing stdout get one JSON line and an exit
|
|
342
|
+
// code, nothing else.
|
|
280
343
|
const emitStatus = (text: string): void => {
|
|
281
344
|
if (outputMode === 'human') stdout.write(text);
|
|
282
345
|
};
|
|
@@ -399,14 +462,19 @@ export function registerPairCli(
|
|
|
399
462
|
'Pair a remote browser device to this gateway (mode = generate | import; default generate)',
|
|
400
463
|
)
|
|
401
464
|
.option('--json', 'Emit a single JSON payload (url/pin/sid/qr_ascii) instead of the human-readable banner. Enables agent-driven pairing.')
|
|
465
|
+
.option('--url-pin-only', 'Emit ONLY {v,url,pin,expires_at_ms} — no QR ASCII, no SID, no mode echo. Headless fallback for container-based agents where the totalreclaw_pair tool is not injected (issue #87). Zero phrase exposure on stdout.')
|
|
402
466
|
.option('--timeout <sec>', 'Session TTL in seconds (default: 900 = 15 min, matches pair-session-store default)')
|
|
403
467
|
.action(async (...args: unknown[]) => {
|
|
404
468
|
// commander passes: [modeArg, options, cmd]
|
|
405
469
|
const modeRaw = typeof args[0] === 'string' ? args[0] : undefined;
|
|
406
|
-
const opts = (args[1] ?? {}) as { json?: boolean; timeout?: string | number };
|
|
470
|
+
const opts = (args[1] ?? {}) as { json?: boolean; urlPinOnly?: boolean; timeout?: string | number };
|
|
407
471
|
const mode: PairCliMode =
|
|
408
472
|
modeRaw === 'import' || modeRaw === 'imp' ? 'import' : 'generate';
|
|
409
|
-
|
|
473
|
+
// --url-pin-only wins over --json when both are passed, since it is
|
|
474
|
+
// strictly the tighter surface (no QR, no SID). The flag is a subset.
|
|
475
|
+
const outputMode: PairCliOutputMode = opts.urlPinOnly
|
|
476
|
+
? 'url-pin'
|
|
477
|
+
: opts.json ? 'json' : 'human';
|
|
410
478
|
let ttlSeconds: number | undefined;
|
|
411
479
|
if (typeof opts.timeout === 'number' && Number.isFinite(opts.timeout)) {
|
|
412
480
|
ttlSeconds = opts.timeout;
|
package/pair-crypto.ts
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pair-crypto — gateway-side cryptographic primitives for the v3.3.
|
|
3
|
-
*
|
|
2
|
+
* pair-crypto — gateway-side cryptographic primitives for the v3.3.x
|
|
3
|
+
* relay-brokered pair flow.
|
|
4
4
|
*
|
|
5
|
-
* Cipher suite (
|
|
5
|
+
* Cipher suite (design doc 3a-3b, cipher swap ratified 2026-04-23 / rc.12):
|
|
6
6
|
* - ECDH on x25519 for key agreement.
|
|
7
7
|
* - HKDF-SHA256 for symmetric-key derivation from the shared secret.
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
8
|
+
* - AES-256-GCM AEAD for the ciphertext payload, with the sid bound as
|
|
9
|
+
* associated data (AD = sid UTF-8 bytes, 12-byte nonce, 16-byte tag).
|
|
10
|
+
*
|
|
11
|
+
* rc.4..rc.11 used ChaCha20-Poly1305, but the Web Crypto API does NOT
|
|
12
|
+
* implement ChaCha20-Poly1305 in Chrome / Safari / Edge. The pair-page
|
|
13
|
+
* submit path silently threw `Algorithm: Unrecognized name` before
|
|
14
|
+
* reaching the network. rc.12 swaps the cipher suite to AES-256-GCM
|
|
15
|
+
* (universally supported in WebCrypto) and bumps HKDF_INFO to v2 so
|
|
16
|
+
* cross-version mis-pairs fail closed rather than garble.
|
|
10
17
|
*
|
|
11
18
|
* Every primitive is provided by the Node built-in `node:crypto` module
|
|
12
19
|
* on Node 18.19+ and above. NO third-party crypto dependency is added
|
|
13
|
-
* to the plugin for the gateway side. (The BROWSER side of the flow
|
|
14
|
-
*
|
|
15
|
-
* for older Safari — those ship as part of the served pairing page and
|
|
16
|
-
* do NOT affect the plugin's server-side dep tree.)
|
|
20
|
+
* to the plugin for the gateway side. (The BROWSER side of the flow uses
|
|
21
|
+
* WebCrypto's AES-GCM directly — no shim needed.)
|
|
17
22
|
*
|
|
18
23
|
* Scope and guarantees
|
|
19
24
|
* --------------------
|
|
@@ -33,11 +38,11 @@
|
|
|
33
38
|
*
|
|
34
39
|
* Interoperability with browser WebCrypto
|
|
35
40
|
* ---------------------------------------
|
|
36
|
-
* The WebCrypto x25519 + HKDF +
|
|
37
|
-
*
|
|
41
|
+
* The WebCrypto x25519 + HKDF + AES-GCM APIs are bit-for-bit compatible
|
|
42
|
+
* with Node's `crypto` as long as:
|
|
38
43
|
* - Raw 32-byte public/private keys are used (not DER/SPKI).
|
|
39
44
|
* - HKDF parameters are (hash=SHA-256, salt=sid bytes, info fixed
|
|
40
|
-
* ASCII string, length=32 bytes
|
|
45
|
+
* ASCII string "totalreclaw-pair-v2", length=32 bytes).
|
|
41
46
|
* - AEAD uses a 12-byte random nonce + 16-byte tag, AD = sid bytes.
|
|
42
47
|
* See tests for fixed test vectors.
|
|
43
48
|
*/
|
|
@@ -60,18 +65,23 @@ import {
|
|
|
60
65
|
|
|
61
66
|
/**
|
|
62
67
|
* HKDF "info" parameter — fixes the domain separation for this protocol.
|
|
63
|
-
* MUST match the browser-side constant in the pair-page bundle
|
|
64
|
-
*
|
|
68
|
+
* MUST match the browser-side constant in the pair-page bundle + the
|
|
69
|
+
* relay-served pair-html page.
|
|
70
|
+
*
|
|
71
|
+
* Versioned so we can roll to a new KDF or cipher suite without silently
|
|
72
|
+
* producing garbage with old ciphertexts. rc.12: bumped from v1 to v2
|
|
73
|
+
* after cipher-suite swap from ChaCha20-Poly1305 → AES-256-GCM (see
|
|
74
|
+
* module header comment for context).
|
|
65
75
|
*/
|
|
66
|
-
export const HKDF_INFO = 'totalreclaw-pair-
|
|
76
|
+
export const HKDF_INFO = 'totalreclaw-pair-v2';
|
|
67
77
|
|
|
68
|
-
/** HKDF output length — 32 bytes = 256-bit
|
|
78
|
+
/** HKDF output length — 32 bytes = 256-bit AES-256-GCM key. */
|
|
69
79
|
export const AEAD_KEY_BYTES = 32;
|
|
70
80
|
|
|
71
|
-
/**
|
|
81
|
+
/** AES-GCM nonce length — 12 bytes (SP 800-38D recommendation). */
|
|
72
82
|
export const AEAD_NONCE_BYTES = 12;
|
|
73
83
|
|
|
74
|
-
/**
|
|
84
|
+
/** AES-GCM auth tag length — 16 bytes (128 bits, standard). */
|
|
75
85
|
export const AEAD_TAG_BYTES = 16;
|
|
76
86
|
|
|
77
87
|
/** Raw x25519 public/private key length — 32 bytes per RFC 7748. */
|
|
@@ -101,7 +111,7 @@ export interface GatewayKeypair {
|
|
|
101
111
|
|
|
102
112
|
/** Fully-derived session keys — caller uses kEnc for AEAD ops. */
|
|
103
113
|
export interface SessionKeys {
|
|
104
|
-
/** 32-byte
|
|
114
|
+
/** 32-byte AES-256-GCM key. */
|
|
105
115
|
kEnc: Buffer;
|
|
106
116
|
}
|
|
107
117
|
|
|
@@ -323,9 +333,9 @@ export function deriveAeadKeyFromEcdh(opts: {
|
|
|
323
333
|
// ---------------------------------------------------------------------------
|
|
324
334
|
|
|
325
335
|
/**
|
|
326
|
-
* Decrypt
|
|
327
|
-
*
|
|
328
|
-
*
|
|
336
|
+
* Decrypt an AES-256-GCM AEAD ciphertext. Returns the plaintext on
|
|
337
|
+
* success; throws if the tag is invalid (which includes both tampering
|
|
338
|
+
* and wrong-key attempts).
|
|
329
339
|
*
|
|
330
340
|
* Ciphertext is expected in the combined form `plaintext || tag`, where
|
|
331
341
|
* tag is the trailing 16 bytes. The caller MUST supply the same
|
|
@@ -351,7 +361,7 @@ export function aeadDecrypt(opts: {
|
|
|
351
361
|
const ct = combined.subarray(0, combined.length - AEAD_TAG_BYTES);
|
|
352
362
|
const tag = combined.subarray(combined.length - AEAD_TAG_BYTES);
|
|
353
363
|
|
|
354
|
-
const decipher = createDecipheriv('
|
|
364
|
+
const decipher = createDecipheriv('aes-256-gcm', opts.kEnc, nonce, {
|
|
355
365
|
authTagLength: AEAD_TAG_BYTES,
|
|
356
366
|
});
|
|
357
367
|
decipher.setAAD(Buffer.from(opts.sid, 'utf-8'), { plaintextLength: ct.length });
|
|
@@ -409,7 +419,7 @@ export function aeadEncryptWithSessionKey(opts: {
|
|
|
409
419
|
}
|
|
410
420
|
|
|
411
421
|
const pt = Buffer.isBuffer(opts.plaintext) ? opts.plaintext : Buffer.from(opts.plaintext);
|
|
412
|
-
const cipher = createCipheriv('
|
|
422
|
+
const cipher = createCipheriv('aes-256-gcm', opts.kEnc, nonceBuf, {
|
|
413
423
|
authTagLength: AEAD_TAG_BYTES,
|
|
414
424
|
});
|
|
415
425
|
cipher.setAAD(Buffer.from(opts.sid, 'utf-8'), { plaintextLength: pt.length });
|
package/pair-page.ts
CHANGED
|
@@ -5,6 +5,17 @@
|
|
|
5
5
|
* the URL fragment (`#pk=...`), runs the client-side pairing flow
|
|
6
6
|
* ENTIRELY in the browser, and POSTs the encrypted payload back.
|
|
7
7
|
*
|
|
8
|
+
* rc.13 status: this OpenClaw-plugin local-mode page has NOT been
|
|
9
|
+
* ported to the wizard UX used by the relay (production) and the
|
|
10
|
+
* Python local-mode pages. Local-mode on the OpenClaw plugin is rarely
|
|
11
|
+
* exercised — the plugin defaults to a relay flow via the Hermes
|
|
12
|
+
* Python sidecar and only falls back here for air-gapped setups. The
|
|
13
|
+
* wizard UX port for this file is tracked for rc.14 alongside the
|
|
14
|
+
* design decision on whether to share a single CSS+JS asset across
|
|
15
|
+
* all three pair pages (relay / Python / plugin) or keep them
|
|
16
|
+
* independently inlined. For now, this file retains its rc.10–rc.12
|
|
17
|
+
* UX shape and the rc.12 AES-GCM cipher swap.
|
|
18
|
+
*
|
|
8
19
|
* Brand tokens imported from the v5b.html public site (colors, font
|
|
9
20
|
* stack). Typography falls back to system fonts for mobile parity —
|
|
10
21
|
* we don't ship Euclid Circular A bytes over the pairing HTTP surface.
|
|
@@ -364,7 +375,9 @@ button.secondary:hover:not(:disabled) { border-color: var(--border-accent); colo
|
|
|
364
375
|
// Client-observed time at page load (used to adjust for clock skew).
|
|
365
376
|
const CLIENT_EPOCH_AT_LOAD = Date.now();
|
|
366
377
|
|
|
367
|
-
|
|
378
|
+
// v2: cipher-suite swap in rc.12 (see pair-crypto.ts header). Keep
|
|
379
|
+
// this constant in lockstep with the gateway-side pair-crypto.ts.
|
|
380
|
+
const HKDF_INFO = "totalreclaw-pair-v2";
|
|
368
381
|
|
|
369
382
|
// ---------- Small utilities ----------
|
|
370
383
|
function $(sel, root) { return (root || document).querySelector(sel); }
|
|
@@ -473,10 +486,9 @@ button.secondary:hover:not(:disabled) { border-color: var(--border-accent); colo
|
|
|
473
486
|
}
|
|
474
487
|
|
|
475
488
|
// ---------- Crypto shims: prefer WebCrypto; fall back to JS path ----------
|
|
476
|
-
// WebCrypto's x25519 + HKDF +
|
|
477
|
-
//
|
|
478
|
-
// is self-contained
|
|
479
|
-
// for the MVP (tracked as Wave 3.1 polish follow-up).
|
|
489
|
+
// WebCrypto's x25519 + HKDF + AES-GCM availability is Safari 17+ and
|
|
490
|
+
// modern Chromium 133+ / Firefox 130+. If absent we render an error
|
|
491
|
+
// — the page is self-contained and we do not bundle any JS crypto shim.
|
|
480
492
|
async function ensureWebCryptoSupport() {
|
|
481
493
|
if (!window.crypto || !crypto.subtle) return false;
|
|
482
494
|
try {
|
|
@@ -505,29 +517,28 @@ button.secondary:hover:not(:disabled) { border-color: var(--border-accent); colo
|
|
|
505
517
|
);
|
|
506
518
|
return new Uint8Array(bits);
|
|
507
519
|
}
|
|
508
|
-
// AEAD:
|
|
509
|
-
//
|
|
510
|
-
|
|
511
|
-
async function aeadEncryptChaCha(keyBytes, nonce, sid, plaintext) {
|
|
520
|
+
// AEAD: AES-256-GCM (universal in WebCrypto). Cipher swap rationale
|
|
521
|
+
// lives in pair-crypto.ts header comment (rc.12 changelog entry).
|
|
522
|
+
async function aeadEncryptAesGcm(keyBytes, nonce, sid, plaintext) {
|
|
512
523
|
const key = await crypto.subtle.importKey(
|
|
513
524
|
"raw", keyBytes,
|
|
514
|
-
{ name: "
|
|
525
|
+
{ name: "AES-GCM" },
|
|
515
526
|
false, ["encrypt"],
|
|
516
527
|
);
|
|
517
528
|
const adBytes = new TextEncoder().encode(sid);
|
|
518
529
|
const ct = new Uint8Array(await crypto.subtle.encrypt(
|
|
519
|
-
{ name: "
|
|
530
|
+
{ name: "AES-GCM", iv: nonce, additionalData: adBytes, tagLength: 128 },
|
|
520
531
|
key, plaintext,
|
|
521
532
|
));
|
|
522
533
|
return ct;
|
|
523
534
|
}
|
|
524
535
|
|
|
525
|
-
async function
|
|
536
|
+
async function aesGcmSupported() {
|
|
526
537
|
try {
|
|
527
538
|
const k = new Uint8Array(32);
|
|
528
539
|
const n = new Uint8Array(12);
|
|
529
|
-
const key = await crypto.subtle.importKey("raw", k, { name: "
|
|
530
|
-
await crypto.subtle.encrypt({ name: "
|
|
540
|
+
const key = await crypto.subtle.importKey("raw", k, { name: "AES-GCM" }, false, ["encrypt"]);
|
|
541
|
+
await crypto.subtle.encrypt({ name: "AES-GCM", iv: n, additionalData: new Uint8Array(0), tagLength: 128 }, key, new Uint8Array(0));
|
|
531
542
|
return true;
|
|
532
543
|
} catch (e) { return false; }
|
|
533
544
|
}
|
|
@@ -742,8 +753,8 @@ button.secondary:hover:not(:disabled) { border-color: var(--border-accent); colo
|
|
|
742
753
|
render(renderError("Your browser does not support modern cryptographic APIs (X25519). Please update your browser and try again, or use a different device. Supported: Chrome 123+, Firefox 130+, Safari 17+."));
|
|
743
754
|
return;
|
|
744
755
|
}
|
|
745
|
-
if (!(await
|
|
746
|
-
render(renderError("Your browser does not support
|
|
756
|
+
if (!(await aesGcmSupported())) {
|
|
757
|
+
render(renderError("Your browser does not support AES-GCM. Update your browser or use a different device."));
|
|
747
758
|
return;
|
|
748
759
|
}
|
|
749
760
|
|
|
@@ -762,7 +773,7 @@ button.secondary:hover:not(:disabled) { border-color: var(--border-accent); colo
|
|
|
762
773
|
const nonce = new Uint8Array(12);
|
|
763
774
|
crypto.getRandomValues(nonce);
|
|
764
775
|
const ptBytes = new TextEncoder().encode(mnemonic);
|
|
765
|
-
const ct = await
|
|
776
|
+
const ct = await aeadEncryptAesGcm(kEnc, nonce, SID, ptBytes);
|
|
766
777
|
|
|
767
778
|
// 6. Zero sensitive buffers BEFORE sending. The JS GC will run
|
|
768
779
|
// whenever it runs; explicit zeroing is best-effort but honours
|