@xmemo/skill 1.1.25
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/LICENSE +21 -0
- package/README.md +34 -0
- package/bin/install.mjs +251 -0
- package/package.json +24 -0
- package/skill/CHANGELOG.md +279 -0
- package/skill/SKILL.md +464 -0
- package/skill/references/ledger-operations.md +147 -0
- package/skill/references/memory-operations.md +231 -0
- package/skill/references/runtime-operations.md +118 -0
- package/skill/references/troubleshooting.md +147 -0
- package/skill/scripts/commands/account.mjs +194 -0
- package/skill/scripts/commands/auth-login.mjs +234 -0
- package/skill/scripts/commands/auth-manage.mjs +201 -0
- package/skill/scripts/commands/ledger.mjs +175 -0
- package/skill/scripts/commands/memory.mjs +306 -0
- package/skill/scripts/commands/ops.mjs +236 -0
- package/skill/scripts/lib/api.mjs +311 -0
- package/skill/scripts/lib/auth-state.mjs +253 -0
- package/skill/scripts/lib/cli-input.mjs +288 -0
- package/skill/scripts/lib/core.mjs +247 -0
- package/skill/scripts/lib/help.mjs +179 -0
- package/skill/scripts/lib/muse-vault.mjs +198 -0
- package/skill/scripts/lib/openclaw-egress.mjs +63 -0
- package/skill/scripts/xmemo-skill.mjs +184 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
|
|
4
|
+
export const credentialsPath = path.join(os.homedir(), '.xmemo', 'skill-credentials.json');
|
|
5
|
+
export const registrationPath = path.join(os.homedir(), '.xmemo', 'skill-registration.json');
|
|
6
|
+
export const SCRIPT_COMMAND = 'node scripts/xmemo-skill.mjs';
|
|
7
|
+
export const PLAINTEXT_STORAGE = 'plaintext-user-file';
|
|
8
|
+
export const DEFAULT_BASE_URL = 'https://xmemo.dev';
|
|
9
|
+
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
10
|
+
export const MAX_TIMEOUT_MS = 300_000;
|
|
11
|
+
export const MAX_RESPONSE_BYTES = 8_388_608;
|
|
12
|
+
// Maximum memory content limit (512 KiB), matching memory-os server models/memory_schema.py: MAX_MEMORY_CONTENT_BYTES
|
|
13
|
+
export const MAX_MEMORY_CONTENT_BYTES = 524_288;
|
|
14
|
+
export const MAX_STATE_TTL_SECONDS = 2_592_000;
|
|
15
|
+
export const DEFAULT_TEMPORARY_LIMITS = Object.freeze({
|
|
16
|
+
max_items: 100,
|
|
17
|
+
ttl_seconds: 1_209_600,
|
|
18
|
+
max_lifetime_seconds: 2_592_000,
|
|
19
|
+
});
|
|
20
|
+
export const EXIT_CODE = Object.freeze({
|
|
21
|
+
SUCCESS: 0,
|
|
22
|
+
USER_ERROR: 1,
|
|
23
|
+
AUTH_ERROR: 2,
|
|
24
|
+
SERVER_ERROR: 3,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export function exitCodeForHttpStatus(statusCode) {
|
|
28
|
+
const code = Number(statusCode);
|
|
29
|
+
if (code >= 200 && code < 300) {
|
|
30
|
+
return EXIT_CODE.SUCCESS;
|
|
31
|
+
}
|
|
32
|
+
if (code === 401 || code === 403) {
|
|
33
|
+
return EXIT_CODE.AUTH_ERROR;
|
|
34
|
+
}
|
|
35
|
+
if (code >= 500) {
|
|
36
|
+
return EXIT_CODE.SERVER_ERROR;
|
|
37
|
+
}
|
|
38
|
+
return EXIT_CODE.USER_ERROR;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function exitCodeForErrorCode(code) {
|
|
42
|
+
if (!code || typeof code !== 'string') return null;
|
|
43
|
+
const normalized = code.toLowerCase();
|
|
44
|
+
if (
|
|
45
|
+
normalized === 'unauthorized' ||
|
|
46
|
+
normalized === 'forbidden' ||
|
|
47
|
+
normalized === 'tenant_forbidden' ||
|
|
48
|
+
normalized === 'auth_error' ||
|
|
49
|
+
normalized === 'invalid_token' ||
|
|
50
|
+
normalized === 'token_expired' ||
|
|
51
|
+
normalized === 'authentication_required' ||
|
|
52
|
+
normalized === 'missing_credentials'
|
|
53
|
+
) {
|
|
54
|
+
return EXIT_CODE.AUTH_ERROR;
|
|
55
|
+
}
|
|
56
|
+
if (
|
|
57
|
+
normalized === 'server_error' ||
|
|
58
|
+
normalized === 'internal_error' ||
|
|
59
|
+
normalized === 'timeout' ||
|
|
60
|
+
normalized === 'bad_gateway' ||
|
|
61
|
+
normalized === 'service_unavailable' ||
|
|
62
|
+
normalized === 'econnrefused' ||
|
|
63
|
+
normalized === 'enotfound' ||
|
|
64
|
+
normalized === 'ehostunreach' ||
|
|
65
|
+
normalized === 'econnreset' ||
|
|
66
|
+
normalized === 'etimedout' ||
|
|
67
|
+
normalized === 'esockettimedout'
|
|
68
|
+
) {
|
|
69
|
+
return EXIT_CODE.SERVER_ERROR;
|
|
70
|
+
}
|
|
71
|
+
if (
|
|
72
|
+
normalized === 'bad_request' ||
|
|
73
|
+
normalized === 'invalid_argument' ||
|
|
74
|
+
normalized === 'not_found' ||
|
|
75
|
+
normalized === 'rate_limited' ||
|
|
76
|
+
normalized === 'rate_limit_exceeded' ||
|
|
77
|
+
normalized === 'precondition_required'
|
|
78
|
+
) {
|
|
79
|
+
return EXIT_CODE.USER_ERROR;
|
|
80
|
+
}
|
|
81
|
+
if (normalized.startsWith('http 401') || normalized.startsWith('http 403')) {
|
|
82
|
+
return EXIT_CODE.AUTH_ERROR;
|
|
83
|
+
}
|
|
84
|
+
if (normalized.startsWith('http 5')) {
|
|
85
|
+
return EXIT_CODE.SERVER_ERROR;
|
|
86
|
+
}
|
|
87
|
+
if (normalized.startsWith('http 4')) {
|
|
88
|
+
return EXIT_CODE.USER_ERROR;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function exitCodeForError(err) {
|
|
94
|
+
if (!err) return EXIT_CODE.USER_ERROR;
|
|
95
|
+
if (typeof err.exitCode === 'number') {
|
|
96
|
+
return err.exitCode;
|
|
97
|
+
}
|
|
98
|
+
const status = Number(err.statusCode || err.status || err.httpStatus);
|
|
99
|
+
if (Number.isInteger(status) && status > 0) {
|
|
100
|
+
return exitCodeForHttpStatus(status);
|
|
101
|
+
}
|
|
102
|
+
const code = String(err.code || '').toUpperCase();
|
|
103
|
+
if (['ECONNREFUSED', 'ENOTFOUND', 'EHOSTUNREACH', 'ECONNRESET', 'ETIMEDOUT', 'ESOCKETTIMEDOUT', 'EAI_AGAIN', 'EPIPE'].includes(code)) {
|
|
104
|
+
return EXIT_CODE.SERVER_ERROR;
|
|
105
|
+
}
|
|
106
|
+
const msg = String(err.message || err).toLowerCase();
|
|
107
|
+
if (
|
|
108
|
+
msg.includes('timed out') ||
|
|
109
|
+
msg.includes('timeout') ||
|
|
110
|
+
msg.includes('safety limit') ||
|
|
111
|
+
msg.includes('socket hang up') ||
|
|
112
|
+
msg.includes('interrupted') ||
|
|
113
|
+
msg.includes('econnrefused') ||
|
|
114
|
+
msg.includes('enotfound') ||
|
|
115
|
+
msg.includes('ehostunreach') ||
|
|
116
|
+
msg.includes('server response exceeded') ||
|
|
117
|
+
msg.includes('server returned a non-json response') ||
|
|
118
|
+
msg.includes('server returned an empty response')
|
|
119
|
+
) {
|
|
120
|
+
return EXIT_CODE.SERVER_ERROR;
|
|
121
|
+
}
|
|
122
|
+
if (
|
|
123
|
+
msg.includes('401') ||
|
|
124
|
+
msg.includes('unauthorized') ||
|
|
125
|
+
msg.includes('403') ||
|
|
126
|
+
msg.includes('forbidden') ||
|
|
127
|
+
msg.includes('invalid or expired token') ||
|
|
128
|
+
msg.includes('no xmemo credential found') ||
|
|
129
|
+
msg.includes('authentication required')
|
|
130
|
+
) {
|
|
131
|
+
return EXIT_CODE.AUTH_ERROR;
|
|
132
|
+
}
|
|
133
|
+
return EXIT_CODE.USER_ERROR;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const REST_COMMANDS = new Set([
|
|
137
|
+
'read', 'update', 'forget',
|
|
138
|
+
'ledger-list', 'ledger-summary',
|
|
139
|
+
'overview', 'activity', 'stats',
|
|
140
|
+
'remember', 'recall', 'search', 'save-state', 'restore-state', 'state-save', 'state-restore',
|
|
141
|
+
'restart-snapshot', 'restart-restore', 'recall-context',
|
|
142
|
+
'todo-add', 'todo-list', 'todo-done', 'expense-add', 'doctor',
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
export const COMMAND_FLAGS = {
|
|
146
|
+
login: new Set(),
|
|
147
|
+
register: new Set(['reason']),
|
|
148
|
+
logout: new Set(),
|
|
149
|
+
doctor: new Set(),
|
|
150
|
+
read: new Set(['id', 'offset', 'limit', 'bucket', 'scope']),
|
|
151
|
+
update: new Set(['id', 'content', 'path', 'metadata', 'bucket', 'scope']),
|
|
152
|
+
forget: new Set(['id', 'reason', 'confirm']),
|
|
153
|
+
'ledger-list': new Set(['limit', 'offset', 'currency', 'from', 'to', 'category', 'min-amount', 'max-amount', 'type', 'month']),
|
|
154
|
+
'ledger-summary': new Set(['months', 'currency', 'type']),
|
|
155
|
+
overview: new Set(),
|
|
156
|
+
activity: new Set(['limit']),
|
|
157
|
+
stats: new Set(['scope', 'path', 'bucket', 'memory-type', 'memory_type', 'status', 'source', 'since', 'until', 'group-by', 'group_by', 'top-n', 'top_n', 'team-id', 'team_id']),
|
|
158
|
+
remember: new Set(['content', 'file', 'path', 'metadata', 'logic_path', 'bucket', 'scope', 'team_id']),
|
|
159
|
+
recall: new Set(['query', 'limit', 'threshold', 'path', 'bucket', 'scope', 'team_id', 'memory_type', 'explain', 'prefer_working']),
|
|
160
|
+
search: new Set(['query', 'limit', 'threshold', 'path', 'bucket', 'scope', 'team_id', 'memory_type', 'explain', 'prefer_working']),
|
|
161
|
+
'save-state': new Set(['key', 'state_key', 'content', 'current_task', 'next_action', 'blocked_reason', 'ttl_seconds', 'bucket', 'scope']),
|
|
162
|
+
'state-save': new Set(['key', 'state_key', 'content', 'current_task', 'next_action', 'blocked_reason', 'ttl_seconds', 'bucket', 'scope']),
|
|
163
|
+
'restore-state': new Set(['key', 'state_key', 'bucket', 'scope']),
|
|
164
|
+
'state-restore': new Set(['key', 'state_key', 'bucket', 'scope']),
|
|
165
|
+
'restart-snapshot': new Set(['session_id', 'state_key', 'timeline_limit', 'reminder_limit', 'decision_limit', 'metadata', 'bucket', 'scope', 'path', 'ttl_seconds']),
|
|
166
|
+
'restart-restore': new Set(['snapshot_id', 'source_session_id', 'target_session_id', 'state_key', 'restore_state', 'record_restore_event', 'ttl_seconds', 'bucket', 'scope']),
|
|
167
|
+
'recall-context': new Set(['query', 'path', 'bucket', 'scope', 'team_id', 'memory_type', 'status', 'threshold', 'max_items', 'max_tokens', 'limit', 'prefer_working', 'include_knowledge']),
|
|
168
|
+
'todo-add': new Set(['content', 'due_at', 'bucket', 'scope', 'path']),
|
|
169
|
+
'todo-list': new Set(['bucket', 'scope', 'status']),
|
|
170
|
+
'todo-done': new Set(['id', 'todo_id', 'note']),
|
|
171
|
+
'expense-add': new Set(['item', 'amount', 'currency', 'transaction_date', 'date', 'path', 'bucket', 'scope']),
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export const AUTH_FLAGS = {
|
|
175
|
+
status: new Set(),
|
|
176
|
+
add: new Set(['from-stdin']),
|
|
177
|
+
'claim-status': new Set(),
|
|
178
|
+
'claim-confirm': new Set(),
|
|
179
|
+
'claim-deny': new Set(),
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
export function parsePositiveInteger(value, name, max = Number.MAX_SAFE_INTEGER) {
|
|
183
|
+
if (!/^\d+$/.test(String(value ?? ''))) {
|
|
184
|
+
throw new Error(`${name} must be a positive integer.`);
|
|
185
|
+
}
|
|
186
|
+
const parsed = Number(value);
|
|
187
|
+
if (!Number.isSafeInteger(parsed) || parsed <= 0 || parsed > max) {
|
|
188
|
+
throw new Error(`${name} must be between 1 and ${max}.`);
|
|
189
|
+
}
|
|
190
|
+
return parsed;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function parseIntegerInRange(value, name, min, max) {
|
|
194
|
+
if (!/^\d+$/.test(String(value ?? ''))) {
|
|
195
|
+
throw new Error(`${name} must be an integer between ${min} and ${max}.`);
|
|
196
|
+
}
|
|
197
|
+
const parsed = Number(value);
|
|
198
|
+
if (!Number.isSafeInteger(parsed) || parsed < min || parsed > max) {
|
|
199
|
+
throw new Error(`${name} must be between ${min} and ${max}.`);
|
|
200
|
+
}
|
|
201
|
+
return parsed;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function parseJsonObject(value, name) {
|
|
205
|
+
let parsed;
|
|
206
|
+
try {
|
|
207
|
+
parsed = JSON.parse(String(value));
|
|
208
|
+
} catch {
|
|
209
|
+
throw new Error(`${name} must be a valid JSON object.`);
|
|
210
|
+
}
|
|
211
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
212
|
+
throw new Error(`${name} must be a JSON object.`);
|
|
213
|
+
}
|
|
214
|
+
return parsed;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function parseStrictBoolean(value, name) {
|
|
218
|
+
if (value === true || value === 'true') return true;
|
|
219
|
+
if (value === false || value === 'false') return false;
|
|
220
|
+
throw new Error(`${name} must be true or false.`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function isLoopbackHostname(hostname) {
|
|
224
|
+
const normalized = String(hostname || '').toLowerCase();
|
|
225
|
+
return normalized === 'localhost'
|
|
226
|
+
|| normalized === '127.0.0.1'
|
|
227
|
+
|| normalized === '::1'
|
|
228
|
+
|| normalized === '[::1]';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function normalizeBaseUrl(value) {
|
|
232
|
+
let url;
|
|
233
|
+
try {
|
|
234
|
+
url = new URL(value);
|
|
235
|
+
} catch {
|
|
236
|
+
throw new Error(`Invalid XMemo base URL: ${value}`);
|
|
237
|
+
}
|
|
238
|
+
if (url.username || url.password) {
|
|
239
|
+
throw new Error('XMemo base URL must not contain embedded credentials.');
|
|
240
|
+
}
|
|
241
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopbackHostname(url.hostname))) {
|
|
242
|
+
throw new Error('XMemo base URL must use HTTPS. Plain HTTP is allowed only for localhost/loopback development.');
|
|
243
|
+
}
|
|
244
|
+
url.hash = '';
|
|
245
|
+
url.search = '';
|
|
246
|
+
return url.toString().replace(/\/$/, '');
|
|
247
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SCRIPT_COMMAND,
|
|
3
|
+
DEFAULT_BASE_URL,
|
|
4
|
+
DEFAULT_TIMEOUT_MS,
|
|
5
|
+
} from './core.mjs';
|
|
6
|
+
|
|
7
|
+
export const COMMAND_USAGE_REGISTRY = {
|
|
8
|
+
login: {
|
|
9
|
+
usage: 'login --allow-plaintext',
|
|
10
|
+
desc: 'Start formal device login and explicitly permit local token storage',
|
|
11
|
+
},
|
|
12
|
+
register: {
|
|
13
|
+
usage: 'register --reason <unattended|declined> --allow-plaintext',
|
|
14
|
+
desc: 'Start limited temporary memory only when formal login is unavailable',
|
|
15
|
+
},
|
|
16
|
+
logout: {
|
|
17
|
+
usage: 'logout [--revoke-environment-token]',
|
|
18
|
+
desc: 'Revoke and remove a local credential',
|
|
19
|
+
},
|
|
20
|
+
'auth status': {
|
|
21
|
+
usage: 'auth status [--verify]',
|
|
22
|
+
desc: 'Show local or verified auth status (alias: auth-status)',
|
|
23
|
+
},
|
|
24
|
+
'auth add': {
|
|
25
|
+
usage: 'auth add --from-stdin --allow-plaintext',
|
|
26
|
+
desc: 'Store a formal token read from standard input',
|
|
27
|
+
},
|
|
28
|
+
'auth claim-status': {
|
|
29
|
+
usage: 'auth claim-status [--allow-plaintext]',
|
|
30
|
+
desc: 'Check temporary-account claim status',
|
|
31
|
+
},
|
|
32
|
+
'auth claim-confirm': {
|
|
33
|
+
usage: 'auth claim-confirm [--allow-plaintext]',
|
|
34
|
+
desc: 'Confirm a pending human claim and accept formal token handoff',
|
|
35
|
+
},
|
|
36
|
+
'auth claim-deny': {
|
|
37
|
+
usage: 'auth claim-deny [--allow-plaintext]',
|
|
38
|
+
desc: 'Decline a pending bind and keep isolated temporary access',
|
|
39
|
+
},
|
|
40
|
+
read: {
|
|
41
|
+
usage: 'read --id <id> [--offset <n>] [--limit <n>] [--bucket <bucket>] [--scope <scope>]',
|
|
42
|
+
},
|
|
43
|
+
update: {
|
|
44
|
+
usage: 'update --id <id> [--content <text>] [--path <path>] [--metadata <json>] [--bucket <bucket>] [--scope <scope>]',
|
|
45
|
+
},
|
|
46
|
+
forget: {
|
|
47
|
+
usage: 'forget --id <id> [--reason <text>] --confirm',
|
|
48
|
+
},
|
|
49
|
+
'ledger-list': {
|
|
50
|
+
usage: 'ledger-list [--month <YYYY-MM>] [--from <date>] [--to <date>] [--currency <code>] [--category <name>] [--type <type>] [--min-amount <n>] [--max-amount <n>] [--limit <n>] [--offset <n>]',
|
|
51
|
+
},
|
|
52
|
+
'ledger-summary': {
|
|
53
|
+
usage: 'ledger-summary [--months <n>] [--currency <code>] [--type <type>]',
|
|
54
|
+
},
|
|
55
|
+
overview: {
|
|
56
|
+
usage: 'overview',
|
|
57
|
+
desc: 'Show account overview (memories, storage, agents)',
|
|
58
|
+
},
|
|
59
|
+
activity: {
|
|
60
|
+
usage: 'activity [--limit <n>]',
|
|
61
|
+
desc: 'Show recent account activity',
|
|
62
|
+
},
|
|
63
|
+
stats: {
|
|
64
|
+
usage: 'stats [--scope <scope>] [--path <path>] [--bucket <bucket>] [--memory-type <type>] [--status <status>] [--source <src>] [--since <iso>] [--until <iso>] [--group-by <dims>] [--top-n <1..200>] [--team-id <id>]',
|
|
65
|
+
desc: 'Show memory statistics and breakdown',
|
|
66
|
+
},
|
|
67
|
+
remember: {
|
|
68
|
+
usage: 'remember (--content <text> | --content - | --file <path>) [--path <path>] [--metadata <json-object>]',
|
|
69
|
+
},
|
|
70
|
+
recall: {
|
|
71
|
+
usage: 'recall --query <text> [--limit <n>] [--explain <true|false>] [--prefer_working <true|false>] [--compact]',
|
|
72
|
+
},
|
|
73
|
+
search: {
|
|
74
|
+
usage: 'search --query <text> [--limit <n>] [--explain <true|false>] [--prefer_working <true|false>] [--compact]',
|
|
75
|
+
},
|
|
76
|
+
'recall-context': {
|
|
77
|
+
usage: 'recall-context --query <text> [--max_items <n>] [--max_tokens <n>] [--prefer_working <true|false>] [--include_knowledge <true|false>]',
|
|
78
|
+
desc: 'Read-only bounded Memory context; opt into Knowledge with true',
|
|
79
|
+
},
|
|
80
|
+
'save-state': {
|
|
81
|
+
usage: 'save-state --key <key> [--content <text>] [--ttl_seconds <0..604800>]',
|
|
82
|
+
desc: '(alias: state-save)',
|
|
83
|
+
},
|
|
84
|
+
'restore-state': {
|
|
85
|
+
usage: 'restore-state --key <key>',
|
|
86
|
+
desc: '(alias: state-restore)',
|
|
87
|
+
},
|
|
88
|
+
'state-save': {
|
|
89
|
+
usage: 'state-save --key <key> [--content <text>] [--ttl_seconds <0..604800>] (legacy alias)',
|
|
90
|
+
aliasOf: 'save-state',
|
|
91
|
+
},
|
|
92
|
+
'state-restore': {
|
|
93
|
+
usage: 'state-restore --key <key> (legacy alias)',
|
|
94
|
+
aliasOf: 'restore-state',
|
|
95
|
+
},
|
|
96
|
+
'restart-snapshot': {
|
|
97
|
+
usage: 'restart-snapshot [--state_key <key>] [--session_id <id>] [--ttl_seconds <0..2592000>]',
|
|
98
|
+
desc: 'Save a full restart-continuity snapshot',
|
|
99
|
+
},
|
|
100
|
+
'restart-restore': {
|
|
101
|
+
usage: 'restart-restore [--snapshot_id <id> | --source_session_id <id>] [--target_session_id <id>]',
|
|
102
|
+
desc: 'Restore the latest or selected restart snapshot',
|
|
103
|
+
},
|
|
104
|
+
'todo-add': {
|
|
105
|
+
usage: 'todo-add --content <text>',
|
|
106
|
+
},
|
|
107
|
+
'todo-list': {
|
|
108
|
+
usage: 'todo-list',
|
|
109
|
+
},
|
|
110
|
+
'todo-done': {
|
|
111
|
+
usage: 'todo-done --id <todo_id>',
|
|
112
|
+
},
|
|
113
|
+
'expense-add': {
|
|
114
|
+
usage: 'expense-add --item <text> --amount <number> --currency <code>',
|
|
115
|
+
},
|
|
116
|
+
doctor: {
|
|
117
|
+
usage: 'doctor [--anonymous]',
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export function buildTopLevelHelp() {
|
|
122
|
+
const lines = [
|
|
123
|
+
'XMemo Standalone Skill Runtime',
|
|
124
|
+
'',
|
|
125
|
+
'Usage:',
|
|
126
|
+
` ${SCRIPT_COMMAND} <command> [options]`,
|
|
127
|
+
'',
|
|
128
|
+
'Commands:',
|
|
129
|
+
];
|
|
130
|
+
for (const entry of Object.values(COMMAND_USAGE_REGISTRY)) {
|
|
131
|
+
if (entry.aliasOf) continue;
|
|
132
|
+
if (entry.desc) {
|
|
133
|
+
lines.push(` ${entry.usage.padEnd(35)} ${entry.desc}`);
|
|
134
|
+
} else {
|
|
135
|
+
lines.push(` ${entry.usage}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
lines.push('');
|
|
139
|
+
lines.push('Credential resolution:');
|
|
140
|
+
lines.push(' XMEMO_KEY Preferred; never copied to the local credential file');
|
|
141
|
+
lines.push(' User credential file Read only as a fallback');
|
|
142
|
+
lines.push('');
|
|
143
|
+
lines.push('Global options:');
|
|
144
|
+
lines.push(' --json Print the API response as JSON');
|
|
145
|
+
lines.push(' --terminal Force human-readable terminal output even when piped');
|
|
146
|
+
lines.push(` --base-url <url> Override ${DEFAULT_BASE_URL}; HTTPS or loopback HTTP only`);
|
|
147
|
+
lines.push(` --timeout-ms <ms> Per-request timeout (default: ${DEFAULT_TIMEOUT_MS})`);
|
|
148
|
+
lines.push(' --compact Shorten recall/search content for terminals');
|
|
149
|
+
lines.push(' --allow-plaintext Explicitly permit unencrypted user-file credential storage');
|
|
150
|
+
lines.push(' --version Show the Skill runtime version');
|
|
151
|
+
lines.push(' --help, -h Show this help');
|
|
152
|
+
lines.push('');
|
|
153
|
+
lines.push(`Run \`${SCRIPT_COMMAND} <command> --help\` for command-specific usage.`);
|
|
154
|
+
return lines.join('\n');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function printUsage(command) {
|
|
158
|
+
const commonOptions = '[--json] [--terminal] [--base-url <url>] [--timeout-ms <ms>]';
|
|
159
|
+
if (command === undefined || (!COMMAND_USAGE_REGISTRY[command] && command !== 'auth' && command !== 'auth-status')) {
|
|
160
|
+
console.log(buildTopLevelHelp());
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (command === 'auth' || command === 'auth-status') {
|
|
165
|
+
console.log(`Usage:\n ${SCRIPT_COMMAND} auth status [--verify] ${commonOptions}\n ${SCRIPT_COMMAND} auth add --from-stdin --allow-plaintext\n ${SCRIPT_COMMAND} auth claim-status [--allow-plaintext]\n ${SCRIPT_COMMAND} auth claim-confirm [--allow-plaintext]\n ${SCRIPT_COMMAND} auth claim-deny [--allow-plaintext]\n\nAlias: ${SCRIPT_COMMAND} auth-status [--verify]\nXMEMO_KEY remains the preferred non-file credential source. --allow-plaintext explicitly permits unencrypted user-file storage.\nRun \`${SCRIPT_COMMAND} --help\` to list all commands.`);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const entry = COMMAND_USAGE_REGISTRY[command];
|
|
170
|
+
if (entry) {
|
|
171
|
+
console.log(`Usage:\n ${SCRIPT_COMMAND} ${entry.usage} ${commonOptions}`);
|
|
172
|
+
if (command === 'logout') {
|
|
173
|
+
console.log('\nXMEMO_KEY is externally managed and is not revoked unless --revoke-environment-token is explicitly passed.');
|
|
174
|
+
}
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
console.log(buildTopLevelHelp());
|
|
179
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { DEFAULT_BASE_URL } from './core.mjs';
|
|
4
|
+
|
|
5
|
+
export const VAULT_CREDENTIAL_NAME = 'custom.xmemo';
|
|
6
|
+
export const VAULT_ENTRY_NAME = 'access_token';
|
|
7
|
+
export const DEFAULT_AUTHD_SOCKET = '/run/hatch/auth/authd.sock';
|
|
8
|
+
export const VAULT_ALLOWED_ORIGIN = new URL(DEFAULT_BASE_URL).origin;
|
|
9
|
+
export const AUTHD_TIMEOUT_MS = 2000;
|
|
10
|
+
export const MAX_AUTHD_RESPONSE_BYTES = 65536;
|
|
11
|
+
|
|
12
|
+
export class VaultKeyError extends Error {
|
|
13
|
+
constructor(message, codeOrOptions = 'authd_error') {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'VaultKeyError';
|
|
16
|
+
if (typeof codeOrOptions === 'string') {
|
|
17
|
+
this.code = codeOrOptions;
|
|
18
|
+
} else if (codeOrOptions && typeof codeOrOptions === 'object') {
|
|
19
|
+
this.code = codeOrOptions.code || 'authd_error';
|
|
20
|
+
if (codeOrOptions.statusCode !== undefined) this.statusCode = codeOrOptions.statusCode;
|
|
21
|
+
if (codeOrOptions.cause !== undefined) this.cause = codeOrOptions.cause;
|
|
22
|
+
} else {
|
|
23
|
+
this.code = 'authd_error';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isSurrogateToken(value) {
|
|
29
|
+
return typeof value === 'string' && value.startsWith('hsurr:');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function validateSurrogate(token) {
|
|
33
|
+
if (typeof token !== 'string' || !token.startsWith('hsurr:')) {
|
|
34
|
+
throw new VaultKeyError('Invalid surrogate token: missing hsurr: prefix', 'authd_error');
|
|
35
|
+
}
|
|
36
|
+
if (token.length < 7 || token.length > 4096) {
|
|
37
|
+
throw new VaultKeyError('Invalid surrogate token: length out of bounds', 'authd_error');
|
|
38
|
+
}
|
|
39
|
+
if (/[\s\u0000-\u001F\u007F-\u009F]/.test(token)) {
|
|
40
|
+
throw new VaultKeyError('Invalid surrogate token: contains whitespace or control characters', 'authd_error');
|
|
41
|
+
}
|
|
42
|
+
return token;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function assertSurrogateOrigin(authHeader, targetUrl) {
|
|
46
|
+
if (!authHeader || typeof authHeader !== 'string') return;
|
|
47
|
+
const match = authHeader.match(/^Bearer\s+(\S+)$/i);
|
|
48
|
+
if (!match) return;
|
|
49
|
+
const token = match[1];
|
|
50
|
+
if (!isSurrogateToken(token)) return;
|
|
51
|
+
|
|
52
|
+
const urlObj = typeof targetUrl === 'string' ? new URL(targetUrl) : targetUrl;
|
|
53
|
+
if (urlObj.origin !== new URL(VAULT_ALLOWED_ORIGIN).origin) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`Meta Muse vault credentials (surrogate) are restricted to ${VAULT_ALLOWED_ORIGIN} and cannot be sent to ${urlObj.origin}. Unset XMEMO_BASE_URL or use a standard credential.`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function getVaultSurrogate(options = {}) {
|
|
61
|
+
const socketPath = options.socketPath || process.env.JARVIS_AUTHD_SOCK || DEFAULT_AUTHD_SOCKET;
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
await fs.stat(socketPath);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
throw new VaultKeyError(`Auth daemon socket unavailable at ${socketPath}`, 'unavailable');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
const postData = JSON.stringify({ name: VAULT_CREDENTIAL_NAME });
|
|
71
|
+
const reqOptions = {
|
|
72
|
+
socketPath,
|
|
73
|
+
path: '/v1/credentials/surrogate',
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: {
|
|
76
|
+
'Content-Type': 'application/json',
|
|
77
|
+
'Content-Length': Buffer.byteLength(postData),
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const timeout = options.timeoutMs || AUTHD_TIMEOUT_MS;
|
|
82
|
+
let settled = false;
|
|
83
|
+
let deadlineTimer = null;
|
|
84
|
+
const cleanup = () => {
|
|
85
|
+
if (deadlineTimer) {
|
|
86
|
+
clearTimeout(deadlineTimer);
|
|
87
|
+
deadlineTimer = null;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
const settleResolve = (val) => {
|
|
91
|
+
if (settled) return;
|
|
92
|
+
settled = true;
|
|
93
|
+
cleanup();
|
|
94
|
+
resolve(val);
|
|
95
|
+
};
|
|
96
|
+
const settleReject = (err) => {
|
|
97
|
+
if (settled) return;
|
|
98
|
+
settled = true;
|
|
99
|
+
cleanup();
|
|
100
|
+
reject(err);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const req = http.request(reqOptions, (res) => {
|
|
104
|
+
let responseData = '';
|
|
105
|
+
let byteCount = 0;
|
|
106
|
+
|
|
107
|
+
res.on('data', (chunk) => {
|
|
108
|
+
byteCount += Buffer.byteLength(chunk);
|
|
109
|
+
if (byteCount > MAX_AUTHD_RESPONSE_BYTES) {
|
|
110
|
+
res.destroy();
|
|
111
|
+
settleReject(new VaultKeyError('Auth daemon response exceeded size limit', 'authd_error'));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
responseData += chunk;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
res.on('end', () => {
|
|
118
|
+
if (res.statusCode === 403 || res.statusCode === 404) {
|
|
119
|
+
settleReject(new VaultKeyError(`Vault credential missing or access denied (HTTP ${res.statusCode})`, {
|
|
120
|
+
code: 'missing',
|
|
121
|
+
statusCode: res.statusCode,
|
|
122
|
+
}));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
127
|
+
settleReject(new VaultKeyError(`Auth daemon returned HTTP ${res.statusCode}`, {
|
|
128
|
+
code: 'authd_error',
|
|
129
|
+
statusCode: res.statusCode,
|
|
130
|
+
}));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let parsed;
|
|
135
|
+
try {
|
|
136
|
+
parsed = JSON.parse(responseData);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
settleReject(new VaultKeyError('Failed to parse auth daemon response as JSON', {
|
|
139
|
+
code: 'authd_error',
|
|
140
|
+
cause: err,
|
|
141
|
+
}));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const candidate = typeof parsed === 'string'
|
|
146
|
+
? parsed
|
|
147
|
+
: (parsed?.[VAULT_ENTRY_NAME] || parsed?.surrogate || parsed?.token);
|
|
148
|
+
|
|
149
|
+
if (!candidate || typeof candidate !== 'string') {
|
|
150
|
+
settleReject(new VaultKeyError('No valid surrogate entry found in auth daemon response', 'authd_error'));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const validated = validateSurrogate(candidate);
|
|
156
|
+
settleResolve(validated);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
settleReject(err);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
res.on('error', (err) => {
|
|
163
|
+
settleReject(new VaultKeyError(`Auth daemon stream error: ${err.message}`, {
|
|
164
|
+
code: 'authd_error',
|
|
165
|
+
cause: err,
|
|
166
|
+
}));
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
deadlineTimer = setTimeout(() => {
|
|
171
|
+
req.destroy();
|
|
172
|
+
settleReject(new VaultKeyError(`Auth daemon request timed out (exceeded total deadline of ${timeout} ms)`, 'authd_error'));
|
|
173
|
+
}, timeout);
|
|
174
|
+
if (deadlineTimer.unref) deadlineTimer.unref();
|
|
175
|
+
|
|
176
|
+
req.setTimeout(timeout, () => {
|
|
177
|
+
req.destroy();
|
|
178
|
+
settleReject(new VaultKeyError(`Auth daemon request timed out after ${timeout} ms`, 'authd_error'));
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
req.on('error', (err) => {
|
|
182
|
+
if (err.code === 'ENOENT' || err.code === 'ECONNREFUSED') {
|
|
183
|
+
settleReject(new VaultKeyError(`Auth daemon socket connection failed: ${err.message}`, {
|
|
184
|
+
code: 'unavailable',
|
|
185
|
+
cause: err,
|
|
186
|
+
}));
|
|
187
|
+
} else {
|
|
188
|
+
settleReject(new VaultKeyError(`Auth daemon request failed: ${err.message}`, {
|
|
189
|
+
code: 'authd_error',
|
|
190
|
+
cause: err,
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
req.write(postData);
|
|
196
|
+
req.end();
|
|
197
|
+
});
|
|
198
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { DEFAULT_BASE_URL } from './core.mjs';
|
|
2
|
+
import { isSurrogateToken, assertSurrogateOrigin } from './muse-vault.mjs';
|
|
3
|
+
|
|
4
|
+
export const OPENCLAW_SENTINEL_REGEX = /^oc-sent-v2\.[A-Za-z0-9_-]+\.end$/;
|
|
5
|
+
export const ALLOWED_EGRESS_ORIGIN = new URL(DEFAULT_BASE_URL).origin;
|
|
6
|
+
|
|
7
|
+
export function isOpenClawSentinel(value) {
|
|
8
|
+
return typeof value === 'string' && OPENCLAW_SENTINEL_REGEX.test(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function isProxyEnvActive(env = process.env) {
|
|
12
|
+
const proxy = env.HTTPS_PROXY || env.https_proxy;
|
|
13
|
+
const nodeProxy = env.NODE_USE_ENV_PROXY;
|
|
14
|
+
const isNodeProxyTruthy = Boolean(
|
|
15
|
+
nodeProxy && nodeProxy !== '0' && String(nodeProxy).toLowerCase() !== 'false'
|
|
16
|
+
);
|
|
17
|
+
return Boolean(proxy && isNodeProxyTruthy);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function sanitizeSensitiveValue(value) {
|
|
21
|
+
if (typeof value !== 'string') return value;
|
|
22
|
+
if (value.startsWith('hsurr:') || isOpenClawSentinel(value)) return '[REDACTED]';
|
|
23
|
+
if (value.includes('oc-sent-v2.') || value.includes('hsurr:')) {
|
|
24
|
+
return value
|
|
25
|
+
.replace(/hsurr:[^\s"'>]+/g, '[REDACTED]')
|
|
26
|
+
.replace(/oc-sent-v2\.[A-Za-z0-9_-]+\.end/g, '[REDACTED]');
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function assertOpenClawEgress(token, targetUrl, env = process.env) {
|
|
32
|
+
if (!isOpenClawSentinel(token)) return;
|
|
33
|
+
|
|
34
|
+
if (!isProxyEnvActive(env)) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
'OpenClaw egress proxy is required when using OpenClaw secrets. Enable secrets.egressProxy.enabled and ensure execution runs in Gateway-hosted exec (HTTPS_PROXY and NODE_USE_ENV_PROXY=1 must be set).'
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const urlObj = typeof targetUrl === 'string' ? new URL(targetUrl) : targetUrl;
|
|
41
|
+
if (urlObj.origin !== ALLOWED_EGRESS_ORIGIN) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`OpenClaw secret sentinels are restricted to ${ALLOWED_EGRESS_ORIGIN} and cannot be sent to ${urlObj.origin}. Unset XMEMO_BASE_URL or use a standard credential.`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function assertEgressSecurity(authHeader, targetUrl, env = process.env) {
|
|
49
|
+
if (!authHeader || typeof authHeader !== 'string') return;
|
|
50
|
+
const match = authHeader.match(/^Bearer\s+(\S+)$/i);
|
|
51
|
+
if (!match) return;
|
|
52
|
+
const token = match[1];
|
|
53
|
+
|
|
54
|
+
if (isSurrogateToken(token)) {
|
|
55
|
+
assertSurrogateOrigin(authHeader, targetUrl);
|
|
56
|
+
} else if (isOpenClawSentinel(token)) {
|
|
57
|
+
assertOpenClawEgress(token, targetUrl, env);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function configureEgressRequest(reqOptions, authHeader, targetUrl, env = process.env) {
|
|
62
|
+
assertEgressSecurity(authHeader, targetUrl, env);
|
|
63
|
+
}
|