@byokit/openclaw 0.5.1 → 0.6.2
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 +56 -3
- package/README.md +205 -15
- package/SECURITY.md +6 -6
- package/dist/auth-store.d.ts +6 -0
- package/dist/auth-store.js +58 -15
- package/dist/bridge.d.ts +10 -2
- package/dist/bridge.js +61 -14
- package/dist/browser/broker.d.ts +63 -0
- package/dist/browser/broker.js +1003 -0
- package/dist/browser/host.d.ts +98 -0
- package/dist/browser/host.js +945 -0
- package/dist/browser/resume.d.ts +10 -0
- package/dist/browser/resume.js +23 -0
- package/dist/browser/store.d.ts +25 -0
- package/dist/browser/store.js +151 -0
- package/dist/browser/verify.d.ts +17 -0
- package/dist/browser/verify.js +38 -0
- package/dist/browser.d.ts +212 -0
- package/dist/browser.js +4 -0
- package/dist/config.d.ts +28 -0
- package/dist/config.js +130 -0
- package/dist/day-usage.d.ts +78 -0
- package/dist/day-usage.js +104 -0
- package/dist/device.d.ts +38 -5
- package/dist/device.js +224 -4
- package/dist/engine-patches.d.ts +36 -0
- package/dist/engine-patches.js +320 -0
- package/dist/engine-status.d.ts +13 -0
- package/dist/engine-status.js +31 -0
- package/dist/engine.d.ts +13 -1
- package/dist/engine.js +99 -52
- package/dist/index.d.ts +9 -1
- package/dist/index.js +2 -0
- package/dist/kit.d.ts +28 -3
- package/dist/kit.js +319 -12
- package/dist/link.d.ts +16 -5
- package/dist/link.js +333 -6
- package/dist/routes.d.ts +19 -8
- package/dist/routes.js +44 -11
- package/dist/routes.json +3775 -356
- package/dist/runs.js +8 -2
- package/dist/signin.d.ts +1 -1
- package/dist/signin.js +19 -4
- package/dist/testing/browser.d.ts +50 -0
- package/dist/testing/browser.js +166 -0
- package/dist/testing/contract.js +1 -2
- package/dist/testing/fake-gateway.d.ts +14 -7
- package/dist/testing/fake-gateway.js +8 -6
- package/dist/testing/index.d.ts +3 -2
- package/dist/testing/index.js +1 -0
- package/dist/testing/model-stub.d.ts +15 -1
- package/dist/testing/model-stub.js +11 -11
- package/dist/types.d.ts +50 -3
- package/dist/usage-boots.d.ts +8 -0
- package/dist/usage-boots.js +26 -0
- package/dist/usage.d.ts +53 -0
- package/dist/usage.js +60 -0
- package/dist/words.json +42 -0
- package/engine/OPENCLAW-LICENSE +24 -0
- package/engine/patches.json +59 -0
- package/engine/patches.schema.json +41 -0
- package/package.json +12 -4
- package/plugin/index.js +78 -5
- package/plugin/usage.js +125 -0
package/dist/device.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
// (test/portable.test.ts guards it). Only type imports from @byokit/link; the seal crypto bundles cleanly.
|
|
3
|
-
import { boxKeyPairFromSeed } from '@byokit/seal';
|
|
1
|
+
import { openBoxFromSeed, boxKeyPairFromSeed } from '@byokit/seal';
|
|
4
2
|
import { toAccountView } from "./words.js";
|
|
5
|
-
import { b64urlEncode, openNotice } from "./notices.js";
|
|
3
|
+
import { b64urlDecode, b64urlEncode, openNotice } from "./notices.js";
|
|
6
4
|
/**
|
|
7
5
|
* The host ended a stream with its own words (e.g. a refusal). Portable twin of link's PublicLinkError:
|
|
8
6
|
* same shape ({ code: 'failed', sealed: true }) so a screen can show .message the same way. A device-side
|
|
@@ -16,6 +14,10 @@ export class LinkRefused extends Error {
|
|
|
16
14
|
this.name = 'LinkRefused';
|
|
17
15
|
}
|
|
18
16
|
}
|
|
17
|
+
export class BrowserRefused extends LinkRefused {
|
|
18
|
+
why;
|
|
19
|
+
constructor(why) { super(`Sign-in refused: ${why}`); this.why = why; }
|
|
20
|
+
}
|
|
19
21
|
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
20
22
|
/**
|
|
21
23
|
* Newline-delimited JSON frames off a link stream, in order. A host end with an error becomes a
|
|
@@ -115,8 +117,223 @@ function liveStream(open) {
|
|
|
115
117
|
},
|
|
116
118
|
};
|
|
117
119
|
}
|
|
120
|
+
/** A sealed hint, never an authorization or a lease. Re-fetch signIns() before taking any action. */
|
|
121
|
+
export function openSignInNotice(data, seed) {
|
|
122
|
+
if (data.v !== 1 || typeof data.sealed !== 'string' || !(seed instanceof Uint8Array) || seed.length !== 32)
|
|
123
|
+
return null;
|
|
124
|
+
const bundle = b64urlDecode(data.sealed);
|
|
125
|
+
const bytes = bundle && openBoxFromSeed(bundle, seed);
|
|
126
|
+
if (!bytes)
|
|
127
|
+
return null;
|
|
128
|
+
try {
|
|
129
|
+
const v = JSON.parse(new TextDecoder().decode(bytes));
|
|
130
|
+
if (!isRecord(v) || v.source !== 'signin' || typeof v.id !== 'string' || !Number.isSafeInteger(v.gen)
|
|
131
|
+
|| typeof v.member !== 'string' || typeof v.site !== 'string')
|
|
132
|
+
return null;
|
|
133
|
+
return { source: 'signin', id: v.id, gen: v.gen, member: v.member, site: v.site };
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
bytes.fill(0);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function decodeBrowserFrame(value) {
|
|
143
|
+
if (!isRecord(value) || typeof value.jpeg !== 'string' || value.jpeg.length > 8 * 1024 * 1024
|
|
144
|
+
|| !Number.isSafeInteger(value.seq) || !Number.isSafeInteger(value.w) || !Number.isSafeInteger(value.h)
|
|
145
|
+
|| value.w < 1 || value.h < 1 || typeof value.at !== 'number' || !Number.isFinite(value.at))
|
|
146
|
+
throw new LinkRefused('Invalid browser frame');
|
|
147
|
+
const jpeg = b64urlDecode(value.jpeg);
|
|
148
|
+
if (!jpeg)
|
|
149
|
+
throw new LinkRefused('Invalid browser frame');
|
|
150
|
+
return { seq: value.seq, w: value.w, h: value.h, at: value.at, jpeg };
|
|
151
|
+
}
|
|
152
|
+
/** Transient streams bypass link's durable request/answer cache: no lease nonce or frame is persisted. */
|
|
153
|
+
export function browserDevice(link) {
|
|
154
|
+
const call = async (op, args = {}) => {
|
|
155
|
+
const it = liveStream(() => link.stream(`oc.browser.${op}`, args))[Symbol.asyncIterator]();
|
|
156
|
+
try {
|
|
157
|
+
const reply = await it.next();
|
|
158
|
+
if (!reply.done && isRecord(reply.value) && typeof reply.value.refused === 'string')
|
|
159
|
+
throw new BrowserRefused(reply.value.refused);
|
|
160
|
+
if (reply.done || !isRecord(reply.value) || !('value' in reply.value))
|
|
161
|
+
throw new LinkRefused('Browser request ended');
|
|
162
|
+
return reply.value.value;
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
await it.return?.();
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
return {
|
|
169
|
+
state: member => call('state', { member }),
|
|
170
|
+
signIns: () => call('signins'),
|
|
171
|
+
takeover: (id, gen, o) => call('takeover', { id, gen, ...o }),
|
|
172
|
+
confirmOrigin: async (lease, origin) => { await call('confirmorigin', { lease, origin }); },
|
|
173
|
+
done: lease => call('done', { lease }),
|
|
174
|
+
notNow: (id, gen) => call('notnow', { id, gen }),
|
|
175
|
+
reopen: (id, gen) => call('reopen', { id, gen }),
|
|
176
|
+
retry: (id, gen) => call('retry', { id, gen }),
|
|
177
|
+
cancel: (id, gen) => call('cancel', { id, gen }),
|
|
178
|
+
forget: async (member, site) => { await call('forget', { member, site }); },
|
|
179
|
+
thumbnail: async (source) => {
|
|
180
|
+
const result = await call('thumb', { source });
|
|
181
|
+
return result.state === 'ok' ? { state: 'ok', frame: decodeBrowserFrame(result.frame) } : result;
|
|
182
|
+
},
|
|
183
|
+
live: (source, o) => {
|
|
184
|
+
let stream;
|
|
185
|
+
let closed = false;
|
|
186
|
+
let ended = false;
|
|
187
|
+
let inputBusy = false;
|
|
188
|
+
let inputBytes = 0;
|
|
189
|
+
const inputs = [];
|
|
190
|
+
let text = '';
|
|
191
|
+
let frame;
|
|
192
|
+
let state = { source, mode: o?.lease ? 'control' : 'observe', phase: 'connecting' };
|
|
193
|
+
const frameWaiters = new Set();
|
|
194
|
+
const stateWaiters = new Set();
|
|
195
|
+
const wake = () => {
|
|
196
|
+
for (const w of [...frameWaiters, ...stateWaiters])
|
|
197
|
+
w();
|
|
198
|
+
frameWaiters.clear();
|
|
199
|
+
stateWaiters.clear();
|
|
200
|
+
};
|
|
201
|
+
const close = () => {
|
|
202
|
+
closed = ended = true;
|
|
203
|
+
frame = undefined;
|
|
204
|
+
state = undefined;
|
|
205
|
+
text = '';
|
|
206
|
+
inputs.length = 0;
|
|
207
|
+
inputBytes = 0;
|
|
208
|
+
stream?.end('closed');
|
|
209
|
+
wake();
|
|
210
|
+
};
|
|
211
|
+
const finish = (error) => {
|
|
212
|
+
if (closed || ended)
|
|
213
|
+
return;
|
|
214
|
+
ended = true;
|
|
215
|
+
frame = undefined;
|
|
216
|
+
text = '';
|
|
217
|
+
inputs.length = 0;
|
|
218
|
+
inputBytes = 0;
|
|
219
|
+
state = { source, mode: o?.lease ? 'control' : 'observe',
|
|
220
|
+
phase: error === 'unreachable' ? 'reconnecting' : error ? 'failed' : 'ended',
|
|
221
|
+
...(error === 'removed' ? { why: 'revoked' } : {}) };
|
|
222
|
+
wake();
|
|
223
|
+
};
|
|
224
|
+
const flushInput = async () => {
|
|
225
|
+
if (inputBusy || !stream || closed || ended)
|
|
226
|
+
return;
|
|
227
|
+
inputBusy = true;
|
|
228
|
+
try {
|
|
229
|
+
while (inputs.length && !closed && !ended) {
|
|
230
|
+
const value = inputs.shift();
|
|
231
|
+
await stream.write(value);
|
|
232
|
+
if (!closed && !ended)
|
|
233
|
+
inputBytes -= new TextEncoder().encode(value).byteLength;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
finish('failed');
|
|
238
|
+
stream.end('failed');
|
|
239
|
+
}
|
|
240
|
+
finally {
|
|
241
|
+
inputBusy = false;
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
const decoder = new TextDecoder();
|
|
245
|
+
void link.stream('oc.browser.live', { source, mode: o?.lease ? 'control' : 'observe', ...o }).then(s => {
|
|
246
|
+
if (closed) {
|
|
247
|
+
s.end();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
stream = s;
|
|
251
|
+
s.onData = chunk => {
|
|
252
|
+
if (closed || ended)
|
|
253
|
+
return;
|
|
254
|
+
try {
|
|
255
|
+
text += decoder.decode(chunk, { stream: true });
|
|
256
|
+
if (text.length > 9 * 1024 * 1024)
|
|
257
|
+
throw new Error();
|
|
258
|
+
let at;
|
|
259
|
+
while ((at = text.indexOf('\n')) >= 0) {
|
|
260
|
+
const v = JSON.parse(text.slice(0, at));
|
|
261
|
+
text = text.slice(at + 1);
|
|
262
|
+
if (!isRecord(v))
|
|
263
|
+
throw new Error();
|
|
264
|
+
if ('frame' in v)
|
|
265
|
+
frame = decodeBrowserFrame(v.frame);
|
|
266
|
+
else if (isRecord(v.state)) {
|
|
267
|
+
state = v.state;
|
|
268
|
+
if (state.phase !== 'live')
|
|
269
|
+
frame = undefined;
|
|
270
|
+
if (state.phase === 'ended' || state.phase === 'failed') {
|
|
271
|
+
ended = true;
|
|
272
|
+
inputs.length = 0;
|
|
273
|
+
inputBytes = 0;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
else
|
|
277
|
+
throw new Error();
|
|
278
|
+
}
|
|
279
|
+
wake();
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
finish('failed');
|
|
283
|
+
s.end('failed');
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
s.onEnd = finish;
|
|
287
|
+
}, () => finish('failed'));
|
|
288
|
+
const channel = (take, waiters) => ({
|
|
289
|
+
[Symbol.asyncIterator]() {
|
|
290
|
+
return {
|
|
291
|
+
async next() {
|
|
292
|
+
for (;;) {
|
|
293
|
+
if (closed)
|
|
294
|
+
return { value: undefined, done: true };
|
|
295
|
+
const value = take();
|
|
296
|
+
if (value !== undefined)
|
|
297
|
+
return { value, done: false };
|
|
298
|
+
if (ended)
|
|
299
|
+
return { value: undefined, done: true };
|
|
300
|
+
await new Promise(resolve => waiters.add(resolve));
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
async return() { close(); return { value: undefined, done: true }; },
|
|
304
|
+
};
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
return {
|
|
308
|
+
states: channel(() => { const s = state; state = undefined; return s; }, stateWaiters),
|
|
309
|
+
frames: channel(() => { const f = frame; frame = undefined; return f; }, frameWaiters),
|
|
310
|
+
input: i => {
|
|
311
|
+
// Ordered, bounded memory-only input while online. Never replay secret input after a drop.
|
|
312
|
+
if (closed || ended || !o?.lease)
|
|
313
|
+
return;
|
|
314
|
+
if (!stream) {
|
|
315
|
+
finish('failed');
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const value = `${JSON.stringify(i)}\n`;
|
|
319
|
+
const bytes = new TextEncoder().encode(value).byteLength;
|
|
320
|
+
if (inputBytes + bytes > 65536) {
|
|
321
|
+
finish('failed');
|
|
322
|
+
stream.end('failed');
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
inputs.push(value);
|
|
326
|
+
inputBytes += bytes;
|
|
327
|
+
void flushInput();
|
|
328
|
+
},
|
|
329
|
+
close,
|
|
330
|
+
};
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
}
|
|
118
334
|
export function openclawDevice(link) {
|
|
119
335
|
return {
|
|
336
|
+
browser: browserDevice(link),
|
|
120
337
|
state: () => link.request('oc.state'),
|
|
121
338
|
routes: () => link.request('oc.routes'),
|
|
122
339
|
signIn: {
|
|
@@ -186,10 +403,13 @@ export function openclawDevice(link) {
|
|
|
186
403
|
await link.request('oc.notices.register', { boxPublicKey: b64urlEncode(boxKeyPairFromSeed(seed).publicKey) });
|
|
187
404
|
},
|
|
188
405
|
openNotice,
|
|
406
|
+
callDynamic: (method, params) => link.request('oc.call', { method, params }),
|
|
189
407
|
call: (method, params) => link.request('oc.call', { method, params }),
|
|
190
408
|
};
|
|
191
409
|
}
|
|
192
410
|
// Portable, re-exported by ./device (7.3).
|
|
193
411
|
export { openNotice } from "./notices.js";
|
|
412
|
+
export { readAgentUsage, agentUsageOf } from "./usage.js";
|
|
413
|
+
export { readAgentDayUsage } from "./day-usage.js";
|
|
194
414
|
// The kit's sentences, so a phone or browser shows the same words the computer does (5.14).
|
|
195
415
|
export { stateWords, toAccountView, words } from "./words.js";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type PatchFile = {
|
|
2
|
+
path: string;
|
|
3
|
+
before: string;
|
|
4
|
+
after: string;
|
|
5
|
+
edits: {
|
|
6
|
+
find: string;
|
|
7
|
+
replace: string;
|
|
8
|
+
}[];
|
|
9
|
+
};
|
|
10
|
+
export type PatchSet = {
|
|
11
|
+
v: 1;
|
|
12
|
+
id: string;
|
|
13
|
+
upstream: {
|
|
14
|
+
name: string;
|
|
15
|
+
version: string;
|
|
16
|
+
integrity: string;
|
|
17
|
+
commit: string;
|
|
18
|
+
license: string;
|
|
19
|
+
};
|
|
20
|
+
files: PatchFile[];
|
|
21
|
+
};
|
|
22
|
+
export declare class EnginePatchError extends Error {
|
|
23
|
+
readonly code = "engine-patch";
|
|
24
|
+
readonly cause: 'spec' | 'drift' | 'drift-after-build' | 'write';
|
|
25
|
+
readonly file?: string;
|
|
26
|
+
constructor(cause: EnginePatchError['cause'], file?: string);
|
|
27
|
+
}
|
|
28
|
+
export declare const sha256: (text: string | Buffer) => string;
|
|
29
|
+
export declare const patchId: (files: PatchFile[]) => string;
|
|
30
|
+
export declare function readPatchSet(path: string, version: string, integrity: string): PatchSet;
|
|
31
|
+
export declare function editText(text: string, file: PatchFile): string;
|
|
32
|
+
export declare function atomic(path: string, text: string): void;
|
|
33
|
+
export declare function processStartTime(pid: number): string;
|
|
34
|
+
export declare function verifyEngineSet(dir: string, set: PatchSet, installMatches: (dir: string) => boolean): void;
|
|
35
|
+
export declare function engineSetName(set: PatchSet): string;
|
|
36
|
+
export declare function prepareEngineSet(engineDir: string, set: PatchSet, install: (dir: string) => void | Promise<void>, installMatches: (dir: string) => boolean): Promise<string>;
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// Internal artifact seam. Published patch semantics live only in engine/patches.json.
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { closeSync, constants, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { chmod, cp, open, readdir, rm } from 'node:fs/promises';
|
|
5
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
export class EnginePatchError extends Error {
|
|
7
|
+
code = 'engine-patch';
|
|
8
|
+
cause;
|
|
9
|
+
file;
|
|
10
|
+
constructor(cause, file) {
|
|
11
|
+
super(`engine-patch: ${cause}${file ? ` (${file})` : ''}`);
|
|
12
|
+
this.name = 'EnginePatchError';
|
|
13
|
+
this.cause = cause;
|
|
14
|
+
this.file = file;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export const sha256 = (text) => createHash('sha256').update(text).digest('hex');
|
|
18
|
+
function canonical(value) {
|
|
19
|
+
if (Array.isArray(value))
|
|
20
|
+
return value.map(canonical);
|
|
21
|
+
if (value && typeof value === 'object')
|
|
22
|
+
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, item]) => [key, canonical(item)]));
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
export const patchId = (files) => sha256(JSON.stringify(canonical(files))).slice(0, 16);
|
|
26
|
+
const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
27
|
+
const keys = (v, expected) => Object.keys(v).sort().join(',') === expected.sort().join(',');
|
|
28
|
+
function validateFiles(id, files) {
|
|
29
|
+
if (typeof id !== 'string' || !Array.isArray(files))
|
|
30
|
+
throw new EnginePatchError('spec');
|
|
31
|
+
const paths = new Set();
|
|
32
|
+
for (const f of files) {
|
|
33
|
+
if (!object(f) || !keys(f, ['path', 'before', 'after', 'edits']) || typeof f.path !== 'string' ||
|
|
34
|
+
!/^dist\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_.-]+\.m?js$/.test(f.path) || f.path.split('/').some(p => p === '.' || p === '..') || paths.has(f.path) ||
|
|
35
|
+
typeof f.before !== 'string' || !/^[a-f0-9]{64}$/.test(f.before) || typeof f.after !== 'string' || !/^[a-f0-9]{64}$/.test(f.after) || f.before === f.after ||
|
|
36
|
+
!Array.isArray(f.edits) || !f.edits.length)
|
|
37
|
+
throw new EnginePatchError('spec');
|
|
38
|
+
paths.add(f.path);
|
|
39
|
+
for (const e of f.edits)
|
|
40
|
+
if (!object(e) || !keys(e, ['find', 'replace']) || typeof e.find !== 'string' || !e.find || typeof e.replace !== 'string' || !e.replace || e.find === e.replace)
|
|
41
|
+
throw new EnginePatchError('spec');
|
|
42
|
+
}
|
|
43
|
+
if (id !== patchId(files))
|
|
44
|
+
throw new EnginePatchError('spec');
|
|
45
|
+
}
|
|
46
|
+
export function readPatchSet(path, version, integrity) {
|
|
47
|
+
try {
|
|
48
|
+
const value = JSON.parse(readFileSync(path, 'utf8'));
|
|
49
|
+
if (!object(value) || !keys(value, ['v', 'id', 'upstream', 'files']) || value.v !== 1 || !object(value.upstream) ||
|
|
50
|
+
!keys(value.upstream, ['name', 'version', 'integrity', 'commit', 'license']) || value.upstream.name !== 'openclaw' || value.upstream.version !== version ||
|
|
51
|
+
value.upstream.integrity !== integrity || value.upstream.license !== 'MIT' || typeof value.upstream.commit !== 'string' || !/^[a-f0-9]{40}$/.test(value.upstream.commit))
|
|
52
|
+
throw new Error();
|
|
53
|
+
validateFiles(value.id, value.files);
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new EnginePatchError('spec', path);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export function editText(text, file) {
|
|
61
|
+
for (const edit of file.edits) {
|
|
62
|
+
const at = text.indexOf(edit.find);
|
|
63
|
+
if (at < 0 || text.indexOf(edit.find, at + 1) >= 0)
|
|
64
|
+
throw new EnginePatchError('spec', file.path);
|
|
65
|
+
text = text.slice(0, at) + edit.replace + text.slice(at + edit.find.length);
|
|
66
|
+
}
|
|
67
|
+
return text;
|
|
68
|
+
}
|
|
69
|
+
export function atomic(path, text) {
|
|
70
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
71
|
+
let fd;
|
|
72
|
+
try {
|
|
73
|
+
fd = openSync(temp, 'wx', 0o600);
|
|
74
|
+
writeFileSync(fd, text);
|
|
75
|
+
fsyncSync(fd);
|
|
76
|
+
closeSync(fd);
|
|
77
|
+
fd = undefined;
|
|
78
|
+
renameSync(temp, path);
|
|
79
|
+
syncDir(dirname(path));
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
throw new EnginePatchError('write', path);
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
if (fd !== undefined)
|
|
86
|
+
closeSync(fd);
|
|
87
|
+
rmSync(temp, { force: true });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const syncDir = (path) => { const fd = openSync(path, 'r'); try {
|
|
91
|
+
fsyncSync(fd);
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
closeSync(fd);
|
|
95
|
+
} };
|
|
96
|
+
export function processStartTime(pid) {
|
|
97
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
98
|
+
const startTime = stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19];
|
|
99
|
+
if (!startTime || !/^\d+$/.test(startTime))
|
|
100
|
+
throw new EnginePatchError('write');
|
|
101
|
+
return startTime;
|
|
102
|
+
}
|
|
103
|
+
const noLink = (path) => { if (lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink())
|
|
104
|
+
throw new EnginePatchError('spec', path); };
|
|
105
|
+
const inside = (root, path) => { const rel = relative(root, path); return rel !== '..' && !rel.startsWith(`..${sep}`) && !rel.startsWith(sep); };
|
|
106
|
+
function tree(dir) {
|
|
107
|
+
const result = [];
|
|
108
|
+
const root = realpathSync(dir);
|
|
109
|
+
const walk = (path, rel) => {
|
|
110
|
+
const info = lstatSync(path);
|
|
111
|
+
if (info.isSymbolicLink()) {
|
|
112
|
+
const link = readlinkSync(path);
|
|
113
|
+
if (resolve(link) === link || !inside(root, realpathSync(path)))
|
|
114
|
+
throw new EnginePatchError('spec', rel);
|
|
115
|
+
result.push({ path: rel, kind: 'link', mode: info.mode & 0o777, size: info.size, hash: sha256(link) });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (info.isDirectory()) {
|
|
119
|
+
for (const name of readdirSync(path).sort()) {
|
|
120
|
+
if (!rel && ['.byokit-tree', '.byokit-patches'].includes(name))
|
|
121
|
+
continue;
|
|
122
|
+
walk(join(path, name), rel ? `${rel}/${name}` : name);
|
|
123
|
+
}
|
|
124
|
+
result.push({ path: rel, kind: 'dir', mode: info.mode & 0o777, size: 0, hash: '' });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (!info.isFile())
|
|
128
|
+
throw new EnginePatchError('spec', rel);
|
|
129
|
+
const hash = sha256(readFileSync(path));
|
|
130
|
+
result.push({ path: rel, kind: 'file', mode: info.mode & 0o777, size: info.size, hash });
|
|
131
|
+
};
|
|
132
|
+
walk(dir, '');
|
|
133
|
+
return result.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
134
|
+
}
|
|
135
|
+
function metadata(dir, set) {
|
|
136
|
+
const root = join(dir, 'node_modules/openclaw');
|
|
137
|
+
const build = JSON.parse(readFileSync(join(root, 'dist/build-info.json'), 'utf8'));
|
|
138
|
+
if (build.version !== set.upstream.version || build.commit !== set.upstream.commit || JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version !== set.upstream.version)
|
|
139
|
+
throw new EnginePatchError('drift', 'dist/build-info.json');
|
|
140
|
+
for (const file of set.files) {
|
|
141
|
+
const path = join(root, file.path);
|
|
142
|
+
if (!inside(realpathSync(root), realpathSync(path)) || lstatSync(path).isSymbolicLink() || sha256(readFileSync(path)) !== file.after)
|
|
143
|
+
throw new EnginePatchError('drift', file.path);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Full verification on EVERY prepare/adoption, not just listed patched files or the directory mode.
|
|
147
|
+
export function verifyEngineSet(dir, set, installMatches) {
|
|
148
|
+
noLink(dir);
|
|
149
|
+
try {
|
|
150
|
+
for (const name of ['.byokit-tree', '.byokit-patches']) {
|
|
151
|
+
const info = lstatSync(join(dir, name));
|
|
152
|
+
if (!info.isFile() || (info.mode & 0o777) !== 0o444)
|
|
153
|
+
throw new Error();
|
|
154
|
+
}
|
|
155
|
+
const bytes = readFileSync(join(dir, '.byokit-tree'), 'utf8');
|
|
156
|
+
const marker = JSON.parse(readFileSync(join(dir, '.byokit-patches'), 'utf8'));
|
|
157
|
+
if (!object(marker) || !keys(marker, ['id', 'files', 'tree']) || marker.id !== set.id || JSON.stringify(marker.files) !== JSON.stringify(set.files) || marker.tree !== sha256(bytes))
|
|
158
|
+
throw new Error();
|
|
159
|
+
metadata(dir, set);
|
|
160
|
+
if (!installMatches(dir) || bytes !== JSON.stringify(tree(dir)))
|
|
161
|
+
throw new Error();
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (error instanceof EnginePatchError && error.cause === 'spec')
|
|
165
|
+
throw error;
|
|
166
|
+
throw new EnginePatchError('drift', dir);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// chmod/remove only this invocation's unlaunched private temp, never a final set or the base install.
|
|
170
|
+
async function removeTemp(dir) {
|
|
171
|
+
if (!existsSync(dir))
|
|
172
|
+
return;
|
|
173
|
+
const walk = async (path) => {
|
|
174
|
+
if (!lstatSync(path).isDirectory())
|
|
175
|
+
return;
|
|
176
|
+
await chmod(path, 0o700);
|
|
177
|
+
for (const name of await readdir(path))
|
|
178
|
+
await walk(join(path, name));
|
|
179
|
+
};
|
|
180
|
+
await walk(dir);
|
|
181
|
+
await rm(dir, { recursive: true, force: true });
|
|
182
|
+
}
|
|
183
|
+
async function freeze(dir) {
|
|
184
|
+
const entries = tree(dir);
|
|
185
|
+
// Bound descriptors/I/O while yielding the host event loop. Every regular file and directory is fsynced.
|
|
186
|
+
const paths = [...entries.filter(e => e.kind === 'file'), ...entries.filter(e => e.kind === 'dir').reverse()];
|
|
187
|
+
for (let i = 0; i < paths.length; i += 16)
|
|
188
|
+
await Promise.all(paths.slice(i, i + 16).map(async (entry) => {
|
|
189
|
+
const path = join(dir, entry.path);
|
|
190
|
+
await chmod(path, entry.kind === 'dir' ? 0o555 : 0o444);
|
|
191
|
+
const fd = await open(path, 'r');
|
|
192
|
+
try {
|
|
193
|
+
await fd.sync();
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
await fd.close();
|
|
197
|
+
}
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
export function engineSetName(set) { return `${sha256(set.upstream.integrity).slice(0, 16)}-${set.id}`; }
|
|
201
|
+
export async function prepareEngineSet(engineDir, set, install, installMatches) {
|
|
202
|
+
validateFiles(set.id, set.files);
|
|
203
|
+
const sets = `${engineDir}.sets`;
|
|
204
|
+
noLink(sets);
|
|
205
|
+
mkdirSync(sets, { recursive: true, mode: 0o700 });
|
|
206
|
+
const name = engineSetName(set);
|
|
207
|
+
for (const candidate of readdirSync(sets).filter(n => n === name || n.startsWith(`${name}.`)).sort()) {
|
|
208
|
+
const dir = join(sets, candidate);
|
|
209
|
+
try {
|
|
210
|
+
verifyEngineSet(dir, set, installMatches);
|
|
211
|
+
return dir;
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
if (!(error instanceof EnginePatchError) || error.cause !== 'drift')
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const stock = { ...set, id: patchId([]), files: [] };
|
|
219
|
+
const stockDir = set.files.length ? await prepareEngineSet(engineDir, stock, install, installMatches) : undefined;
|
|
220
|
+
const tmp = join(sets, `.tmp-${process.pid}-${process.platform === 'linux' ? processStartTime(process.pid) : 'unknown'}-${randomUUID()}`);
|
|
221
|
+
const final = join(sets, existsSync(join(sets, name)) ? `${name}.${randomUUID()}` : name);
|
|
222
|
+
try {
|
|
223
|
+
if (stockDir) {
|
|
224
|
+
// ponytail: reflink or plain copy, never hardlinks; no GC without per-launch leases (~889 MB/set).
|
|
225
|
+
await cp(stockDir, tmp, { recursive: true, verbatimSymlinks: true, mode: constants.COPYFILE_FICLONE });
|
|
226
|
+
// The copy is private and unlaunched. Make only it writable, and drop inherited provenance.
|
|
227
|
+
const writable = async (path) => {
|
|
228
|
+
const info = lstatSync(path);
|
|
229
|
+
if (info.isSymbolicLink())
|
|
230
|
+
return;
|
|
231
|
+
await chmod(path, info.isDirectory() ? 0o700 : 0o600);
|
|
232
|
+
if (info.isDirectory())
|
|
233
|
+
for (const name of await readdir(path))
|
|
234
|
+
await writable(join(path, name));
|
|
235
|
+
};
|
|
236
|
+
await writable(tmp);
|
|
237
|
+
rmSync(join(tmp, '.byokit-tree'));
|
|
238
|
+
rmSync(join(tmp, '.byokit-patches'));
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
mkdirSync(tmp, { mode: 0o700 });
|
|
242
|
+
await install(tmp);
|
|
243
|
+
}
|
|
244
|
+
if (!installMatches(tmp))
|
|
245
|
+
throw new EnginePatchError('drift-after-build', tmp);
|
|
246
|
+
const root = join(tmp, 'node_modules/openclaw');
|
|
247
|
+
for (const file of set.files) {
|
|
248
|
+
const path = join(root, file.path);
|
|
249
|
+
if (!inside(realpathSync(root), realpathSync(path)) || lstatSync(path).isSymbolicLink())
|
|
250
|
+
throw new EnginePatchError('spec', file.path);
|
|
251
|
+
const before = readFileSync(path, 'utf8');
|
|
252
|
+
if (sha256(before) !== file.before)
|
|
253
|
+
throw new EnginePatchError('drift-after-build', file.path);
|
|
254
|
+
const after = editText(before, file);
|
|
255
|
+
if (sha256(after) !== file.after)
|
|
256
|
+
throw new EnginePatchError('spec', file.path);
|
|
257
|
+
atomic(path, after);
|
|
258
|
+
}
|
|
259
|
+
metadata(tmp, set);
|
|
260
|
+
await freeze(tmp);
|
|
261
|
+
const bytes = JSON.stringify(tree(tmp));
|
|
262
|
+
// Root alone is writable while publishing metadata, then made read-only too.
|
|
263
|
+
await chmod(tmp, 0o700);
|
|
264
|
+
atomic(join(tmp, '.byokit-tree'), bytes);
|
|
265
|
+
atomic(join(tmp, '.byokit-patches'), JSON.stringify({ id: set.id, files: set.files, tree: sha256(bytes) }));
|
|
266
|
+
for (const file of ['.byokit-tree', '.byokit-patches']) {
|
|
267
|
+
const path = join(tmp, file);
|
|
268
|
+
await chmod(path, 0o444);
|
|
269
|
+
const fd = await open(path, 'r');
|
|
270
|
+
try {
|
|
271
|
+
await fd.sync();
|
|
272
|
+
}
|
|
273
|
+
finally {
|
|
274
|
+
await fd.close();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
await chmod(tmp, 0o555);
|
|
278
|
+
const directory = await open(tmp, 'r');
|
|
279
|
+
try {
|
|
280
|
+
await directory.sync();
|
|
281
|
+
}
|
|
282
|
+
finally {
|
|
283
|
+
await directory.close();
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
verifyEngineSet(tmp, set, installMatches);
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
if (error instanceof EnginePatchError && error.cause === 'drift')
|
|
290
|
+
throw new EnginePatchError('drift-after-build', error.file);
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
renameSync(tmp, final);
|
|
295
|
+
syncDir(sets);
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
if (!['ENOTEMPTY', 'EEXIST', 'EPERM'].includes(error.code ?? ''))
|
|
299
|
+
throw error;
|
|
300
|
+
try {
|
|
301
|
+
verifyEngineSet(final, set, installMatches);
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
throw new EnginePatchError('drift-after-build', final);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return final;
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
if (error instanceof EnginePatchError) {
|
|
311
|
+
if (error.cause === 'drift')
|
|
312
|
+
throw new EnginePatchError('drift-after-build', error.file);
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
throw new EnginePatchError('write', tmp);
|
|
316
|
+
}
|
|
317
|
+
finally {
|
|
318
|
+
await removeTemp(tmp);
|
|
319
|
+
}
|
|
320
|
+
}
|
package/dist/engine-status.d.ts
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
import type { ChildProcess } from 'node:child_process';
|
|
2
|
+
/** Every process the kit itself spawned, held for shutdown by pid. */
|
|
3
|
+
export declare class StartedProcesses {
|
|
4
|
+
private readonly started;
|
|
5
|
+
/** Record a spawn. Its pid is the kit's to signal; nothing else about it is. */
|
|
6
|
+
add(child: ChildProcess): void;
|
|
7
|
+
forget(child: ChildProcess | undefined): void;
|
|
8
|
+
/** SIGTERM each recorded pid, wait, SIGKILL the ones still running, wait. Never signals a process group. */
|
|
9
|
+
terminate(wait: (ms: number) => Promise<void>, graceMs?: number): Promise<void>;
|
|
10
|
+
private live;
|
|
11
|
+
private signal;
|
|
12
|
+
private settle;
|
|
13
|
+
}
|
|
1
14
|
/** A live or unverifiable writer still owns the isolated engine state. Retry after it stops. */
|
|
2
15
|
export declare class EngineAlreadyRunningError extends Error {
|
|
3
16
|
readonly code = "engine-already-running";
|
package/dist/engine-status.js
CHANGED
|
@@ -1,4 +1,35 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
+
/** Every process the kit itself spawned, held for shutdown by pid. */
|
|
3
|
+
export class StartedProcesses {
|
|
4
|
+
started = new Set();
|
|
5
|
+
/** Record a spawn. Its pid is the kit's to signal; nothing else about it is. */
|
|
6
|
+
add(child) { this.started.add(child); }
|
|
7
|
+
forget(child) { if (child)
|
|
8
|
+
this.started.delete(child); }
|
|
9
|
+
/** SIGTERM each recorded pid, wait, SIGKILL the ones still running, wait. Never signals a process group. */
|
|
10
|
+
async terminate(wait, graceMs = 3000) {
|
|
11
|
+
for (const child of this.live())
|
|
12
|
+
this.signal(child, 'SIGTERM');
|
|
13
|
+
await this.settle(wait, graceMs);
|
|
14
|
+
for (const child of this.live())
|
|
15
|
+
this.signal(child, 'SIGKILL');
|
|
16
|
+
await this.settle(wait, graceMs);
|
|
17
|
+
}
|
|
18
|
+
live() { return [...this.started].filter(child => child.exitCode === null && child.signalCode === null); }
|
|
19
|
+
signal(child, sig) {
|
|
20
|
+
const { pid } = child;
|
|
21
|
+
if (pid === undefined)
|
|
22
|
+
return;
|
|
23
|
+
try {
|
|
24
|
+
process.kill(pid, sig);
|
|
25
|
+
}
|
|
26
|
+
catch { /* already gone */ }
|
|
27
|
+
}
|
|
28
|
+
async settle(wait, graceMs) {
|
|
29
|
+
for (let waited = 200; waited <= graceMs && this.live().length; waited += 200)
|
|
30
|
+
await wait(200);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
2
33
|
/** A live or unverifiable writer still owns the isolated engine state. Retry after it stops. */
|
|
3
34
|
export class EngineAlreadyRunningError extends Error {
|
|
4
35
|
code = 'engine-already-running';
|