@byokit/openclaw 0.2.2 → 0.3.0
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 +26 -0
- package/README.md +56 -0
- package/dist/auth-store.d.ts +32 -0
- package/dist/auth-store.js +320 -0
- package/dist/engine.d.ts +6 -1
- package/dist/engine.js +88 -18
- package/dist/kit.d.ts +2 -0
- package/dist/kit.js +10 -6
- package/dist/migrate.d.ts +10 -3
- package/dist/migrate.js +55 -34
- package/package.json +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.3.0 (2026-09-30)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
- SECURITY: Provider access and refresh tokens persisted in plaintext engine stores; hosts can now pass `authSeal` from `@byokit/secrets` to seal the isolated stores while stopped and protect migration archives. Upgrade and call `prepare()` with an OS-keyring or host-owned-key adapter; protect live state and backups separately.
|
|
10
|
+
- SECURITY: Verified retained-login sources are removed instead of archived in plaintext; existing confirmed copies and engine migration archives are removed or sealed on the next prepare.
|
|
11
|
+
|
|
12
|
+
## 0.2.5 (2026-09-30)
|
|
13
|
+
|
|
14
|
+
- Dependency update: pins @byokit/relay 0.4.2.
|
|
15
|
+
- Dependency update: pins @byokit/link 0.6.0.
|
|
16
|
+
|
|
17
|
+
## 0.2.4 (2026-09-30)
|
|
18
|
+
|
|
19
|
+
- Dependency update: pins @byokit/relay 0.4.1.
|
|
20
|
+
- Dependency update: pins @byokit/link 0.5.1.
|
|
21
|
+
- FIX: (from @byokit/link 0.5.1) Reject an authenticated empty transport frame instead of decoding it as an empty control message.
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
## 0.2.3 (2026-09-30)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
- FIX: `prepare()` repairs stale engine manifests and missing or wrong-version dependencies before reusing an installed engine.
|
|
29
|
+
|
|
30
|
+
|
|
5
31
|
## 0.2.2 (2026-09-30)
|
|
6
32
|
|
|
7
33
|
- Depends on @byokit/relay 0.4.0.
|
package/README.md
CHANGED
|
@@ -166,6 +166,62 @@ The kit writes only under the `stateDir` the app passes. It spawns the engine wi
|
|
|
166
166
|
(never `process.env`), reads no environment variables except `PATH` to find `npm`, and never bills an API behind the
|
|
167
167
|
person's back (memory search is never a paid provider).
|
|
168
168
|
|
|
169
|
+
## Credential sealing and threat model
|
|
170
|
+
|
|
171
|
+
The pinned engine has no supported hook for sealing OAuth profile writes. Its `auth-profiles` loader stores
|
|
172
|
+
credential JSON in agent SQLite databases and a shared state database, and doctor imports leave migration
|
|
173
|
+
archives. `authSeal` therefore protects the complete isolated `state` and `home` directories, including SQLite
|
|
174
|
+
journals. It uses the same injected `SealingAdapter` as `@byokit/secrets`; the kit creates no sealing key.
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
import { osKeyringSeal, hostKeySeal } from '@byokit/secrets';
|
|
178
|
+
import { OpenClawKit } from '@byokit/openclaw';
|
|
179
|
+
|
|
180
|
+
const kit = new OpenClawKit({
|
|
181
|
+
stateDir: './openclaw-state',
|
|
182
|
+
authSeal: osKeyringSeal({ service: 'my-app-runtime' }),
|
|
183
|
+
});
|
|
184
|
+
await kit.prepare(); // seals an existing plaintext store and migration archives without starting the engine
|
|
185
|
+
await kit.start(); // authenticates and restores the isolated files for the engine
|
|
186
|
+
await kit.stop(); // waits for exit, verifies an atomic sealed snapshot, then removes plaintext
|
|
187
|
+
|
|
188
|
+
// A headless server supplies its own 32-byte key, held outside stateDir and its backups:
|
|
189
|
+
declare const hostKey: Uint8Array;
|
|
190
|
+
const server = new OpenClawKit({
|
|
191
|
+
stateDir: './server-state',
|
|
192
|
+
authSeal: hostKeySeal({ key: hostKey, service: 'my-app-runtime' }),
|
|
193
|
+
});
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Without `authSeal`, engine credentials remain plaintext. With it, successful `prepare()` and `stop()` leave
|
|
197
|
+
only a sealed snapshot, `auth-store.sealed`, for those directories. The adapter authenticates the snapshot
|
|
198
|
+
before any restoration; a wrong key, tampering, or a missing adapter rejects. Files restored for the engine
|
|
199
|
+
have mode 0600 and directories 0700. Symlinks and special files inside the sealed directories are refused.
|
|
200
|
+
One kit owns the store at a time; a live owner or orphan gateway blocks preparation instead of racing its writes.
|
|
201
|
+
Sealing copies the whole store through memory, so startup and stop cost grows with session history.
|
|
202
|
+
|
|
203
|
+
The migration doctor temporarily opens the same store and reseals it even when import fails. Once the gateway
|
|
204
|
+
verifies every provider, `confirmRetainedLogin()` removes the explicitly passed legacy source and writes only
|
|
205
|
+
an empty completion marker. It no longer creates a plaintext `.moved-to-engine` copy. On prepare, old engine
|
|
206
|
+
`*.json.migrated-*` and `*.json.sqlite-import.*.bak` archives and retained copies under `stateDir` are sealed
|
|
207
|
+
with the adapter (a `credential archive sealed` log event). Without an adapter, engine archives and confirmed
|
|
208
|
+
retained copies are removed (`verified credential archive removed`). An unconfirmed retained copy remains a
|
|
209
|
+
migration source until verified or sealed; pass its original path to migration to recover a sealed copy.
|
|
210
|
+
Archives stay sealed and are never restored as engine input. External source paths are handled only when the
|
|
211
|
+
host explicitly passes them to migration/confirmation. An app supplying a record still removes its own copy.
|
|
212
|
+
|
|
213
|
+
Residual risk: credentials are plaintext on disk and in process memory while the gateway or migration doctor
|
|
214
|
+
runs. Abrupt host termination or power loss cannot run stop cleanup: next prepare seals leftover live files,
|
|
215
|
+
but refuses while the old gateway is alive. Interrupted restore/removal is recovered from the authenticated
|
|
216
|
+
snapshot. A sealing failure rejects stop and retains recoverable live files; the host must resolve it and retry.
|
|
217
|
+
Await `stop()` during orderly shutdown. File removal does not erase old filesystem blocks, snapshots, swap or
|
|
218
|
+
backups; use an encrypted volume and exclude live state from backups. A same-user process, administrator,
|
|
219
|
+
compromised engine/plugin, memory dump, or stolen host key can read credentials. There is no rollback protection
|
|
220
|
+
against replacement with an older authentic snapshot. Gateway/device keys, inline config secrets, logs, engine
|
|
221
|
+
install/cache files and app workspaces are outside this adapter's sealing scope; protect them separately.
|
|
222
|
+
`doctorContext()` is an advanced escape hatch: launching an external doctor bypasses this lifecycle; use the
|
|
223
|
+
kit's migration method for sealed stores.
|
|
224
|
+
|
|
169
225
|
## Status
|
|
170
226
|
|
|
171
227
|
Pinned to OpenClaw `2026.8.1` (protocol 4); `ENGINE_VERSION` and `PROTOCOL_VERSION` carry the pin. Signatures are
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { SealingAdapter } from '@byokit/secrets';
|
|
2
|
+
export declare class AuthStore {
|
|
3
|
+
private active;
|
|
4
|
+
private owned;
|
|
5
|
+
private queue;
|
|
6
|
+
private readonly file;
|
|
7
|
+
private readonly lock;
|
|
8
|
+
private readonly cleanup;
|
|
9
|
+
private readonly restoring;
|
|
10
|
+
constructor(privateOptions: {
|
|
11
|
+
root: string;
|
|
12
|
+
stateDir: string;
|
|
13
|
+
engineDir: string;
|
|
14
|
+
seal?: SealingAdapter;
|
|
15
|
+
log?: (line: string) => void;
|
|
16
|
+
});
|
|
17
|
+
private readonly o;
|
|
18
|
+
private serial;
|
|
19
|
+
private acquire;
|
|
20
|
+
private release;
|
|
21
|
+
private read;
|
|
22
|
+
private collect;
|
|
23
|
+
private persist;
|
|
24
|
+
private restore;
|
|
25
|
+
archives(): Promise<void>;
|
|
26
|
+
prepare(): Promise<void>;
|
|
27
|
+
start(): Promise<void>;
|
|
28
|
+
stop(): Promise<void>;
|
|
29
|
+
offline<T>(task: () => Promise<T>): Promise<T>;
|
|
30
|
+
}
|
|
31
|
+
/** A retired archive is never restored as a live engine source. */
|
|
32
|
+
export declare function retireArchive(path: string, seal?: SealingAdapter, log?: (line: string) => void): Promise<void>;
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// The pin persists OAuth JSON in both shared and agent SQLite. There is no public persistence hook.
|
|
2
|
+
// Seal the complete isolated stores, including journals, only after their writer has exited.
|
|
3
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, closeSync, fsyncSync, openSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
5
|
+
const encoder = new TextEncoder();
|
|
6
|
+
const archive = (name) => /^(auth-profiles|auth-state|auth|oauth)\.json\.(migrated-.+|sqlite-import\..+\.bak)$/.test(name)
|
|
7
|
+
|| name.endsWith('.moved-to-engine');
|
|
8
|
+
const live = (pid) => {
|
|
9
|
+
if (!Number.isSafeInteger(pid) || pid < 1)
|
|
10
|
+
return false;
|
|
11
|
+
try {
|
|
12
|
+
process.kill(pid, 0);
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
return error.code !== 'ESRCH';
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
function regular(path) {
|
|
20
|
+
if (!lstatSync(path).isFile())
|
|
21
|
+
throw new Error('credential store requires regular files');
|
|
22
|
+
}
|
|
23
|
+
function syncDir(path) {
|
|
24
|
+
if (process.platform === 'win32')
|
|
25
|
+
return;
|
|
26
|
+
const fd = openSync(path, 'r');
|
|
27
|
+
try {
|
|
28
|
+
fsyncSync(fd);
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
closeSync(fd);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function removeMarker(path) {
|
|
35
|
+
rmSync(path, { force: true });
|
|
36
|
+
syncDir(dirname(path));
|
|
37
|
+
}
|
|
38
|
+
function put(path, bytes) {
|
|
39
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
40
|
+
const tmp = `${path}.sealing-${process.pid}`;
|
|
41
|
+
try {
|
|
42
|
+
writeFileSync(tmp, bytes, { mode: 0o600, flag: 'wx' });
|
|
43
|
+
const fd = openSync(tmp, 'r');
|
|
44
|
+
try {
|
|
45
|
+
fsyncSync(fd);
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
closeSync(fd);
|
|
49
|
+
}
|
|
50
|
+
renameSync(tmp, path);
|
|
51
|
+
// POSIX directory fsync makes the rename durable before any plaintext is removed.
|
|
52
|
+
syncDir(dirname(path));
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
rmSync(tmp, { force: true });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function safePath(path) {
|
|
59
|
+
return typeof path === 'string' && /^(state|home)(\/[^/]+)*$/.test(path)
|
|
60
|
+
&& !path.split('/').some((part) => part === '.' || part === '..' || part.includes('\\') || part.includes('\0'));
|
|
61
|
+
}
|
|
62
|
+
function snapshot(text) {
|
|
63
|
+
const s = JSON.parse(text);
|
|
64
|
+
if (s?.v !== 1 || !Array.isArray(s.dirs) || !Array.isArray(s.files))
|
|
65
|
+
throw new Error('invalid sealed credential store');
|
|
66
|
+
const paths = new Set();
|
|
67
|
+
for (const path of s.dirs) {
|
|
68
|
+
if (!safePath(path) || paths.has(path))
|
|
69
|
+
throw new Error('invalid sealed credential path');
|
|
70
|
+
paths.add(path);
|
|
71
|
+
}
|
|
72
|
+
for (const entry of s.files) {
|
|
73
|
+
if (!Array.isArray(entry) || entry.length !== 2 || !safePath(entry[0]) || typeof entry[1] !== 'string'
|
|
74
|
+
|| paths.has(entry[0]) || Buffer.from(entry[1], 'base64').toString('base64') !== entry[1])
|
|
75
|
+
throw new Error('invalid sealed credential file');
|
|
76
|
+
paths.add(entry[0]);
|
|
77
|
+
if (!s.dirs.includes(dirname(entry[0])))
|
|
78
|
+
throw new Error('invalid sealed credential parent');
|
|
79
|
+
}
|
|
80
|
+
for (const path of s.dirs)
|
|
81
|
+
if (path.includes('/') && !s.dirs.includes(dirname(path)))
|
|
82
|
+
throw new Error('invalid sealed credential parent');
|
|
83
|
+
return s;
|
|
84
|
+
}
|
|
85
|
+
export class AuthStore {
|
|
86
|
+
active = false;
|
|
87
|
+
owned = false;
|
|
88
|
+
queue = Promise.resolve();
|
|
89
|
+
file;
|
|
90
|
+
lock;
|
|
91
|
+
cleanup;
|
|
92
|
+
restoring;
|
|
93
|
+
constructor(privateOptions) {
|
|
94
|
+
this.o = privateOptions;
|
|
95
|
+
this.file = join(this.o.root, 'auth-store.sealed');
|
|
96
|
+
this.lock = join(this.o.root, 'auth-store.lock');
|
|
97
|
+
this.cleanup = join(this.o.root, 'auth-store.cleanup');
|
|
98
|
+
this.restoring = join(this.o.root, 'auth-store.restoring');
|
|
99
|
+
}
|
|
100
|
+
o;
|
|
101
|
+
serial(task) {
|
|
102
|
+
const pending = this.queue.then(task);
|
|
103
|
+
this.queue = pending.catch(() => { });
|
|
104
|
+
return pending;
|
|
105
|
+
}
|
|
106
|
+
acquire() {
|
|
107
|
+
if (this.owned)
|
|
108
|
+
return;
|
|
109
|
+
if (existsSync(this.file) && !this.o.seal)
|
|
110
|
+
throw new Error('authSeal required for sealed credential store');
|
|
111
|
+
if (!this.o.seal)
|
|
112
|
+
return;
|
|
113
|
+
// Never race a live gateway or another kit, including an orphan after its host crashed.
|
|
114
|
+
const gateway = join(this.o.root, 'gateway.pid');
|
|
115
|
+
if (existsSync(gateway) && live(Number(readFileSync(gateway, 'utf8'))))
|
|
116
|
+
throw new Error('credential store is in use');
|
|
117
|
+
if (existsSync(this.lock)) {
|
|
118
|
+
if (!lstatSync(this.lock).isDirectory() || lstatSync(this.lock).isSymbolicLink())
|
|
119
|
+
throw new Error('invalid credential lock');
|
|
120
|
+
const owner = join(this.lock, 'pid');
|
|
121
|
+
if (!existsSync(owner) || live(Number(readFileSync(owner, 'utf8'))))
|
|
122
|
+
throw new Error('credential store is in use');
|
|
123
|
+
// Only one stale-owner recovery can proceed. Recheck after claiming it so a stale reader
|
|
124
|
+
// cannot remove the lock a newer owner has just acquired.
|
|
125
|
+
const recovery = join(this.lock, 'recovery');
|
|
126
|
+
try {
|
|
127
|
+
mkdirSync(recovery, { mode: 0o700 });
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
throw new Error('credential store is in use');
|
|
131
|
+
}
|
|
132
|
+
if (!existsSync(owner) || live(Number(readFileSync(owner, 'utf8')))) {
|
|
133
|
+
rmSync(recovery, { recursive: true });
|
|
134
|
+
throw new Error('credential store is in use');
|
|
135
|
+
}
|
|
136
|
+
rmSync(this.lock, { recursive: true });
|
|
137
|
+
}
|
|
138
|
+
mkdirSync(this.lock, { mode: 0o700 });
|
|
139
|
+
writeFileSync(join(this.lock, 'pid'), String(process.pid), { mode: 0o600, flag: 'wx' });
|
|
140
|
+
this.owned = true;
|
|
141
|
+
}
|
|
142
|
+
release() {
|
|
143
|
+
if (!this.owned)
|
|
144
|
+
return;
|
|
145
|
+
rmSync(this.lock, { recursive: true });
|
|
146
|
+
this.owned = false;
|
|
147
|
+
}
|
|
148
|
+
async read() {
|
|
149
|
+
if (!existsSync(this.file))
|
|
150
|
+
return undefined;
|
|
151
|
+
regular(this.file);
|
|
152
|
+
return snapshot(this.o.seal.decryptString(readFileSync(this.file)));
|
|
153
|
+
}
|
|
154
|
+
collect() {
|
|
155
|
+
const s = { v: 1, dirs: [], files: [] };
|
|
156
|
+
const walk = (path) => {
|
|
157
|
+
const name = relative(this.o.root, path).split('\\').join('/');
|
|
158
|
+
const stat = lstatSync(path);
|
|
159
|
+
if (stat.isDirectory()) {
|
|
160
|
+
chmodSync(path, 0o700);
|
|
161
|
+
s.dirs.push(name);
|
|
162
|
+
for (const child of readdirSync(path).sort())
|
|
163
|
+
walk(join(path, child));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
regular(path);
|
|
167
|
+
chmodSync(path, 0o600);
|
|
168
|
+
s.files.push([name, readFileSync(path).toString('base64')]);
|
|
169
|
+
};
|
|
170
|
+
for (const dir of ['state', 'home'])
|
|
171
|
+
if (existsSync(join(this.o.root, dir)))
|
|
172
|
+
walk(join(this.o.root, dir));
|
|
173
|
+
return s;
|
|
174
|
+
}
|
|
175
|
+
async persist() {
|
|
176
|
+
if (!this.o.seal)
|
|
177
|
+
return;
|
|
178
|
+
// Always authenticate an earlier snapshot before considering a leftover live store after a crash.
|
|
179
|
+
const previous = await this.read();
|
|
180
|
+
if (previous && (existsSync(this.cleanup) || existsSync(this.restoring))) {
|
|
181
|
+
for (const dir of ['state', 'home'])
|
|
182
|
+
rmSync(join(this.o.root, dir), { recursive: true, force: true });
|
|
183
|
+
removeMarker(this.cleanup);
|
|
184
|
+
removeMarker(this.restoring);
|
|
185
|
+
this.o.log?.('interrupted credential transition recovered');
|
|
186
|
+
}
|
|
187
|
+
const hasLive = ['state', 'home'].some((dir) => existsSync(join(this.o.root, dir)));
|
|
188
|
+
if (!hasLive && previous)
|
|
189
|
+
return;
|
|
190
|
+
const text = JSON.stringify(this.collect());
|
|
191
|
+
const sealed = this.o.seal.encryptString(text);
|
|
192
|
+
if (this.o.seal.decryptString(Buffer.from(sealed)) !== text)
|
|
193
|
+
throw new Error('credential seal verification failed');
|
|
194
|
+
put(this.file, sealed);
|
|
195
|
+
if (this.o.seal.decryptString(readFileSync(this.file)) !== text)
|
|
196
|
+
throw new Error('credential seal verification failed');
|
|
197
|
+
put(this.cleanup, encoder.encode('1'));
|
|
198
|
+
for (const dir of ['state', 'home'])
|
|
199
|
+
rmSync(join(this.o.root, dir), { recursive: true, force: true });
|
|
200
|
+
removeMarker(this.cleanup);
|
|
201
|
+
}
|
|
202
|
+
async restore() {
|
|
203
|
+
if (!this.o.seal)
|
|
204
|
+
return;
|
|
205
|
+
const saved = await this.read();
|
|
206
|
+
// A crashed host may leave a newer live state; prepare seals that before restore.
|
|
207
|
+
put(this.restoring, encoder.encode('1'));
|
|
208
|
+
if (saved) {
|
|
209
|
+
for (const dir of saved.dirs.sort((a, b) => a.split('/').length - b.split('/').length))
|
|
210
|
+
mkdirSync(join(this.o.root, dir), { recursive: true, mode: 0o700 });
|
|
211
|
+
for (const [path, data] of saved.files)
|
|
212
|
+
put(join(this.o.root, path), Buffer.from(data, 'base64'));
|
|
213
|
+
}
|
|
214
|
+
for (const dir of ['state', 'home'])
|
|
215
|
+
mkdirSync(join(this.o.root, dir), { recursive: true, mode: 0o700 });
|
|
216
|
+
removeMarker(this.restoring);
|
|
217
|
+
}
|
|
218
|
+
async archives() {
|
|
219
|
+
const ignored = new Set([resolve(this.o.engineDir), ...['npm-cache', 'install-home', 'tmp', 'plugin', 'workspaces'].map((d) => resolve(this.o.root, d))]);
|
|
220
|
+
const walk = async (dir) => {
|
|
221
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
222
|
+
const path = join(dir, entry.name);
|
|
223
|
+
if (ignored.has(resolve(path)) || entry.isSymbolicLink())
|
|
224
|
+
continue;
|
|
225
|
+
if (entry.isDirectory()) {
|
|
226
|
+
await walk(path);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (!entry.isFile() || !archive(entry.name))
|
|
230
|
+
continue;
|
|
231
|
+
if (!this.o.seal && entry.name.endsWith('.moved-to-engine') && !existsSync(path + '.canonicalized'))
|
|
232
|
+
continue;
|
|
233
|
+
await retireArchive(path, this.o.seal, this.o.log);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
if (existsSync(this.o.stateDir))
|
|
237
|
+
await walk(this.o.stateDir);
|
|
238
|
+
}
|
|
239
|
+
prepare() {
|
|
240
|
+
return this.serial(async () => {
|
|
241
|
+
if (this.active)
|
|
242
|
+
return;
|
|
243
|
+
this.acquire();
|
|
244
|
+
try {
|
|
245
|
+
await this.read();
|
|
246
|
+
await this.archives();
|
|
247
|
+
await this.persist();
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
this.release();
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
start() {
|
|
255
|
+
return this.serial(async () => {
|
|
256
|
+
if (this.active)
|
|
257
|
+
return;
|
|
258
|
+
this.acquire();
|
|
259
|
+
try {
|
|
260
|
+
await this.restore();
|
|
261
|
+
this.active = true;
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
this.release();
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
stop() {
|
|
270
|
+
return this.serial(async () => {
|
|
271
|
+
this.acquire();
|
|
272
|
+
await this.archives();
|
|
273
|
+
await this.persist();
|
|
274
|
+
this.active = false;
|
|
275
|
+
this.release();
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
offline(task) {
|
|
279
|
+
return this.serial(async () => {
|
|
280
|
+
if (this.active)
|
|
281
|
+
throw new Error('credential migration requires a stopped engine');
|
|
282
|
+
this.acquire();
|
|
283
|
+
try {
|
|
284
|
+
await this.restore();
|
|
285
|
+
try {
|
|
286
|
+
return await task();
|
|
287
|
+
}
|
|
288
|
+
finally {
|
|
289
|
+
await this.archives();
|
|
290
|
+
await this.persist();
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
finally {
|
|
294
|
+
this.release();
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/** A retired archive is never restored as a live engine source. */
|
|
300
|
+
export async function retireArchive(path, seal, log) {
|
|
301
|
+
regular(path);
|
|
302
|
+
if (seal) {
|
|
303
|
+
const bytes = readFileSync(path);
|
|
304
|
+
try {
|
|
305
|
+
const text = bytes.toString('base64');
|
|
306
|
+
const sealed = seal.encryptString(text);
|
|
307
|
+
if (seal.decryptString(Buffer.from(sealed)) !== text)
|
|
308
|
+
throw new Error('archive seal verification failed');
|
|
309
|
+
put(path + '.sealed', sealed);
|
|
310
|
+
if (seal.decryptString(readFileSync(path + '.sealed')) !== text)
|
|
311
|
+
throw new Error('archive seal verification failed');
|
|
312
|
+
}
|
|
313
|
+
finally {
|
|
314
|
+
bytes.fill(0);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
rmSync(path);
|
|
318
|
+
syncDir(dirname(path));
|
|
319
|
+
log?.(seal ? 'credential archive sealed' : 'verified credential archive removed');
|
|
320
|
+
}
|
package/dist/engine.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { KitOptions } from './kit.ts';
|
|
2
2
|
import type { KitState, ToolSpec } from './types.ts';
|
|
3
|
-
export type EngineOptions = Pick<KitOptions, 'stateDir' | 'engineDir' | 'npmPath' | 'enginePath' | 'config' | 'installPolicy' | 'log' | 'bridge'> & {
|
|
3
|
+
export type EngineOptions = Pick<KitOptions, 'stateDir' | 'engineDir' | 'npmPath' | 'enginePath' | 'config' | 'installPolicy' | 'log' | 'bridge' | 'authSeal'> & {
|
|
4
4
|
pluginId: string;
|
|
5
5
|
tools: ToolSpec[];
|
|
6
6
|
gateBuiltins?: boolean;
|
|
@@ -13,6 +13,8 @@ export declare class Engine {
|
|
|
13
13
|
readonly bridgeSock: string;
|
|
14
14
|
private readonly dir;
|
|
15
15
|
private child?;
|
|
16
|
+
private starting?;
|
|
17
|
+
private readonly authStore;
|
|
16
18
|
private stopping;
|
|
17
19
|
private repaired;
|
|
18
20
|
private prepared?;
|
|
@@ -39,5 +41,8 @@ export declare class Engine {
|
|
|
39
41
|
token: string;
|
|
40
42
|
identityPath: string;
|
|
41
43
|
}>;
|
|
44
|
+
private startOnce;
|
|
45
|
+
private launch;
|
|
46
|
+
withAuthStore<T>(task: () => Promise<T>): Promise<T>;
|
|
42
47
|
stop(): Promise<void>;
|
|
43
48
|
}
|
package/dist/engine.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createServer } from 'node:net';
|
|
|
5
5
|
import { join, dirname } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
8
|
+
import { AuthStore } from "./auth-store.js";
|
|
8
9
|
import { ENGINE_VERSION } from "./constants.js";
|
|
9
10
|
import { reconcileConfig } from "./config.js";
|
|
10
11
|
import { writePlugin, resolveBridge } from "./bridge.js";
|
|
@@ -15,11 +16,41 @@ const putChanged = (path, data) => {
|
|
|
15
16
|
if (!existsSync(path) || readFileSync(path, 'utf8') !== data)
|
|
16
17
|
writeFileSync(path, data, { mode: 0o600 });
|
|
17
18
|
};
|
|
19
|
+
const supports = (list, value) => !list ||
|
|
20
|
+
(!list.includes(`!${value}`) && (list.includes('any') || list.every(item => item.startsWith('!')) || list.includes(value)));
|
|
21
|
+
function installMatches(dir) {
|
|
22
|
+
const manifests = ['package.json', 'package-lock.json'].map(file => ({
|
|
23
|
+
path: join(dir, file), shipped: readFileSync(join(kitDir, 'engine', file)),
|
|
24
|
+
}));
|
|
25
|
+
const lock = JSON.parse(manifests[1].shipped.toString('utf8'));
|
|
26
|
+
// npm omits optional binaries for other platforms (including the other Linux libc).
|
|
27
|
+
const report = process.platform === 'linux' ? process.report.getReport() : undefined;
|
|
28
|
+
const libc = report?.header.glibcVersionRuntime ? 'glibc' : process.platform === 'linux' ? 'musl' : '';
|
|
29
|
+
try {
|
|
30
|
+
if (manifests.some(({ path, shipped }) => !readFileSync(path).equals(shipped)))
|
|
31
|
+
return false;
|
|
32
|
+
for (const [path, pkg] of Object.entries(lock.packages)) {
|
|
33
|
+
if (!path)
|
|
34
|
+
continue; // The root manifest is checked byte for byte above.
|
|
35
|
+
if (pkg.optional && (!supports(pkg.os, process.platform) || !supports(pkg.cpu, process.arch) || !supports(pkg.libc, libc)))
|
|
36
|
+
continue;
|
|
37
|
+
if (JSON.parse(readFileSync(join(dir, path, 'package.json'), 'utf8')).version !== pkg.version)
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Missing or unreadable manifests and malformed installed package metadata need repair too.
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
18
47
|
export class Engine {
|
|
19
48
|
root;
|
|
20
49
|
bridgeSock;
|
|
21
50
|
dir;
|
|
22
51
|
child;
|
|
52
|
+
starting;
|
|
53
|
+
authStore;
|
|
23
54
|
stopping = false;
|
|
24
55
|
repaired = false;
|
|
25
56
|
prepared;
|
|
@@ -34,6 +65,7 @@ export class Engine {
|
|
|
34
65
|
this.dir = o.engineDir ?? join(this.root, 'engine');
|
|
35
66
|
this.bridgeSock = join(this.root, bridge.socketName);
|
|
36
67
|
this.paramPrefix = bridge.paramPrefix;
|
|
68
|
+
this.authStore = new AuthStore({ root: this.root, stateDir: o.stateDir, engineDir: this.dir, seal: o.authSeal, log: o.log });
|
|
37
69
|
}
|
|
38
70
|
state(phase, why, retryAt) { this.o.onState({ phase, ...(why ? { why } : {}), ...(retryAt ? { retryAt } : {}) }); }
|
|
39
71
|
get entry() { return join(this.dir, 'node_modules', 'openclaw', 'openclaw.mjs'); }
|
|
@@ -58,12 +90,14 @@ export class Engine {
|
|
|
58
90
|
return pending;
|
|
59
91
|
}
|
|
60
92
|
async prepareOnce() {
|
|
61
|
-
|
|
93
|
+
if (!this.o.authSeal && existsSync(join(this.root, 'auth-store.sealed')))
|
|
94
|
+
throw new Error('authSeal required for sealed credential store');
|
|
95
|
+
for (const d of [this.root, ...(!this.o.authSeal ? [join(this.root, 'home'), join(this.root, 'state')] : []), join(this.root, 'tmp'), join(this.root, 'install-home'), join(this.root, 'npm-cache'), join(this.o.stateDir, 'logs'), this.dir])
|
|
62
96
|
mkdirSync(d, { recursive: true, mode: 0o700 });
|
|
97
|
+
await this.authStore.prepare();
|
|
63
98
|
if (this.o.spawnEngine) {
|
|
64
99
|
const versionPath = join(this.dir, 'node_modules', 'openclaw', 'package.json');
|
|
65
|
-
|
|
66
|
-
if (!existsSync(this.entry) || version !== ENGINE_VERSION) {
|
|
100
|
+
if (!existsSync(this.entry) || !installMatches(this.dir)) {
|
|
67
101
|
this.state('installing');
|
|
68
102
|
rmSync(join(this.dir, 'node_modules'), { recursive: true, force: true });
|
|
69
103
|
for (const f of ['package.json', 'package-lock.json'])
|
|
@@ -139,8 +173,28 @@ export class Engine {
|
|
|
139
173
|
}
|
|
140
174
|
catch { /* pid is gone or not ours */ }
|
|
141
175
|
}
|
|
142
|
-
|
|
176
|
+
start() {
|
|
177
|
+
if (this.starting)
|
|
178
|
+
return this.starting;
|
|
179
|
+
this.stopping = false;
|
|
180
|
+
const pending = this.startOnce();
|
|
181
|
+
this.starting = pending;
|
|
182
|
+
void pending.finally(() => { if (this.starting === pending)
|
|
183
|
+
this.starting = undefined; }).catch(() => { });
|
|
184
|
+
return pending;
|
|
185
|
+
}
|
|
186
|
+
async startOnce() {
|
|
143
187
|
await this.prepare();
|
|
188
|
+
await this.authStore.start();
|
|
189
|
+
try {
|
|
190
|
+
return this.launch();
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
await this.authStore.stop();
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
launch() {
|
|
144
198
|
const identityPath = join(this.root, 'device.json');
|
|
145
199
|
if (!existsSync(identityPath)) {
|
|
146
200
|
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
|
|
@@ -150,7 +204,6 @@ export class Engine {
|
|
|
150
204
|
return { port: this.port, token: this.token, identityPath };
|
|
151
205
|
if (this.child && this.child.exitCode === null)
|
|
152
206
|
return { port: this.port, token: this.token, identityPath };
|
|
153
|
-
this.stopping = false;
|
|
154
207
|
this.stalePid();
|
|
155
208
|
this.state('starting');
|
|
156
209
|
const { entry, env } = this.doctorContext();
|
|
@@ -162,6 +215,12 @@ export class Engine {
|
|
|
162
215
|
closeSync(fd);
|
|
163
216
|
}
|
|
164
217
|
const child = this.child;
|
|
218
|
+
child.on('error', () => {
|
|
219
|
+
if (this.child !== child || this.stopping)
|
|
220
|
+
return;
|
|
221
|
+
this.child = undefined;
|
|
222
|
+
void this.authStore.stop().then(() => { this.state('failed', 'exited'); this.o.onExit(null); }, () => this.state('failed', 'exited'));
|
|
223
|
+
});
|
|
165
224
|
if (!child.pid) {
|
|
166
225
|
this.state('failed', 'exited');
|
|
167
226
|
throw new Error('engine spawn failed');
|
|
@@ -171,41 +230,52 @@ export class Engine {
|
|
|
171
230
|
if (this.child !== child || this.stopping)
|
|
172
231
|
return;
|
|
173
232
|
this.child = undefined;
|
|
174
|
-
this.
|
|
175
|
-
|
|
176
|
-
this.
|
|
177
|
-
this.
|
|
178
|
-
const result = this.doctor(60_000);
|
|
179
|
-
if (result.status === 0) {
|
|
180
|
-
void this.start().catch(() => this.state('failed', 'exited'));
|
|
233
|
+
rmSync(join(this.root, 'gateway.pid'), { force: true });
|
|
234
|
+
void (async () => {
|
|
235
|
+
await this.authStore.stop();
|
|
236
|
+
if (this.stopping)
|
|
181
237
|
return;
|
|
238
|
+
if (code === 78 && !this.repaired) {
|
|
239
|
+
this.repaired = true;
|
|
240
|
+
this.state('repairing');
|
|
241
|
+
const result = await this.withAuthStore(async () => this.doctor(60_000));
|
|
242
|
+
if (result.status === 0 && !this.stopping) {
|
|
243
|
+
await this.start();
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
182
246
|
}
|
|
183
|
-
|
|
184
|
-
|
|
247
|
+
this.state('failed', 'exited');
|
|
248
|
+
this.o.onExit(code);
|
|
249
|
+
})().catch(() => this.state('failed', 'exited'));
|
|
185
250
|
});
|
|
186
251
|
return { port: this.port, token: this.token, identityPath };
|
|
187
252
|
}
|
|
253
|
+
withAuthStore(task) { return this.authStore.offline(task); }
|
|
188
254
|
async stop() {
|
|
189
255
|
this.stopping = true;
|
|
256
|
+
await this.starting?.catch(() => { });
|
|
190
257
|
const child = this.child;
|
|
191
|
-
this.child = undefined;
|
|
192
258
|
if (child?.pid && child.exitCode === null) {
|
|
193
259
|
try {
|
|
194
260
|
process.kill(-child.pid, 'SIGTERM');
|
|
195
261
|
}
|
|
196
262
|
catch { /* already gone */ }
|
|
197
|
-
for (let i = 0; i < 15 && child.exitCode === null; i++)
|
|
263
|
+
for (let i = 0; i < 15 && child.exitCode === null && child.signalCode === null; i++)
|
|
198
264
|
await delay(200);
|
|
199
|
-
if (child.exitCode === null) {
|
|
265
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
200
266
|
try {
|
|
201
267
|
process.kill(-child.pid, 'SIGKILL');
|
|
202
268
|
}
|
|
203
269
|
catch { /* already gone */ }
|
|
204
270
|
}
|
|
205
|
-
for (let i = 0; i < 15 && child.exitCode === null; i++)
|
|
271
|
+
for (let i = 0; i < 15 && child.exitCode === null && child.signalCode === null; i++)
|
|
206
272
|
await delay(200);
|
|
207
273
|
}
|
|
274
|
+
if (child && child.exitCode === null && child.signalCode === null)
|
|
275
|
+
throw new Error('engine did not stop; credential store still in use');
|
|
276
|
+
this.child = undefined;
|
|
208
277
|
rmSync(join(this.root, 'gateway.pid'), { force: true });
|
|
278
|
+
await this.authStore.stop();
|
|
209
279
|
rmSync(this.bridgeSock, { force: true });
|
|
210
280
|
this.state('stopped');
|
|
211
281
|
}
|
package/dist/kit.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import type { SealingAdapter } from '@byokit/secrets';
|
|
1
2
|
import type { Approval, CallOptions, Decision, GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult, GatewayTransport, Hello, KitState, Member, Route, RunEnd, RunEvent, RunSpec, SignInView, ToolHost, ToolSpec } from './types.ts';
|
|
2
3
|
export type KitOptions = {
|
|
3
4
|
stateDir: string;
|
|
5
|
+
authSeal?: SealingAdapter;
|
|
4
6
|
engineDir?: string;
|
|
5
7
|
npmPath?: string;
|
|
6
8
|
enginePath?: string[];
|
package/dist/kit.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
// The OpenClawKit facade: state, complete pass-through (D6), members, sign-in, runs, approvals, config (5.3).
|
|
2
|
-
// Built in O4 (and O5/O6/O8 for the delegated parts); until then every body refuses to run.
|
|
3
1
|
import { readFileSync } from 'node:fs';
|
|
4
2
|
import { join } from 'node:path';
|
|
5
3
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
@@ -501,9 +499,13 @@ export class OpenClawKit {
|
|
|
501
499
|
await this.approvalsCtl.resync();
|
|
502
500
|
}
|
|
503
501
|
catch (error) {
|
|
502
|
+
const needsUpdate = this.current.phase === 'needs-update';
|
|
504
503
|
if (transport && this.transport === transport)
|
|
505
504
|
await this.disconnect();
|
|
506
|
-
|
|
505
|
+
await this.engine.stop();
|
|
506
|
+
if (needsUpdate)
|
|
507
|
+
this.setState({ phase: 'needs-update', why: 'version' });
|
|
508
|
+
if (!needsUpdate && !this.stopping)
|
|
507
509
|
this.setState({ phase: 'failed', why: 'handshake' });
|
|
508
510
|
throw error;
|
|
509
511
|
}
|
|
@@ -518,7 +520,8 @@ export class OpenClawKit {
|
|
|
518
520
|
await transport?.stop();
|
|
519
521
|
}
|
|
520
522
|
closed(_why) {
|
|
521
|
-
|
|
523
|
+
const engineFailed = _why === 'engine exited' && this.current.phase === 'failed' && this.current.why === 'exited';
|
|
524
|
+
if (this.stopping || (this.current.phase !== 'ready' && !engineFailed))
|
|
522
525
|
return;
|
|
523
526
|
void this.disconnect();
|
|
524
527
|
if (this.o.spawnEngine === false) {
|
|
@@ -593,10 +596,11 @@ export class OpenClawKit {
|
|
|
593
596
|
return engineSignOut(this.signInCtx(), member, provider);
|
|
594
597
|
}
|
|
595
598
|
migrateRetainedLogin(member, source) {
|
|
596
|
-
return migrateLogin({ root: this.engine.root, prepare: () => this.prepare(), doctor: () => this.engine.doctor(120_000)
|
|
599
|
+
return migrateLogin({ root: this.engine.root, prepare: () => this.prepare(), doctor: () => this.engine.doctor(120_000),
|
|
600
|
+
seal: this.o.authSeal, log: this.o.log, withStore: (task) => this.engine.withAuthStore(task) }, member, source);
|
|
597
601
|
}
|
|
598
602
|
confirmRetainedLogin(member, source) {
|
|
599
|
-
return confirmLogin(this.signInCtx(), member, source);
|
|
603
|
+
return confirmLogin({ ...this.signInCtx(), seal: this.o.authSeal, log: this.o.log }, member, source);
|
|
600
604
|
}
|
|
601
605
|
// runs (5.8): the facade owns no run state; every call delegates to the O8 module
|
|
602
606
|
// with this kit's live transport, member table and bridge (firstmate clearance, O9).
|
package/dist/migrate.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SealingAdapter } from '@byokit/secrets';
|
|
1
2
|
import type { RetainedLogin } from './kit.ts';
|
|
2
3
|
import type { SignInCtx } from './signin.ts';
|
|
3
4
|
import type { Member } from './types.ts';
|
|
@@ -13,10 +14,16 @@ export declare function migrateRetainedLogin(ctx: {
|
|
|
13
14
|
root: string;
|
|
14
15
|
prepare(): Promise<void>;
|
|
15
16
|
doctor: DoctorRunner;
|
|
17
|
+
seal?: SealingAdapter;
|
|
18
|
+
log?: (line: string) => void;
|
|
19
|
+
withStore?<T>(task: () => Promise<T>): Promise<T>;
|
|
16
20
|
}, member: Member, source: RetainedLogin): Promise<'staged' | 'nothing' | 'failed'>;
|
|
17
21
|
/**
|
|
18
|
-
* After `ready`: the migration counts only when the Gateway itself reports every provider signed in. Only then
|
|
19
|
-
* a path source
|
|
22
|
+
* After `ready`: the migration counts only when the Gateway itself reports every provider signed in. Only then is
|
|
23
|
+
* a path source removed without creating a plaintext archive — anything else leaves the sign-in where
|
|
20
24
|
* it was and the next boot retries it without asking the person to sign in again.
|
|
21
25
|
*/
|
|
22
|
-
export declare function confirmRetainedLogin(ctx: SignInCtx
|
|
26
|
+
export declare function confirmRetainedLogin(ctx: SignInCtx & {
|
|
27
|
+
seal?: SealingAdapter;
|
|
28
|
+
log?: (line: string) => void;
|
|
29
|
+
}, member: Member, source: RetainedLogin): Promise<boolean>;
|
package/dist/migrate.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// only when the Gateway itself reports every provider signed in.
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { retireArchive } from "./auth-store.js";
|
|
5
6
|
import { MEMBER_ID } from "./members.js";
|
|
6
7
|
import { providers } from "./signin.js";
|
|
7
8
|
const MOVED = '.moved-to-engine';
|
|
@@ -15,17 +16,30 @@ function sourceFile(source) {
|
|
|
15
16
|
if (existsSync(source.path))
|
|
16
17
|
return source.path;
|
|
17
18
|
const moved = source.path + MOVED;
|
|
18
|
-
|
|
19
|
+
if (existsSync(moved + MARKER))
|
|
20
|
+
return undefined;
|
|
21
|
+
if (existsSync(moved))
|
|
22
|
+
return moved;
|
|
23
|
+
return existsSync(moved + '.sealed') ? moved + '.sealed' : undefined;
|
|
19
24
|
}
|
|
20
25
|
/** The old sign-in's plain `{ '<provider>': credential }` map, or undefined when it is absent or unreadable. */
|
|
21
|
-
function credentials(source, path) {
|
|
26
|
+
async function credentials(source, path, seal) {
|
|
27
|
+
let opened;
|
|
28
|
+
if (path?.endsWith('.sealed')) {
|
|
29
|
+
if (!seal)
|
|
30
|
+
throw new Error('authSeal required for sealed retained login');
|
|
31
|
+
opened = Buffer.from(seal.decryptString(readFileSync(path)), 'base64');
|
|
32
|
+
}
|
|
22
33
|
try {
|
|
23
|
-
const raw = 'record' in source ? source.record : JSON.parse(readFileSync(path, 'utf8'));
|
|
34
|
+
const raw = 'record' in source ? source.record : JSON.parse(opened ? new TextDecoder('utf-8', { fatal: true }).decode(opened) : readFileSync(path, 'utf8'));
|
|
24
35
|
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : undefined;
|
|
25
36
|
}
|
|
26
37
|
catch {
|
|
27
38
|
return undefined;
|
|
28
39
|
}
|
|
40
|
+
finally {
|
|
41
|
+
opened?.fill(0);
|
|
42
|
+
}
|
|
29
43
|
}
|
|
30
44
|
/** The engine's own provider ids for what the old sign-in holds; `openai-codex` is `openai` (D15). */
|
|
31
45
|
function wantedProviders(legacy) {
|
|
@@ -50,40 +64,45 @@ export async function migrateRetainedLogin(ctx, member, source) {
|
|
|
50
64
|
const path = sourceFile(source);
|
|
51
65
|
if ('path' in source && !path)
|
|
52
66
|
return 'nothing';
|
|
53
|
-
const legacy = credentials(source, path);
|
|
67
|
+
const legacy = await credentials(source, path, ctx.seal);
|
|
54
68
|
if (!legacy || !Object.keys(legacy).length)
|
|
55
69
|
return 'nothing'; // never migrate an empty source
|
|
56
70
|
// A retired copy without the one provider the engine's canonicalization exists for is not worth importing.
|
|
57
|
-
if (path?.endsWith(MOVED) && !('openai-codex' in legacy))
|
|
71
|
+
if ((path?.endsWith(MOVED) || path?.endsWith(MOVED + '.sealed')) && !('openai-codex' in legacy))
|
|
58
72
|
return 'nothing';
|
|
73
|
+
if (path?.endsWith(MOVED) && ctx.seal)
|
|
74
|
+
await retireArchive(path, ctx.seal, ctx.log);
|
|
59
75
|
await ctx.prepare();
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
76
|
+
const stage = async () => {
|
|
77
|
+
const agentDir = join(ctx.root, 'state', 'agents', member, 'agent');
|
|
78
|
+
const staged = join(agentDir, 'auth-profiles.json');
|
|
79
|
+
// Only a staging this call wrote may ever be removed: the file is the member's live sign-in once the engine has
|
|
80
|
+
// imported it, and a failed run must not take that with it.
|
|
81
|
+
const wrote = !existsSync(staged);
|
|
82
|
+
if (wrote) {
|
|
83
|
+
// Doctor canonicalizes legacy provider ids before importing; staging `auth.json` instead keeps the old id,
|
|
84
|
+
// which looks signed in but cannot authenticate `openai/*` turns.
|
|
85
|
+
mkdirSync(agentDir, { recursive: true, mode: 0o700 });
|
|
86
|
+
const body = JSON.stringify({ version: 1, profiles: Object.fromEntries(Object.entries(legacy).map(([provider, credential]) => [`${provider}:default`, credential])) });
|
|
87
|
+
// Written beside its home and renamed into place, so a reader never sees half a profile store.
|
|
88
|
+
const staging = `${staged}.staging-${process.pid}`;
|
|
89
|
+
writeFileSync(staging, body, { mode: 0o600 });
|
|
90
|
+
renameSync(staging, staged);
|
|
91
|
+
}
|
|
92
|
+
// The doctor's exit is a weak yes (it exits 0 even when it imports nothing), so a failed run only clears the
|
|
93
|
+
// staging; retiring the original stays confirm's job.
|
|
94
|
+
if (ctx.doctor().status !== 0) {
|
|
95
|
+
if (wrote)
|
|
96
|
+
rmSync(staged, { force: true });
|
|
97
|
+
return 'failed';
|
|
98
|
+
}
|
|
99
|
+
return 'staged';
|
|
100
|
+
};
|
|
101
|
+
return ctx.withStore ? ctx.withStore(stage) : stage();
|
|
83
102
|
}
|
|
84
103
|
/**
|
|
85
|
-
* After `ready`: the migration counts only when the Gateway itself reports every provider signed in. Only then
|
|
86
|
-
* a path source
|
|
104
|
+
* After `ready`: the migration counts only when the Gateway itself reports every provider signed in. Only then is
|
|
105
|
+
* a path source removed without creating a plaintext archive — anything else leaves the sign-in where
|
|
87
106
|
* it was and the next boot retries it without asking the person to sign in again.
|
|
88
107
|
*/
|
|
89
108
|
export async function confirmRetainedLogin(ctx, member, source) {
|
|
@@ -93,7 +112,7 @@ export async function confirmRetainedLogin(ctx, member, source) {
|
|
|
93
112
|
const path = sourceFile(source);
|
|
94
113
|
if (original && !path)
|
|
95
114
|
return false;
|
|
96
|
-
const legacy = credentials(source, path);
|
|
115
|
+
const legacy = await credentials(source, path, ctx.seal);
|
|
97
116
|
if (!legacy)
|
|
98
117
|
return false;
|
|
99
118
|
const wanted = wantedProviders(legacy);
|
|
@@ -103,10 +122,12 @@ export async function confirmRetainedLogin(ctx, member, source) {
|
|
|
103
122
|
for (let tries = 0; tries < TRIES; tries++) {
|
|
104
123
|
const have = await providers(ctx, member, true);
|
|
105
124
|
if (wanted.every((provider) => have.includes(provider))) {
|
|
106
|
-
if (
|
|
107
|
-
|
|
108
|
-
|
|
125
|
+
if (original) {
|
|
126
|
+
for (const file of [original, original + MOVED, original + MOVED + '.sealed'])
|
|
127
|
+
rmSync(file, { force: true });
|
|
109
128
|
writeFileSync(original + MOVED + MARKER, '', { mode: 0o600 });
|
|
129
|
+
ctx.log?.('verified retained credential source removed');
|
|
130
|
+
}
|
|
110
131
|
return true; // a record source moves nothing: the app deletes its own copy
|
|
111
132
|
}
|
|
112
133
|
if (tries < TRIES - 1)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@byokit/openclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "The OpenClaw runtime kit: the pinned engine's full operator surface with plain-words helpers for members, sign-in, runs and approvals.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -51,10 +51,11 @@
|
|
|
51
51
|
"prepack": "tsc -b && node ../../scripts/fix-words-dts.cjs"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
-
"@byokit/link": "0.
|
|
55
|
-
"@byokit/relay": "0.4.
|
|
54
|
+
"@byokit/link": "0.6.0",
|
|
55
|
+
"@byokit/relay": "0.4.2",
|
|
56
56
|
"@byokit/reach": "0.4.0",
|
|
57
57
|
"@byokit/seal": "0.2.0",
|
|
58
|
+
"@byokit/secrets": "0.3.0",
|
|
58
59
|
"@openclaw/gateway-client": "2026.8.1",
|
|
59
60
|
"@openclaw/gateway-protocol": "2026.8.1",
|
|
60
61
|
"ws": "8.21.3"
|