@winmatrix/supervisor 1.0.5 → 1.0.7
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/package.json +1 -5
- package/src/claude-session-lib.mjs +84 -0
- package/src/engine-run-success.mjs +118 -0
- package/src/env-sanitizer.mjs +4 -0
- package/src/openclaw-device-store.mjs +208 -0
- package/src/openclaw-pair-workstation.mjs +190 -0
- package/src/run-agent-engine.mjs +318 -77
- package/src/run-engine.mjs +139 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@winmatrix/supervisor",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/run-agent-engine.mjs",
|
|
6
6
|
"bin": {
|
|
@@ -14,10 +14,6 @@
|
|
|
14
14
|
"build": "node -e \"console.log('no build step')\"",
|
|
15
15
|
"test": "node --test tests/**/*.test.mjs"
|
|
16
16
|
},
|
|
17
|
-
"dependencies": {
|
|
18
|
-
"@winmatrix/agent-sdk": "^1.0.5",
|
|
19
|
-
"@winmatrix/protocol": "^1.0.5"
|
|
20
|
-
},
|
|
21
17
|
"engines": {
|
|
22
18
|
"node": ">=20"
|
|
23
19
|
}
|
|
@@ -15,6 +15,7 @@ import { createRequire } from 'module';
|
|
|
15
15
|
const require = createRequire(import.meta.url);
|
|
16
16
|
|
|
17
17
|
export const TRUNCATE_LEN = 4096;
|
|
18
|
+
export const SESSION_DETAIL_TRANSPORT_MAX_BYTES = 48 * 1024;
|
|
18
19
|
export const REDACTED = '[REDACTED]';
|
|
19
20
|
export const SENSITIVE_KEY_PATTERN =
|
|
20
21
|
/(authorization|password|passwd|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|credential|session[_-]?key|jwt)/i;
|
|
@@ -326,6 +327,89 @@ export function previewValue(value, max = 1000) {
|
|
|
326
327
|
return text.length > max ? text.slice(0, max) + `…[${text.length - max} chars]` : text;
|
|
327
328
|
}
|
|
328
329
|
|
|
330
|
+
/**
|
|
331
|
+
* 将 session-get 结果压缩到 Sandbox exec 的安全传输预算内,避免 stdout 截断后产生非法 JSON。
|
|
332
|
+
* @param {Record<string, unknown>} detail
|
|
333
|
+
* @param {number} maxBytes
|
|
334
|
+
* @returns {Record<string, unknown>}
|
|
335
|
+
*/
|
|
336
|
+
export function compactSessionDetailForTransport(detail, maxBytes = SESSION_DETAIL_TRANSPORT_MAX_BYTES) {
|
|
337
|
+
const serializedBytes = (value) => Buffer.byteLength(JSON.stringify(value));
|
|
338
|
+
if (serializedBytes(detail) <= maxBytes) return detail;
|
|
339
|
+
|
|
340
|
+
const compacted = {
|
|
341
|
+
...detail,
|
|
342
|
+
messages: Array.isArray(detail.messages)
|
|
343
|
+
? detail.messages.map((message) => ({
|
|
344
|
+
...message,
|
|
345
|
+
content: Array.isArray(message.content)
|
|
346
|
+
? message.content.map((block) => ({
|
|
347
|
+
...block,
|
|
348
|
+
...(typeof block.text === 'string' && block.text.length > 1200
|
|
349
|
+
? { text: `${block.text.slice(0, 1200)}…[transport-truncated]` }
|
|
350
|
+
: {}),
|
|
351
|
+
}))
|
|
352
|
+
: message.content,
|
|
353
|
+
}))
|
|
354
|
+
: [],
|
|
355
|
+
metadata: detail.metadata && typeof detail.metadata === 'object'
|
|
356
|
+
? {
|
|
357
|
+
...detail.metadata,
|
|
358
|
+
...(typeof detail.metadata.assistantText === 'string'
|
|
359
|
+
? { assistantText: detail.metadata.assistantText.slice(0, 8000) }
|
|
360
|
+
: {}),
|
|
361
|
+
...(Array.isArray(detail.metadata.userPrompts)
|
|
362
|
+
? { userPrompts: detail.metadata.userPrompts.slice(-20).map((prompt) => (
|
|
363
|
+
typeof prompt === 'string' && prompt.length > 800
|
|
364
|
+
? `${prompt.slice(0, 800)}…[transport-truncated]`
|
|
365
|
+
: prompt
|
|
366
|
+
)) }
|
|
367
|
+
: {}),
|
|
368
|
+
...(detail.metadata.diagnostics && typeof detail.metadata.diagnostics === 'object'
|
|
369
|
+
? {
|
|
370
|
+
diagnostics: {
|
|
371
|
+
...detail.metadata.diagnostics,
|
|
372
|
+
...(Array.isArray(detail.metadata.diagnostics.toolCalls)
|
|
373
|
+
? { toolCalls: detail.metadata.diagnostics.toolCalls.slice(-20) }
|
|
374
|
+
: {}),
|
|
375
|
+
...(Array.isArray(detail.metadata.diagnostics.toolResults)
|
|
376
|
+
? { toolResults: detail.metadata.diagnostics.toolResults.slice(-20) }
|
|
377
|
+
: {}),
|
|
378
|
+
...(Array.isArray(detail.metadata.diagnostics.timeline)
|
|
379
|
+
? { timeline: detail.metadata.diagnostics.timeline.slice(-40) }
|
|
380
|
+
: {}),
|
|
381
|
+
},
|
|
382
|
+
}
|
|
383
|
+
: {}),
|
|
384
|
+
...(Array.isArray(detail.metadata.rawMessages) ? { rawMessages: undefined, rawMessagesIncluded: false } : {}),
|
|
385
|
+
transportCompacted: true,
|
|
386
|
+
}
|
|
387
|
+
: detail.metadata,
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
if (serializedBytes(compacted) <= maxBytes) return compacted;
|
|
391
|
+
|
|
392
|
+
const minimal = {
|
|
393
|
+
...compacted,
|
|
394
|
+
messages: [],
|
|
395
|
+
metadata: compacted.metadata && typeof compacted.metadata === 'object'
|
|
396
|
+
? {
|
|
397
|
+
...compacted.metadata,
|
|
398
|
+
assistantText: typeof compacted.metadata.assistantText === 'string'
|
|
399
|
+
? compacted.metadata.assistantText.slice(0, 4000)
|
|
400
|
+
: compacted.metadata.assistantText,
|
|
401
|
+
userPrompts: Array.isArray(compacted.metadata.userPrompts)
|
|
402
|
+
? compacted.metadata.userPrompts.map((prompt) => (
|
|
403
|
+
typeof prompt === 'string' ? prompt.slice(0, 800) : prompt
|
|
404
|
+
))
|
|
405
|
+
: compacted.metadata.userPrompts,
|
|
406
|
+
diagnostics: undefined,
|
|
407
|
+
}
|
|
408
|
+
: compacted.metadata,
|
|
409
|
+
};
|
|
410
|
+
return minimal;
|
|
411
|
+
}
|
|
412
|
+
|
|
329
413
|
/** @param {string} text */
|
|
330
414
|
export function redactPreviewText(text) {
|
|
331
415
|
return text
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工作站引擎终态成败:不得只看进程 exitCode。
|
|
3
|
+
*
|
|
4
|
+
* recover-workstation-context-overflow D2:SDK result 帧 `is_error` /
|
|
5
|
+
* 失败 `subtype`(至少 error_during_execution / error_max_turns)须写成失败,
|
|
6
|
+
* 失败原因进 error,可见正文仍保留。缺字段时回退 exitCode(旧镜像靠 server P0 短语)。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** @type {ReadonlySet<string>} */
|
|
10
|
+
export const SDK_FAILURE_SUBTYPES = Object.freeze(new Set([
|
|
11
|
+
'error_during_execution',
|
|
12
|
+
'error_max_turns',
|
|
13
|
+
]));
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {unknown} subtype
|
|
17
|
+
* @returns {subtype is string}
|
|
18
|
+
*/
|
|
19
|
+
export function isSdkFailureSubtype(subtype) {
|
|
20
|
+
return typeof subtype === 'string' && SDK_FAILURE_SUBTYPES.has(subtype);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 从 events.jsonl 条目(envelope 或裸事件)提取最后一条 result 上的 SDK 成败信号。
|
|
25
|
+
*
|
|
26
|
+
* @param {unknown[]} events
|
|
27
|
+
* @returns {{
|
|
28
|
+
* isError: boolean,
|
|
29
|
+
* subtype: string | undefined,
|
|
30
|
+
* visibleText: string | undefined,
|
|
31
|
+
* error: string | undefined,
|
|
32
|
+
* sessionId: string | undefined,
|
|
33
|
+
* }}
|
|
34
|
+
*/
|
|
35
|
+
export function extractSdkResultSignals(events) {
|
|
36
|
+
const empty = {
|
|
37
|
+
isError: false,
|
|
38
|
+
subtype: undefined,
|
|
39
|
+
visibleText: undefined,
|
|
40
|
+
error: undefined,
|
|
41
|
+
sessionId: undefined,
|
|
42
|
+
};
|
|
43
|
+
if (!Array.isArray(events)) return empty;
|
|
44
|
+
|
|
45
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
46
|
+
const raw = events[i];
|
|
47
|
+
const evt = raw && typeof raw === 'object' && raw.event && typeof raw.event === 'object'
|
|
48
|
+
? raw.event
|
|
49
|
+
: raw;
|
|
50
|
+
if (!evt || typeof evt !== 'object' || evt.type !== 'result') continue;
|
|
51
|
+
|
|
52
|
+
const payload = evt.result && typeof evt.result === 'object' && !Array.isArray(evt.result)
|
|
53
|
+
? evt.result
|
|
54
|
+
: evt;
|
|
55
|
+
|
|
56
|
+
const isError = payload.is_error === true || payload.isError === true;
|
|
57
|
+
const subtype = typeof payload.subtype === 'string'
|
|
58
|
+
? payload.subtype
|
|
59
|
+
: typeof payload.sdkSubtype === 'string'
|
|
60
|
+
? payload.sdkSubtype
|
|
61
|
+
: undefined;
|
|
62
|
+
const visibleText = typeof payload.text === 'string'
|
|
63
|
+
? payload.text
|
|
64
|
+
: typeof payload.content === 'string'
|
|
65
|
+
? payload.content
|
|
66
|
+
: undefined;
|
|
67
|
+
const error = typeof payload.error === 'string' ? payload.error : undefined;
|
|
68
|
+
const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId : undefined;
|
|
69
|
+
|
|
70
|
+
return { isError, subtype, visibleText, error, sessionId };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return empty;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {object} input
|
|
78
|
+
* @param {boolean} [input.cancelled]
|
|
79
|
+
* @param {number | null | undefined} [input.exitCode]
|
|
80
|
+
* @param {boolean} [input.isError]
|
|
81
|
+
* @param {string} [input.subtype]
|
|
82
|
+
* @param {string} [input.visibleText]
|
|
83
|
+
* @param {string} [input.existingError]
|
|
84
|
+
* @returns {{ success: boolean, status: 'cancelled' | 'completed' | 'failed', error?: string }}
|
|
85
|
+
*/
|
|
86
|
+
export function resolveEngineRunOutcome({
|
|
87
|
+
cancelled = false,
|
|
88
|
+
exitCode,
|
|
89
|
+
isError = false,
|
|
90
|
+
subtype,
|
|
91
|
+
visibleText,
|
|
92
|
+
existingError,
|
|
93
|
+
} = {}) {
|
|
94
|
+
if (cancelled) {
|
|
95
|
+
return { success: false, status: 'cancelled', error: 'cancelled' };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const sdkFailed = isError === true || isSdkFailureSubtype(subtype);
|
|
99
|
+
if (sdkFailed) {
|
|
100
|
+
const trimmedVisible = typeof visibleText === 'string' ? visibleText.trim() : '';
|
|
101
|
+
const trimmedExisting = typeof existingError === 'string' ? existingError.trim() : '';
|
|
102
|
+
const error = trimmedExisting
|
|
103
|
+
|| trimmedVisible
|
|
104
|
+
|| (isSdkFailureSubtype(subtype) ? `SDK result subtype=${subtype}` : 'SDK result is_error=true');
|
|
105
|
+
return { success: false, status: 'failed', error };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (exitCode !== 0) {
|
|
109
|
+
const trimmedExisting = typeof existingError === 'string' ? existingError.trim() : '';
|
|
110
|
+
return {
|
|
111
|
+
success: false,
|
|
112
|
+
status: 'failed',
|
|
113
|
+
error: trimmedExisting || `Engine exited with code ${exitCode}`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { success: true, status: 'completed' };
|
|
118
|
+
}
|
package/src/env-sanitizer.mjs
CHANGED
|
@@ -69,6 +69,10 @@ export function isFilteredEnvKey(key) {
|
|
|
69
69
|
// 精确匹配的过滤项(优先级最高)
|
|
70
70
|
if (FILTERED_EXACT_KEYS.has(key)) return true;
|
|
71
71
|
|
|
72
|
+
// Pod 创建时注入的 Claude 持久根目录必须和 HOME 一起传给 Engine。
|
|
73
|
+
// 其余 CLAUDE_CODE_* 仍按下方规则过滤,避免 Supervisor 内部标记泄漏。
|
|
74
|
+
if (/^CLAUDE_CODE_HOME$/i.test(key)) return false;
|
|
75
|
+
|
|
72
76
|
// 先检查是否匹配过滤模式(过滤优先于白名单)
|
|
73
77
|
// 这样 CLAUDE_CODE_ENTRYPOINT 被过滤,即使 CLAUDE_ 在白名单中
|
|
74
78
|
for (const pattern of FILTERED_ENV_PATTERNS) {
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
closeSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
openSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
statSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from 'node:fs';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
import { createHash, createPublicKey, generateKeyPairSync } from 'node:crypto';
|
|
15
|
+
|
|
16
|
+
const REQUIRED_SCOPES = ['operator.read', 'operator.write'];
|
|
17
|
+
|
|
18
|
+
function parseRecord(raw, gatewayOrigin) {
|
|
19
|
+
const value = JSON.parse(raw);
|
|
20
|
+
if (!value || value.version !== 1 || value.gatewayOrigin !== gatewayOrigin
|
|
21
|
+
|| !['unpaired', 'pairing_pending', 'paired', 'token_stale'].includes(value.status)
|
|
22
|
+
|| typeof value.deviceId !== 'string'
|
|
23
|
+
|| typeof value.privateKeyPem !== 'string' || typeof value.publicKeyPem !== 'string'
|
|
24
|
+
|| typeof value.role !== 'string' || !Array.isArray(value.scopes)) {
|
|
25
|
+
throw new Error('OpenClaw workstation device binding is invalid');
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function publicKeyRaw(publicKeyPem) {
|
|
31
|
+
const der = createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' });
|
|
32
|
+
if (der.length < 32) throw new Error('OpenClaw workstation public key is invalid');
|
|
33
|
+
return der.subarray(der.length - 32);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function fingerprintOpenClawPublicKey(publicKeyPem) {
|
|
37
|
+
return createHash('sha256').update(publicKeyRaw(publicKeyPem)).digest('hex');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function encodeOpenClawPublicKey(publicKeyPem) {
|
|
41
|
+
return publicKeyRaw(publicKeyPem).toString('base64url');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function assertPrivatePermissions(path) {
|
|
45
|
+
if (process.platform === 'win32') return;
|
|
46
|
+
if ((statSync(path).mode & 0o077) !== 0) {
|
|
47
|
+
throw new Error('OpenClaw workstation device binding must have mode 0600');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function acquireLock(lockPath) {
|
|
52
|
+
mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
53
|
+
for (let attempt = 0; attempt < 80; attempt += 1) {
|
|
54
|
+
try {
|
|
55
|
+
const fd = openSync(lockPath, 'wx', 0o600);
|
|
56
|
+
writeFileSync(fd, `${process.pid}\n`, 'utf8');
|
|
57
|
+
closeSync(fd);
|
|
58
|
+
return;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
61
|
+
const ageMs = Date.now() - statSync(lockPath).mtimeMs;
|
|
62
|
+
if (ageMs > 30_000) {
|
|
63
|
+
rmSync(lockPath, { force: true });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const wait = new Int32Array(new SharedArrayBuffer(4));
|
|
67
|
+
Atomics.wait(wait, 0, 0, 25);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
throw new Error('OpenClaw workstation device binding is locked');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function atomicWrite(path, value, lockPath) {
|
|
74
|
+
acquireLock(lockPath);
|
|
75
|
+
const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
76
|
+
try {
|
|
77
|
+
writeFileSync(tempPath, JSON.stringify(value, null, 2), { mode: 0o600, flag: 'wx' });
|
|
78
|
+
chmodSync(tempPath, 0o600);
|
|
79
|
+
renameSync(tempPath, path);
|
|
80
|
+
chmodSync(path, 0o600);
|
|
81
|
+
} finally {
|
|
82
|
+
rmSync(tempPath, { force: true });
|
|
83
|
+
rmSync(lockPath, { force: true });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class WorkstationOpenClawDeviceStore {
|
|
88
|
+
#filePath;
|
|
89
|
+
#lockPath;
|
|
90
|
+
#gatewayOrigin;
|
|
91
|
+
|
|
92
|
+
constructor({ stateDir, gatewayOrigin }) {
|
|
93
|
+
this.#filePath = join(stateDir, 'winmatrix-gateway-client', 'device-auth.json');
|
|
94
|
+
this.#lockPath = `${this.#filePath}.lock`;
|
|
95
|
+
this.#gatewayOrigin = gatewayOrigin;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#load() {
|
|
99
|
+
if (!existsSync(this.#filePath)) {
|
|
100
|
+
throw new Error('OpenClaw workstation DeviceToken is not paired');
|
|
101
|
+
}
|
|
102
|
+
assertPrivatePermissions(this.#filePath);
|
|
103
|
+
return parseRecord(readFileSync(this.#filePath, 'utf8'), this.#gatewayOrigin);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#loadOptional() {
|
|
107
|
+
if (!existsSync(this.#filePath)) return null;
|
|
108
|
+
assertPrivatePermissions(this.#filePath);
|
|
109
|
+
return parseRecord(readFileSync(this.#filePath, 'utf8'), this.#gatewayOrigin);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
loadOrCreateIdentity() {
|
|
113
|
+
let record = this.#loadOptional();
|
|
114
|
+
if (!record) {
|
|
115
|
+
const pair = generateKeyPairSync('ed25519');
|
|
116
|
+
const privateKeyPem = pair.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
|
117
|
+
const publicKeyPem = pair.publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
|
118
|
+
record = {
|
|
119
|
+
version: 1,
|
|
120
|
+
gatewayOrigin: this.#gatewayOrigin,
|
|
121
|
+
deviceId: fingerprintOpenClawPublicKey(publicKeyPem),
|
|
122
|
+
privateKeyPem,
|
|
123
|
+
publicKeyPem,
|
|
124
|
+
role: 'operator',
|
|
125
|
+
scopes: [],
|
|
126
|
+
status: 'unpaired',
|
|
127
|
+
};
|
|
128
|
+
mkdirSync(dirname(this.#filePath), { recursive: true, mode: 0o700 });
|
|
129
|
+
chmodSync(dirname(this.#filePath), 0o700);
|
|
130
|
+
atomicWrite(this.#filePath, record, this.#lockPath);
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
deviceId: record.deviceId,
|
|
134
|
+
privateKeyPem: record.privateKeyPem,
|
|
135
|
+
publicKeyPem: record.publicKeyPem,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
loadToken({ gatewayOrigin, deviceId, role }) {
|
|
140
|
+
const record = this.#load();
|
|
141
|
+
if (gatewayOrigin !== this.#gatewayOrigin || record.deviceId !== deviceId || record.role !== role) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
if (record.status !== 'paired' || typeof record.deviceToken !== 'string') return null;
|
|
145
|
+
for (const scope of REQUIRED_SCOPES) {
|
|
146
|
+
if (!record.scopes.includes(scope)) {
|
|
147
|
+
throw new Error(`OpenClaw workstation DeviceToken is missing scope '${scope}'`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return { token: record.deviceToken, scopes: [...record.scopes] };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
storeToken({ gatewayOrigin, deviceId, role, token, scopes }) {
|
|
154
|
+
const record = this.#loadOptional();
|
|
155
|
+
if (!record) throw new Error('OpenClaw workstation device identity is unavailable');
|
|
156
|
+
if (gatewayOrigin !== this.#gatewayOrigin || record.deviceId !== deviceId) {
|
|
157
|
+
throw new Error('OpenClaw workstation DeviceToken rotation binding mismatch');
|
|
158
|
+
}
|
|
159
|
+
atomicWrite(this.#filePath, {
|
|
160
|
+
...record,
|
|
161
|
+
role,
|
|
162
|
+
scopes: [...scopes],
|
|
163
|
+
status: 'paired',
|
|
164
|
+
deviceToken: token,
|
|
165
|
+
}, this.#lockPath);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
clearToken({ gatewayOrigin, deviceId, role }) {
|
|
169
|
+
const record = this.#loadOptional();
|
|
170
|
+
if (!record) return;
|
|
171
|
+
if (gatewayOrigin !== this.#gatewayOrigin || record.deviceId !== deviceId || record.role !== role) return;
|
|
172
|
+
const { deviceToken: _removed, ...withoutToken } = record;
|
|
173
|
+
atomicWrite(this.#filePath, {
|
|
174
|
+
...withoutToken,
|
|
175
|
+
scopes: [],
|
|
176
|
+
status: 'token_stale',
|
|
177
|
+
}, this.#lockPath);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
markPairingPending(requestId) {
|
|
182
|
+
const record = this.#load();
|
|
183
|
+
atomicWrite(this.#filePath, {
|
|
184
|
+
...record,
|
|
185
|
+
status: 'pairing_pending',
|
|
186
|
+
requestId,
|
|
187
|
+
}, this.#lockPath);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function createWorkstationOpenClawAdapterConfig(env = process.env) {
|
|
192
|
+
const stateDir = env.OPENCLAW_STATE_DIR || '/home/node/.openclaw';
|
|
193
|
+
const port = Number(env.OPENCLAW_GATEWAY_PORT || 18789);
|
|
194
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
195
|
+
throw new Error('OPENCLAW_GATEWAY_PORT must be a valid TCP port');
|
|
196
|
+
}
|
|
197
|
+
const gatewayUrl = `ws://127.0.0.1:${port}`;
|
|
198
|
+
const gatewayOrigin = `http://127.0.0.1:${port}`;
|
|
199
|
+
const deviceAuthStore = new WorkstationOpenClawDeviceStore({ stateDir, gatewayOrigin });
|
|
200
|
+
deviceAuthStore.loadOrCreateIdentity();
|
|
201
|
+
return {
|
|
202
|
+
mode: 'gateway',
|
|
203
|
+
hostKind: 'workstation',
|
|
204
|
+
gatewayUrl,
|
|
205
|
+
gatewayOrigin,
|
|
206
|
+
deviceAuthStore,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
WorkstationOpenClawDeviceStore,
|
|
7
|
+
encodeOpenClawPublicKey,
|
|
8
|
+
fingerprintOpenClawPublicKey,
|
|
9
|
+
} from './openclaw-device-store.mjs';
|
|
10
|
+
|
|
11
|
+
const SDK_PATH = process.env.WINMATRIX_AGENT_SDK_PATH
|
|
12
|
+
|| '/home/node/.local/lib/node_modules/@winmatrix/agent-sdk';
|
|
13
|
+
|
|
14
|
+
function readArg(argv, name) {
|
|
15
|
+
const index = argv.indexOf(name);
|
|
16
|
+
return index >= 0 ? argv[index + 1]?.trim() : undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function decodeSetupCode(setupCode) {
|
|
20
|
+
if (typeof setupCode !== 'string' || !setupCode) throw new Error('OpenClaw setup code is missing');
|
|
21
|
+
const payload = JSON.parse(Buffer.from(setupCode, 'base64url').toString('utf8'));
|
|
22
|
+
if (!payload || typeof payload.bootstrapToken !== 'string' || !payload.bootstrapToken.trim()) {
|
|
23
|
+
throw new Error('OpenClaw setup code does not contain a bootstrap credential');
|
|
24
|
+
}
|
|
25
|
+
return payload;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function resolvePairingContext(input) {
|
|
29
|
+
const expectedWorkstationId = input.workstationId?.trim();
|
|
30
|
+
const env = input.env ?? process.env;
|
|
31
|
+
const actualWorkstationId = env.WINMATRIX_WORKSTATION_ID?.trim();
|
|
32
|
+
if (!expectedWorkstationId || actualWorkstationId !== expectedWorkstationId) {
|
|
33
|
+
throw new Error('OpenClaw pairing workstation identity mismatch');
|
|
34
|
+
}
|
|
35
|
+
const port = Number(env.OPENCLAW_GATEWAY_PORT || 18789);
|
|
36
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
37
|
+
throw new Error('OPENCLAW_GATEWAY_PORT must be a valid TCP port');
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
expectedWorkstationId,
|
|
41
|
+
env,
|
|
42
|
+
gatewayUrl: `ws://127.0.0.1:${port}`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function issueOpenClawBootstrap(input, deps = {}) {
|
|
47
|
+
const context = resolvePairingContext(input);
|
|
48
|
+
const cliJson = deps.runJson ?? ((args) => runJson('openclaw', args, { env: context.env }));
|
|
49
|
+
const qr = await cliJson(['qr', '--json', '--url', context.gatewayUrl]);
|
|
50
|
+
const setup = decodeSetupCode(qr.setupCode);
|
|
51
|
+
return { bootstrapToken: setup.bootstrapToken.trim() };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function approveOpenClawPairing(input, deps = {}) {
|
|
55
|
+
const context = resolvePairingContext(input);
|
|
56
|
+
const requestId = input.requestId?.trim();
|
|
57
|
+
const deviceId = input.deviceId?.trim();
|
|
58
|
+
const publicKey = input.publicKey?.trim();
|
|
59
|
+
if (!requestId || !deviceId || !publicKey) {
|
|
60
|
+
throw new Error('OpenClaw pairing approval identity is incomplete');
|
|
61
|
+
}
|
|
62
|
+
const cliJson = deps.runJson ?? ((args) => runJson('openclaw', args, { env: context.env }));
|
|
63
|
+
const listed = await cliJson(['devices', 'list', '--json']);
|
|
64
|
+
const pending = Array.isArray(listed?.pending) ? listed.pending : [];
|
|
65
|
+
const exact = pending.find((item) => item?.requestId === requestId);
|
|
66
|
+
if (!exact || exact.deviceId !== deviceId || exact.publicKey !== publicKey) {
|
|
67
|
+
throw new Error('OpenClaw pending pairing request identity mismatch');
|
|
68
|
+
}
|
|
69
|
+
await cliJson(['devices', 'approve', requestId, '--json']);
|
|
70
|
+
return { approved: true, requestId, deviceId };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function runJson(command, args, options = {}) {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const child = spawn(command, args, {
|
|
76
|
+
env: options.env ?? process.env,
|
|
77
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
78
|
+
});
|
|
79
|
+
const stdout = [];
|
|
80
|
+
const stderr = [];
|
|
81
|
+
child.stdout.on('data', (chunk) => stdout.push(chunk));
|
|
82
|
+
child.stderr.on('data', (chunk) => stderr.push(chunk));
|
|
83
|
+
child.once('error', () => reject(new Error(`OpenClaw command unavailable: ${args[0] ?? command}`)));
|
|
84
|
+
child.once('close', (code) => {
|
|
85
|
+
if (code !== 0) {
|
|
86
|
+
reject(new Error(`OpenClaw command failed: ${args.slice(0, 2).join(' ')}`));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
resolve(JSON.parse(Buffer.concat(stdout).toString('utf8')));
|
|
91
|
+
} catch {
|
|
92
|
+
reject(new Error('OpenClaw command returned invalid JSON'));
|
|
93
|
+
}
|
|
94
|
+
void stderr;
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function createBootstrapProvider(value) {
|
|
100
|
+
let credential = value;
|
|
101
|
+
return {
|
|
102
|
+
readOnce: async () => credential,
|
|
103
|
+
clear: async () => { credential = ''; },
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function pairOpenClawWorkstation(input, deps = {}) {
|
|
108
|
+
const context = resolvePairingContext(input);
|
|
109
|
+
const { expectedWorkstationId, env, gatewayUrl } = context;
|
|
110
|
+
const stateDir = env.OPENCLAW_STATE_DIR || '/home/node/.openclaw';
|
|
111
|
+
const port = Number(env.OPENCLAW_GATEWAY_PORT || 18789);
|
|
112
|
+
const gatewayOrigin = `http://127.0.0.1:${port}`;
|
|
113
|
+
const store = deps.store ?? new WorkstationOpenClawDeviceStore({ stateDir, gatewayOrigin });
|
|
114
|
+
const identity = store.loadOrCreateIdentity();
|
|
115
|
+
const expectedPublicKey = encodeOpenClawPublicKey(identity.publicKeyPem);
|
|
116
|
+
const publicKeyFingerprint = fingerprintOpenClawPublicKey(identity.publicKeyPem);
|
|
117
|
+
const cliJson = deps.runJson ?? ((args) => runJson('openclaw', args, { env }));
|
|
118
|
+
const provision = deps.provisionGatewayDevice ?? (await import(
|
|
119
|
+
pathToFileURL(join(SDK_PATH, 'dist', 'index.js')).href
|
|
120
|
+
)).provisionGatewayDevice;
|
|
121
|
+
|
|
122
|
+
let bootstrapToken = '';
|
|
123
|
+
try {
|
|
124
|
+
const qr = await cliJson(['qr', '--json', '--url', gatewayUrl]);
|
|
125
|
+
const setup = decodeSetupCode(qr.setupCode);
|
|
126
|
+
bootstrapToken = setup.bootstrapToken.trim();
|
|
127
|
+
let result = await provision({
|
|
128
|
+
gatewayUrl,
|
|
129
|
+
gatewayOrigin,
|
|
130
|
+
deviceAuthStore: store,
|
|
131
|
+
bootstrapProvider: createBootstrapProvider(bootstrapToken),
|
|
132
|
+
timeoutMs: 30_000,
|
|
133
|
+
});
|
|
134
|
+
if (result.status === 'pairing_pending') {
|
|
135
|
+
const listed = await cliJson(['devices', 'list', '--json']);
|
|
136
|
+
const pending = Array.isArray(listed?.pending) ? listed.pending : [];
|
|
137
|
+
const exact = pending.find((item) => item?.requestId === result.requestId);
|
|
138
|
+
if (!exact || exact.deviceId !== identity.deviceId || exact.publicKey !== expectedPublicKey) {
|
|
139
|
+
throw new Error('OpenClaw pending pairing request identity mismatch');
|
|
140
|
+
}
|
|
141
|
+
store.markPairingPending?.(result.requestId);
|
|
142
|
+
await cliJson(['devices', 'approve', result.requestId, '--json']);
|
|
143
|
+
result = await provision({
|
|
144
|
+
gatewayUrl,
|
|
145
|
+
gatewayOrigin,
|
|
146
|
+
deviceAuthStore: store,
|
|
147
|
+
bootstrapProvider: createBootstrapProvider(bootstrapToken),
|
|
148
|
+
timeoutMs: 30_000,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
if (result.status !== 'paired' || result.deviceId !== identity.deviceId) {
|
|
152
|
+
throw new Error(`OpenClaw pairing did not complete: ${result.reasonCode ?? result.status}`);
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
paired: true,
|
|
156
|
+
workstationId: expectedWorkstationId,
|
|
157
|
+
deviceId: result.deviceId,
|
|
158
|
+
role: result.role,
|
|
159
|
+
scopes: [...result.scopes],
|
|
160
|
+
publicKeyFingerprint,
|
|
161
|
+
};
|
|
162
|
+
} finally {
|
|
163
|
+
bootstrapToken = '';
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function main() {
|
|
168
|
+
const argv = process.argv.slice(2);
|
|
169
|
+
const command = argv[0]?.startsWith('--') ? 'pair' : argv[0] ?? 'pair';
|
|
170
|
+
const workstationId = readArg(argv, '--workstation-id');
|
|
171
|
+
let result;
|
|
172
|
+
if (command === 'bootstrap') {
|
|
173
|
+
result = await issueOpenClawBootstrap({ workstationId });
|
|
174
|
+
} else if (command === 'approve') {
|
|
175
|
+
const chunks = [];
|
|
176
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
177
|
+
const approval = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
178
|
+
result = await approveOpenClawPairing({ workstationId, ...approval });
|
|
179
|
+
} else {
|
|
180
|
+
result = await pairOpenClawWorkstation({ workstationId });
|
|
181
|
+
}
|
|
182
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
|
|
186
|
+
main().catch((error) => {
|
|
187
|
+
process.stderr.write(`${error instanceof Error ? error.message : 'OpenClaw pairing failed'}\n`);
|
|
188
|
+
process.exitCode = 1;
|
|
189
|
+
});
|
|
190
|
+
}
|