@byokit/openclaw 0.12.0 → 0.13.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 +6 -0
- package/dist/accounts.d.ts +4 -0
- package/dist/accounts.js +154 -19
- package/dist/auth-store.d.ts +6 -0
- package/dist/auth-store.js +135 -23
- package/dist/engine.d.ts +1 -0
- package/dist/engine.js +15 -0
- package/dist/kit.d.ts +3 -0
- package/dist/kit.js +11 -4
- package/dist/testing/fake-gateway.d.ts +7 -0
- package/dist/testing/fake-gateway.js +13 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.13.0 (2026-10-14)
|
|
6
|
+
|
|
7
|
+
- FEAT: OpenClaw kit S8 `kit.removeAccount(member, id)`: a member-agent account goes through `models.authLogout` with its sealed profileIds, then an empty provider `list` (still listed stops with nothing dropped and says the account is still signing in), then its entry is dropped; an account agent goes through `byokit.accounts clear`, an empty `list` (a profile still listed after clear stops with nothing deleted and says the account is still signing in), `agents.delete` with `deleteFiles: false`, removal of its `<root>/state/agents/<agent>` dir, then the entry. Removal is refused `busy` while a run holds the agent and the member stays ready. On every `prepare` and after the sealed auth store restores, the kit removes each `<member>--<hex6>` state dir that a removal recorded as gone and no index names; a legacy member's own dir is never recorded and stays. `byokit.accounts clear` takes an optional `provider` that clears only that provider; a seal failure on a member-agent sign-in clears only that provider, and when that clear is refused the add reports failed with its credential still on the member agent.
|
|
8
|
+
- SECURITY: `kit.removeAccount` re-seals the live credential store at once while the engine runs, so `auth-store.sealed` and `auth-store.objects/` stop holding the removed profile immediately instead of keeping it until the next stop; a late engine write of the removed agent is swept on the next `prepare` or sealed restore. Durable removal tombstones also exclude late refreshes from stop-time sealing and prune removed-agent entries and objects from older sealed stores before restore.
|
|
9
|
+
- FIX: Changing the wrap mode (for example turning on dual wrapping) now reseals every engine store under the new mode on the next stop, including unchanged ones, so a later restore with the OS keyring locked opens them. Since 0.12.0 unchanged stores kept their old keyring-only wrap and that restore failed with `AuthStoreUnreadableError` (`invalid-snapshot`).
|
|
10
|
+
|
|
5
11
|
## 0.12.0 (2026-10-13)
|
|
6
12
|
|
|
7
13
|
- FIX: an unsealed API key that a failed `addKey` could not clear (the gateway refused the clear after a restart ran late) is now dropped at the kit's first ready after start, so it no longer stays in the member's `byokit-key-<member>` store until the next add. A key whose ready marker exists is never cleared, and a sweep the gateway interrupts runs again on the next ready.
|
package/dist/accounts.d.ts
CHANGED
|
@@ -27,6 +27,8 @@ type StartSignIn = (agentId: string, o: SignInOptions, on: (v: SignInView) => vo
|
|
|
27
27
|
done: Promise<SignInView>;
|
|
28
28
|
};
|
|
29
29
|
export declare function memberOfAccount(root: string, ref: string): Member | undefined;
|
|
30
|
+
export declare function removedAccountAgents(root: string): Set<string>;
|
|
31
|
+
export declare function sweepOrphans(root: string): void;
|
|
30
32
|
export declare function createAccounts(ctx: {
|
|
31
33
|
request: GatewayTransport['request'];
|
|
32
34
|
root: string;
|
|
@@ -40,6 +42,7 @@ export declare function createAccounts(ctx: {
|
|
|
40
42
|
session: string;
|
|
41
43
|
}>;
|
|
42
44
|
log?: (line: string) => void;
|
|
45
|
+
reseal?: () => Promise<void>;
|
|
43
46
|
}): {
|
|
44
47
|
list(member: Member): Promise<Account[]>;
|
|
45
48
|
add(member: Member, o: AddOptions, on: (v: SignInView) => void): Promise<AccountSignIn>;
|
|
@@ -56,5 +59,6 @@ export declare function createAccounts(ctx: {
|
|
|
56
59
|
}>;
|
|
57
60
|
rest(member: Member, id: AccountId, until: number): Promise<void>;
|
|
58
61
|
move(member: Member, sessionKey: string, to: AccountId): Promise<MoveResult>;
|
|
62
|
+
remove(member: Member, id: AccountId): Promise<void>;
|
|
59
63
|
};
|
|
60
64
|
export {};
|
package/dist/accounts.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// The per-member account index (5.15, O14 step S5): `<root>/accounts/<member>.json` (0600, whole-file temp
|
|
2
2
|
// plus rename) is the only kit state: `{ v: 1, accounts: { [id]: { agent, name, route, addedAt, until?, model?,
|
|
3
|
-
// who? } }, defaults }
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
3
|
+
// who? } }, defaults, gone? }`, where `gone` names the agent dirs a removal left for the orphan sweep. No credential,
|
|
4
|
+
// token, email or engine line. Listing, rename, defaults, models, room, adding with its identity merge (O14 steps
|
|
5
|
+
// S6, S7), the start-of-run pick (S10), session binding (S11), moving a conversation (S12) and removal with its
|
|
6
|
+
// orphan sweep (S8) live here.
|
|
7
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
8
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
8
9
|
import { join } from 'node:path';
|
|
9
10
|
import { providerOf, resolveSelection } from "./pick.js";
|
|
@@ -86,7 +87,8 @@ const readIndex = (root, member) => {
|
|
|
86
87
|
defaults.model = raw.model;
|
|
87
88
|
if (typeof raw.auto === 'boolean')
|
|
88
89
|
defaults.auto = raw.auto;
|
|
89
|
-
|
|
90
|
+
const gone = Array.isArray(file.gone) ? file.gone.filter((a) => typeof a === 'string' && ACCOUNT_AGENT.test(a)) : [];
|
|
91
|
+
return { v: 1, accounts, defaults, ...(gone.length ? { gone } : {}) };
|
|
90
92
|
};
|
|
91
93
|
// Attribution (5.15, O14m): the member an agent id or `agent:<agent>:<tail>` session key belongs to. A member agent is its
|
|
92
94
|
// own, and so is an agent whose workspace the kit created for it (a legacy member id may end in `--<hex6>`); any other
|
|
@@ -108,6 +110,64 @@ export function memberOfAccount(root, ref) {
|
|
|
108
110
|
return undefined;
|
|
109
111
|
}
|
|
110
112
|
}
|
|
113
|
+
// Every agent any member's index names. An index that cannot be read or parsed throws: its accounts are unknown,
|
|
114
|
+
// so no sweep may run.
|
|
115
|
+
function indexedAgents(root) {
|
|
116
|
+
const named = new Set();
|
|
117
|
+
const gone = new Set();
|
|
118
|
+
let files;
|
|
119
|
+
try {
|
|
120
|
+
files = readdirSync(join(root, 'accounts'));
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (error.code === 'ENOENT')
|
|
124
|
+
return { named, gone };
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
for (const file of files) {
|
|
128
|
+
if (!file.endsWith('.json'))
|
|
129
|
+
continue;
|
|
130
|
+
let parsed;
|
|
131
|
+
try {
|
|
132
|
+
parsed = JSON.parse(readFileSync(join(root, 'accounts', file), 'utf8'));
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
throw new Error(`account index is unreadable: accounts/${file}`, { cause: error });
|
|
136
|
+
}
|
|
137
|
+
for (const entry of Object.values(parsed.accounts ?? {}))
|
|
138
|
+
if (typeof entry?.agent === 'string')
|
|
139
|
+
named.add(entry.agent);
|
|
140
|
+
for (const agent of Array.isArray(parsed.gone) ? parsed.gone : [])
|
|
141
|
+
if (typeof agent === 'string')
|
|
142
|
+
gone.add(agent);
|
|
143
|
+
}
|
|
144
|
+
return { named, gone };
|
|
145
|
+
}
|
|
146
|
+
// Durable removal tombstones, except ids an index now names again. Shared by the live and sealed-store sweeps.
|
|
147
|
+
export function removedAccountAgents(root) {
|
|
148
|
+
const { named, gone } = indexedAgents(root);
|
|
149
|
+
return new Set([...gone].filter((agent) => ACCOUNT_AGENT.test(agent) && !named.has(agent)));
|
|
150
|
+
}
|
|
151
|
+
// Removes each `<root>/state/agents/<member>--<hex6>` directory that a removal recorded as gone and no index names.
|
|
152
|
+
// Only recorded directories go: a legacy member's own directory is never recorded, so it stays. The directories are
|
|
153
|
+
// listed before the index is read: each listed one was created after its entry was written.
|
|
154
|
+
export function sweepOrphans(root) {
|
|
155
|
+
let dirs;
|
|
156
|
+
try {
|
|
157
|
+
dirs = readdirSync(join(root, 'state', 'agents'));
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
if (error.code === 'ENOENT')
|
|
161
|
+
return;
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
const gone = removedAccountAgents(root);
|
|
165
|
+
for (const dir of dirs) {
|
|
166
|
+
if (!gone.has(dir))
|
|
167
|
+
continue;
|
|
168
|
+
rmSync(join(root, 'state', 'agents', dir), { recursive: true, force: true });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
111
171
|
export function createAccounts(ctx) {
|
|
112
172
|
const path = (member) => indexPath(ctx.root, member);
|
|
113
173
|
const read = (member) => readIndex(ctx.root, member);
|
|
@@ -119,6 +179,13 @@ export function createAccounts(ctx) {
|
|
|
119
179
|
if (!MEMBER_ID.test(member) || member.startsWith(KEY_PREFIX))
|
|
120
180
|
throw new Error(`invalid member id: ${member}`);
|
|
121
181
|
};
|
|
182
|
+
const dropEntry = (file, id) => {
|
|
183
|
+
delete file.accounts[id];
|
|
184
|
+
if (file.defaults.account === id)
|
|
185
|
+
delete file.defaults.account;
|
|
186
|
+
};
|
|
187
|
+
const markGone = (file, agent) => { if (!file.gone?.includes(agent))
|
|
188
|
+
file.gone = [...(file.gone ?? []), agent]; };
|
|
122
189
|
const write = (member, file) => {
|
|
123
190
|
mkdirSync(join(ctx.root, 'accounts'), { recursive: true, mode: 0o700 });
|
|
124
191
|
const tmp = `${path(member)}.${randomUUID()}.tmp`;
|
|
@@ -141,8 +208,8 @@ export function createAccounts(ctx) {
|
|
|
141
208
|
const synthesizedRoute = (provider) => routeFor(provider, 'browser')?.choice
|
|
142
209
|
?? routes().find((r) => r.provider === provider && r.billing === 'subscription')?.choice
|
|
143
210
|
?? provider;
|
|
144
|
-
const
|
|
145
|
-
|
|
211
|
+
const agentRoster = () => ctx.request('agents.list').then((r) => new Set(r.agents.map((a) => a.id)));
|
|
212
|
+
const rosterOf = () => agentRoster().catch(() => new Set());
|
|
146
213
|
const profilesOf = (agentId) => ctx.request('byokit.accounts', { action: 'list', agentId })
|
|
147
214
|
.then((r) => Array.isArray(r.profiles) ? r.profiles : [], () => []);
|
|
148
215
|
const stateOf = (route, status, until) => {
|
|
@@ -539,6 +606,63 @@ export function createAccounts(ctx) {
|
|
|
539
606
|
// A run on the account ended resting: Auto skips it until `until` (its state reads `resting` until then).
|
|
540
607
|
const rest = (member, id, until) => edit(member, (file) => { const entry = file.accounts[id]; if (entry)
|
|
541
608
|
entry.until = until; });
|
|
609
|
+
const agentDir = (agent) => join(ctx.root, 'state', 'agents', agent);
|
|
610
|
+
// Removing (5.15): a member-agent account goes through `models.authLogout` with its sealed profileIds, then its
|
|
611
|
+
// entry is dropped. An account agent goes through `clear`, an empty `list`, `agents.delete` with
|
|
612
|
+
// `deleteFiles: false`, removal of its state dir, then the entry is dropped. Refused `busy` while a run holds
|
|
613
|
+
// the agent; the member agent stays ready (only the named provider is logged out or cleared).
|
|
614
|
+
const providerProfileIds = async (agentId, provider) => {
|
|
615
|
+
const listed = await ctx.request('byokit.accounts', { action: 'list', agentId });
|
|
616
|
+
if (listed.ok !== true || !Array.isArray(listed.profiles))
|
|
617
|
+
throw new Error('removeAccount: the sign-ins could not be read');
|
|
618
|
+
return listed.profiles.filter((p) => p.provider === provider && typeof p.profileId === 'string' && p.profileId).map((p) => p.profileId);
|
|
619
|
+
};
|
|
620
|
+
async function remove(member, id) {
|
|
621
|
+
check(member);
|
|
622
|
+
const { agentId: memberAgent } = await ctx.ensure(member);
|
|
623
|
+
const entry = read(member).accounts[id];
|
|
624
|
+
if (!entry || (entry.agent !== memberAgent && !(ACCOUNT_AGENT.test(entry.agent) && entry.agent.startsWith(`${member}--`))))
|
|
625
|
+
throw new Error(`unknown account: ${id}`);
|
|
626
|
+
const agent = entry.agent;
|
|
627
|
+
const provider = id.split('.')[0];
|
|
628
|
+
if (ctx.locks.live(agent) || signing.has(`${member}\n${id}`))
|
|
629
|
+
throw new Error('removeAccount: busy');
|
|
630
|
+
if (agent === memberAgent) {
|
|
631
|
+
await ctx.locks.exclusive([agent, indexLock(member)], async () => {
|
|
632
|
+
const profileIds = await providerProfileIds(agent, provider);
|
|
633
|
+
if (profileIds.length)
|
|
634
|
+
await ctx.request('models.authLogout', { provider, agentId: agent, profileIds });
|
|
635
|
+
if ((await providerProfileIds(agent, provider)).length)
|
|
636
|
+
throw new Error('removeAccount: the account is still signing in');
|
|
637
|
+
const file = read(member);
|
|
638
|
+
dropEntry(file, id);
|
|
639
|
+
write(member, file);
|
|
640
|
+
// The live store changed: re-seal at once so the sealed copy stops holding the removed profile.
|
|
641
|
+
await ctx.reseal?.();
|
|
642
|
+
});
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
await ctx.locks.exclusive([agent, indexLock(member)], async () => {
|
|
646
|
+
const roster = await agentRoster();
|
|
647
|
+
if (roster.has(agent)) {
|
|
648
|
+
await ctx.request('byokit.accounts', { action: 'clear', agentId: agent });
|
|
649
|
+
const listed = await ctx.request('byokit.accounts', { action: 'list', agentId: agent });
|
|
650
|
+
if (listed.ok !== true || !Array.isArray(listed.profiles))
|
|
651
|
+
throw new Error('removeAccount: the sign-ins could not be read');
|
|
652
|
+
if (listed.profiles.length)
|
|
653
|
+
throw new Error('removeAccount: the account is still signing in');
|
|
654
|
+
await ctx.request('agents.delete', { agentId: agent, deleteFiles: false });
|
|
655
|
+
}
|
|
656
|
+
rmSync(agentDir(agent), { recursive: true, force: true });
|
|
657
|
+
// Already holding the index lock: write directly instead of `edit`, which would wait on itself.
|
|
658
|
+
const file = read(member);
|
|
659
|
+
dropEntry(file, id);
|
|
660
|
+
markGone(file, agent);
|
|
661
|
+
write(member, file);
|
|
662
|
+
// The live store changed: re-seal at once so the sealed copy stops holding the removed profile.
|
|
663
|
+
await ctx.reseal?.();
|
|
664
|
+
});
|
|
665
|
+
}
|
|
542
666
|
// Index edits take the index lock, as listing's own write does.
|
|
543
667
|
const edit = (member, fn) => ctx.locks.exclusive([indexLock(member)], async () => { const file = read(member); fn(file); write(member, file); });
|
|
544
668
|
const identityOf = async (agentId) => {
|
|
@@ -581,7 +705,7 @@ export function createAccounts(ctx) {
|
|
|
581
705
|
await ctx.request('byokit.accounts', { action: 'clear', agentId: agent });
|
|
582
706
|
await ctx.request('agents.delete', { agentId: agent, deleteFiles: false });
|
|
583
707
|
}
|
|
584
|
-
rmSync(
|
|
708
|
+
rmSync(agentDir(agent), { recursive: true, force: true });
|
|
585
709
|
await edit(member, (file) => { delete file.accounts[id]; });
|
|
586
710
|
})().catch((error) => ctx.log?.(`addAccount: ${agent} is not removed yet: ${error instanceof Error ? error.message : String(error)}`));
|
|
587
711
|
// Member-agent adds of one provider take turns from the sign-in's start to its settle, so a cleanup clears only the
|
|
@@ -776,17 +900,28 @@ export function createAccounts(ctx) {
|
|
|
776
900
|
return { ...(answer.who ? { who: answer.who } : {}), ...(keep ? { keep } : {}), dups };
|
|
777
901
|
}).catch((error) => route.keyEntry ? 'Sign-in could not be saved. Try again.'
|
|
778
902
|
: (error instanceof Error ? error.message : String(error)).slice(0, 200));
|
|
779
|
-
if (typeof sealed === 'string')
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
903
|
+
if (typeof sealed === 'string') {
|
|
904
|
+
let error = sealed;
|
|
905
|
+
// A seal failure on the member agent (the non-fresh path) leaves its new credential with no index entry,
|
|
906
|
+
// so listing hides it but runs could use it. Clear only this provider: the bare clear wipes every profile.
|
|
907
|
+
if (agent === memberAgent) {
|
|
908
|
+
const cleared = await ctx.request('byokit.accounts', { action: 'clear', agentId: memberAgent, provider })
|
|
909
|
+
.then((answer) => answer?.ok === true, () => false);
|
|
910
|
+
if (!cleared) {
|
|
911
|
+
ctx.log?.(`addAccount: ${memberAgent} ${provider} is not cleared`);
|
|
912
|
+
error = `${sealed} Its credential is still on the member agent: remove this account.`;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
await drop();
|
|
916
|
+
return { view: { state: 'failed', via: view.via, why: 'failed', error }, id };
|
|
789
917
|
}
|
|
918
|
+
for (const dup of sealed.dups)
|
|
919
|
+
await collapse(dup, sealed.keep, sealed.who);
|
|
920
|
+
const into = sealed.keep?.[0] !== id ? sealed.keep : undefined;
|
|
921
|
+
if (!into || !await adopt(sealed.who, into[1].agent))
|
|
922
|
+
return { view, id };
|
|
923
|
+
await removeEntry();
|
|
924
|
+
return { view, id: into[0] };
|
|
790
925
|
}
|
|
791
926
|
// A member agent held no profile of this provider before a non-fresh add, so clearing the provider removes only this one.
|
|
792
927
|
if (agent === memberAgent)
|
|
@@ -813,5 +948,5 @@ export function createAccounts(ctx) {
|
|
|
813
948
|
throw error;
|
|
814
949
|
}
|
|
815
950
|
}
|
|
816
|
-
return { list, add, rename, defaults, setDefaults, models, room, bind, keyRefusal, pick, rest, move };
|
|
951
|
+
return { list, add, rename, defaults, setDefaults, models, room, bind, keyRefusal, pick, rest, move, remove };
|
|
817
952
|
}
|
package/dist/auth-store.d.ts
CHANGED
|
@@ -61,6 +61,12 @@ export declare class AuthStore {
|
|
|
61
61
|
private restore;
|
|
62
62
|
archives(): Promise<void>;
|
|
63
63
|
prepare(): Promise<void>;
|
|
64
|
+
/** Re-seal the credential snapshot while the engine runs (removeAccount): it drops the removed profile at once
|
|
65
|
+
* instead of waiting for stop, and read() drops the removed agent's sealed objects. Database objects are never
|
|
66
|
+
* read here — each keeps its last sealed copy until stop, when persist() seals the live databases. Live files
|
|
67
|
+
* stay in place — unlike persist(), nothing is removed and no crash-transition recovery runs, so this never
|
|
68
|
+
* deletes the tree a live engine is running on. No-op without a seal or while the store is not active. */
|
|
69
|
+
reseal(): Promise<void>;
|
|
64
70
|
start(): Promise<void>;
|
|
65
71
|
stop(): Promise<void>;
|
|
66
72
|
offline<T>(task: () => Promise<T>): Promise<T>;
|
package/dist/auth-store.js
CHANGED
|
@@ -8,6 +8,7 @@ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync,
|
|
|
8
8
|
import { createHash } from 'node:crypto';
|
|
9
9
|
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
10
10
|
import { EngineAlreadyRunningError, pidAlive as live } from "./engine-status.js";
|
|
11
|
+
import { removedAccountAgents, sweepOrphans } from "./accounts.js";
|
|
11
12
|
const encoder = new TextEncoder();
|
|
12
13
|
const archive = (name) => !name.endsWith('.sealed') && (/^(auth-profiles|auth-state|auth|oauth)\.json\.(migrated-.+|sqlite-import\..+\.bak)$/.test(name)
|
|
13
14
|
|| name.endsWith('.moved-to-engine'));
|
|
@@ -140,6 +141,11 @@ function safePath(path) {
|
|
|
140
141
|
return typeof path === 'string' && /^(state|home)(\/[^/]+)*$/.test(path)
|
|
141
142
|
&& !path.split('/').some((part) => part === '.' || part === '..' || part.includes('\\') || part.includes('\0'));
|
|
142
143
|
}
|
|
144
|
+
// A file the live engine deletes between the directory listing and the read is simply gone: there is nothing to
|
|
145
|
+
// seal, so callers skip it. Every other failure still throws.
|
|
146
|
+
function vanished(error) {
|
|
147
|
+
return error.code === 'ENOENT';
|
|
148
|
+
}
|
|
143
149
|
function snapshot(text) {
|
|
144
150
|
const s = JSON.parse(text);
|
|
145
151
|
if ((s?.v !== 1 && s?.v !== 2) || !Array.isArray(s.dirs) || !Array.isArray(s.files))
|
|
@@ -206,8 +212,9 @@ export class AuthStore {
|
|
|
206
212
|
cleanup;
|
|
207
213
|
restoring;
|
|
208
214
|
// Object file name -> sha256 of the plaintext payload currently sealed at that file. Kept only in memory: it
|
|
209
|
-
// never becomes on-disk metadata. Filled by restore() from what is decrypted
|
|
210
|
-
// seal. A process that never restored starts with an empty map and
|
|
215
|
+
// never becomes on-disk metadata. Filled by restore() from what is decrypted (except objects in an older wrap
|
|
216
|
+
// mode), refreshed after each successful seal. A process that never restored starts with an empty map and
|
|
217
|
+
// reseals every object, as before.
|
|
211
218
|
objectHashes = new Map();
|
|
212
219
|
constructor(privateOptions) {
|
|
213
220
|
this.o = privateOptions;
|
|
@@ -315,19 +322,38 @@ export class AuthStore {
|
|
|
315
322
|
catch {
|
|
316
323
|
throw new AuthStoreUnreadableError('invalid-snapshot');
|
|
317
324
|
}
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
325
|
+
const gone = removedAccountAgents(this.o.root);
|
|
326
|
+
const keep = (path) => !path.startsWith('state/agents/') || !gone.has(path.split('/')[2]);
|
|
327
|
+
const kept = { v: 1, dirs: saved.dirs.filter(keep), files: saved.files.filter(([path]) => keep(path)) };
|
|
328
|
+
if (kept.dirs.length !== saved.dirs.length || kept.files.length !== saved.files.length) {
|
|
329
|
+
this.seal(this.file, JSON.stringify(kept));
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
const upgraded = seal.upgrade?.(bytes);
|
|
333
|
+
if (upgraded) {
|
|
334
|
+
if (seal.decryptString(Buffer.from(upgraded)) !== text)
|
|
335
|
+
throw new Error('credential upgrade verification failed');
|
|
336
|
+
put(this.file, upgraded);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (gone.size && existsSync(this.objects)) {
|
|
340
|
+
for (const entry of readdirSync(this.objects)) {
|
|
341
|
+
const file = join(this.objects, entry);
|
|
342
|
+
if (keep(this.readObject(file)[0]))
|
|
343
|
+
continue;
|
|
344
|
+
rmSync(file);
|
|
345
|
+
this.objectHashes.delete(entry);
|
|
346
|
+
}
|
|
323
347
|
}
|
|
324
|
-
return
|
|
348
|
+
return kept;
|
|
325
349
|
}
|
|
326
350
|
readObject(file) {
|
|
351
|
+
let bytes = Buffer.alloc(0);
|
|
327
352
|
let text = '';
|
|
328
353
|
let entry = null;
|
|
329
354
|
try {
|
|
330
|
-
|
|
355
|
+
bytes = readFileSync(file);
|
|
356
|
+
text = this.open(bytes);
|
|
331
357
|
entry = JSON.parse(text);
|
|
332
358
|
}
|
|
333
359
|
catch (error) {
|
|
@@ -340,7 +366,7 @@ export class AuthStore {
|
|
|
340
366
|
if (!safePath(path) || !apart(path) || typeof data !== 'string' || Buffer.from(data, 'base64').toString('base64') !== data) {
|
|
341
367
|
throw new AuthStoreUnreadableError('invalid-snapshot');
|
|
342
368
|
}
|
|
343
|
-
return [path, data, text];
|
|
369
|
+
return [path, data, text, bytes];
|
|
344
370
|
}
|
|
345
371
|
inventory() {
|
|
346
372
|
const dirs = [];
|
|
@@ -348,15 +374,35 @@ export class AuthStore {
|
|
|
348
374
|
const objects = [];
|
|
349
375
|
let total = 0;
|
|
350
376
|
const root = realpathSync(this.o.root);
|
|
377
|
+
const gone = removedAccountAgents(this.o.root);
|
|
351
378
|
const walk = (path) => {
|
|
352
379
|
const name = relative(this.o.root, path).split('\\').join('/');
|
|
380
|
+
if (name.startsWith('state/agents/') && gone.has(name.split('/')[2]))
|
|
381
|
+
return; // a late refresh never enters a seal
|
|
353
382
|
if (name.startsWith('home/') && nonCredential(name))
|
|
354
383
|
return; // regenerable home caches stay on disk; skipping a directory skips its subtree
|
|
355
|
-
|
|
384
|
+
let stat;
|
|
385
|
+
try {
|
|
386
|
+
stat = lstatSync(path);
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
if (vanished(error))
|
|
390
|
+
return;
|
|
391
|
+
throw error;
|
|
392
|
+
}
|
|
356
393
|
if (stat.isDirectory()) {
|
|
357
394
|
if (!apart(name))
|
|
358
395
|
dirs.push(name);
|
|
359
|
-
|
|
396
|
+
let children;
|
|
397
|
+
try {
|
|
398
|
+
children = readdirSync(path).sort();
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
if (vanished(error))
|
|
402
|
+
return;
|
|
403
|
+
throw error;
|
|
404
|
+
}
|
|
405
|
+
for (const child of children)
|
|
360
406
|
walk(join(path, child));
|
|
361
407
|
return;
|
|
362
408
|
}
|
|
@@ -374,7 +420,14 @@ export class AuthStore {
|
|
|
374
420
|
const rel = relative(root, file);
|
|
375
421
|
if (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`))
|
|
376
422
|
return;
|
|
377
|
-
|
|
423
|
+
try {
|
|
424
|
+
target = lstatSync(file);
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
if (vanished(error))
|
|
428
|
+
return;
|
|
429
|
+
throw error;
|
|
430
|
+
}
|
|
378
431
|
}
|
|
379
432
|
if (!target.isFile())
|
|
380
433
|
return;
|
|
@@ -396,15 +449,44 @@ export class AuthStore {
|
|
|
396
449
|
return { dirs, files, objects };
|
|
397
450
|
}
|
|
398
451
|
collect() {
|
|
452
|
+
sweepOrphans(this.o.root);
|
|
399
453
|
const { dirs, files, objects } = this.inventory();
|
|
400
|
-
for (const dir of dirs)
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
454
|
+
for (const dir of dirs) {
|
|
455
|
+
try {
|
|
456
|
+
chmodSync(join(this.o.root, dir), 0o700);
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
if (!vanished(error))
|
|
460
|
+
throw error;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
// Read the checked target, but save the link's path: restore materializes a regular file there. A file that
|
|
464
|
+
// vanishes here is skipped by the same rule as in the listing above.
|
|
465
|
+
const read = ({ name, file }) => {
|
|
466
|
+
try {
|
|
467
|
+
chmodSync(file, 0o600);
|
|
468
|
+
return [name, readFileSync(file).toString('base64')];
|
|
469
|
+
}
|
|
470
|
+
catch (error) {
|
|
471
|
+
if (vanished(error))
|
|
472
|
+
return undefined;
|
|
473
|
+
throw error;
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
const kept = objects.filter(({ file }) => {
|
|
477
|
+
try {
|
|
478
|
+
chmodSync(file, 0o600);
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
catch (error) {
|
|
482
|
+
if (vanished(error))
|
|
483
|
+
return false;
|
|
484
|
+
throw error;
|
|
485
|
+
}
|
|
486
|
+
});
|
|
405
487
|
return {
|
|
406
|
-
saved: { v: 1, dirs, files: files.map((
|
|
407
|
-
objects,
|
|
488
|
+
saved: { v: 1, dirs, files: files.map(read).filter((entry) => entry !== undefined) },
|
|
489
|
+
objects: kept,
|
|
408
490
|
};
|
|
409
491
|
}
|
|
410
492
|
/** Remove the live sealed trees: all of `state` (every file there is sealed, as a blob entry or an object),
|
|
@@ -431,10 +513,19 @@ export class AuthStore {
|
|
|
431
513
|
mkdirSync(this.objects, { recursive: true, mode: 0o700 });
|
|
432
514
|
const keep = new Set();
|
|
433
515
|
for (const { name, file } of objects) {
|
|
516
|
+
let data;
|
|
517
|
+
try {
|
|
518
|
+
data = readFileSync(file).toString('base64');
|
|
519
|
+
}
|
|
520
|
+
catch (error) {
|
|
521
|
+
if (vanished(error))
|
|
522
|
+
continue;
|
|
523
|
+
throw error;
|
|
524
|
+
}
|
|
434
525
|
const object = objectName(name);
|
|
435
526
|
keep.add(object);
|
|
436
527
|
const target = join(this.objects, object);
|
|
437
|
-
const payload = JSON.stringify({ path: name, data
|
|
528
|
+
const payload = JSON.stringify({ path: name, data });
|
|
438
529
|
// An object this process already sealed (or restored) unchanged is not resealed: its plaintext hash is
|
|
439
530
|
// the same and its sealed file is still on disk, so the encrypt + verify + fsyncs are skipped. A changed
|
|
440
531
|
// file, a new one, or one whose sealed file vanished falls through to the normal seal.
|
|
@@ -502,8 +593,11 @@ export class AuthStore {
|
|
|
502
593
|
}
|
|
503
594
|
if (existsSync(this.objects)) {
|
|
504
595
|
for (const entry of readdirSync(this.objects)) {
|
|
505
|
-
const [path, data, payload] = this.readObject(join(this.objects, entry));
|
|
506
|
-
|
|
596
|
+
const [path, data, payload, bytes] = this.readObject(join(this.objects, entry));
|
|
597
|
+
// An object sealed in an older wrap mode (the adapter would upgrade it, as read() does the main file) gets
|
|
598
|
+
// no hash, so the next seal reseals it in the current mode even when unchanged.
|
|
599
|
+
if (!this.o.seal.upgrade?.(bytes))
|
|
600
|
+
this.objectHashes.set(entry, objectHash(payload));
|
|
507
601
|
// An object path can be several directories below an existing one, and those directories are not in
|
|
508
602
|
// `saved.dirs` (they sit in a non-credential subtree). Create them through ensureRestoreDirs so the
|
|
509
603
|
// parent of every directory this restore creates — not just each object's own directory — is fsynced
|
|
@@ -558,6 +652,24 @@ export class AuthStore {
|
|
|
558
652
|
}
|
|
559
653
|
});
|
|
560
654
|
}
|
|
655
|
+
/** Re-seal the credential snapshot while the engine runs (removeAccount): it drops the removed profile at once
|
|
656
|
+
* instead of waiting for stop, and read() drops the removed agent's sealed objects. Database objects are never
|
|
657
|
+
* read here — each keeps its last sealed copy until stop, when persist() seals the live databases. Live files
|
|
658
|
+
* stay in place — unlike persist(), nothing is removed and no crash-transition recovery runs, so this never
|
|
659
|
+
* deletes the tree a live engine is running on. No-op without a seal or while the store is not active. */
|
|
660
|
+
async reseal() {
|
|
661
|
+
return this.serial(async () => {
|
|
662
|
+
if (!this.o.seal || !this.active)
|
|
663
|
+
return;
|
|
664
|
+
const previous = await this.read();
|
|
665
|
+
const { saved, objects } = this.collect();
|
|
666
|
+
// Same debris guard as persist(): a running engine always leaves files behind, so an empty live tree
|
|
667
|
+
// keeps the sealed credentials instead of replacing them with an empty snapshot.
|
|
668
|
+
if (!saved.files.length && !objects.length && previous)
|
|
669
|
+
return;
|
|
670
|
+
this.seal(this.file, JSON.stringify(saved));
|
|
671
|
+
});
|
|
672
|
+
}
|
|
561
673
|
start() {
|
|
562
674
|
return this.serial(async () => {
|
|
563
675
|
if (this.active)
|
package/dist/engine.d.ts
CHANGED
package/dist/engine.js
CHANGED
|
@@ -12,6 +12,7 @@ import { ENGINE_VERSION } from "./constants.js";
|
|
|
12
12
|
import { appendUsageBoot } from "./usage-boots.js";
|
|
13
13
|
import { reconcileConfig, appRecoveryPrefixes } from "./config.js";
|
|
14
14
|
import { writePlugin, resolveBridge } from "./bridge.js";
|
|
15
|
+
import { sweepOrphans } from "./accounts.js";
|
|
15
16
|
const kitDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
16
17
|
const putOnce = (path, data) => { if (!existsSync(path))
|
|
17
18
|
writeFileSync(path, data, { mode: 0o600, flag: 'wx' }); };
|
|
@@ -161,6 +162,7 @@ export class Engine {
|
|
|
161
162
|
await this.recoverOrphan();
|
|
162
163
|
for (const d of [this.root, ...(!this.o.authSeal ? [join(this.root, 'home'), join(this.root, 'state')] : []), join(this.root, 'tmp'), join(this.root, 'usage'), join(this.root, 'install-home'), join(this.root, 'npm-cache'), join(this.o.stateDir, 'logs')])
|
|
163
164
|
mkdirSync(d, { recursive: true, mode: 0o700 });
|
|
165
|
+
this.sweepAccountOrphans();
|
|
164
166
|
try {
|
|
165
167
|
await this.authStore.prepare();
|
|
166
168
|
this.credentialsLocked = false;
|
|
@@ -351,6 +353,18 @@ export class Engine {
|
|
|
351
353
|
throw occupied();
|
|
352
354
|
// The new store owner now seals crash leftovers under its lock before restoring them.
|
|
353
355
|
}
|
|
356
|
+
// The account orphan sweep (5.15): a late engine write after a removal cannot be ruled out by a bounded check, so
|
|
357
|
+
// prepare and the sealed restore remove each `<root>/state/agents/<member>--<hex6>` directory a removal recorded as
|
|
358
|
+
// gone and no index names. No roster here: the gateway is down, and no add is in flight while preparing (an entry
|
|
359
|
+
// is written before its agent is created, so an account being added is named).
|
|
360
|
+
sweepAccountOrphans() {
|
|
361
|
+
try {
|
|
362
|
+
sweepOrphans(this.root);
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
this.o.log?.(`prepare: orphans are not swept: ${String(error.message).slice(0, 200)}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
354
368
|
start() {
|
|
355
369
|
if (this.starting)
|
|
356
370
|
return this.starting;
|
|
@@ -382,6 +396,7 @@ export class Engine {
|
|
|
382
396
|
this.state('failed', 'auth-store-unreadable');
|
|
383
397
|
throw error;
|
|
384
398
|
}
|
|
399
|
+
this.sweepAccountOrphans();
|
|
385
400
|
try {
|
|
386
401
|
return this.launch();
|
|
387
402
|
}
|
package/dist/kit.d.ts
CHANGED
|
@@ -148,6 +148,9 @@ export declare class OpenClawKit {
|
|
|
148
148
|
}>;
|
|
149
149
|
}>;
|
|
150
150
|
renameAccount(member: Member, id: AccountId, name: string): Promise<Account>;
|
|
151
|
+
/** Remove one account: a member-agent account through `authLogout` with its profileIds, an account agent through
|
|
152
|
+
* `clear`, an empty `list`, `agents.delete` with `deleteFiles: false`, removal of its state dir, then the entry. */
|
|
153
|
+
removeAccount(member: Member, id: AccountId): Promise<void>;
|
|
151
154
|
defaults(member: Member): Promise<Defaults>;
|
|
152
155
|
setDefaults(member: Member, d: Defaults): Promise<void>;
|
|
153
156
|
/** Save an explicitly supplied API key (billed per use) in this member's separate key agent. */
|
package/dist/kit.js
CHANGED
|
@@ -763,7 +763,7 @@ export class OpenClawKit {
|
|
|
763
763
|
this.accountsCtl = createAccounts({ request: (method, params, o) => this.request()(method, params, o), root: this.engine.root,
|
|
764
764
|
ensure: (member) => this.ensureMember(member), locks: this.locks,
|
|
765
765
|
signIn: (agentId, o, on, fresh) => startSignIn({ ...this.signInCtx(), ensure: async () => ({ agentId }), fresh }, agentId, o, on),
|
|
766
|
-
routes: this.routeMap, log: this.o.log });
|
|
766
|
+
routes: this.routeMap, log: this.o.log, reseal: () => this.engine.authStore.reseal() });
|
|
767
767
|
// The fail-closed tool bridge feeds the kit's single Approval surface (5.9, O5).
|
|
768
768
|
await this.bridge.start();
|
|
769
769
|
this.off.push(transport.onEvent((e) => this.approvalsCtl.handleEvent(e)));
|
|
@@ -981,9 +981,9 @@ export class OpenClawKit {
|
|
|
981
981
|
memberOf(ref) {
|
|
982
982
|
return memberOfAccount(this.engine.root, ref);
|
|
983
983
|
}
|
|
984
|
-
// accounts (5.15, O14 steps S5, S6 and
|
|
985
|
-
// and room; `run({ account })` and `pick` choose among them, held to the account a session is bound to,
|
|
986
|
-
// carries a conversation to another account
|
|
984
|
+
// accounts (5.15, O14 steps S5, S6 and S8-S12): the per-member index with listing, adding, defaults, rename, models
|
|
985
|
+
// and room; `run({ account })` and `pick` choose among them, held to the account a session is bound to, `move`
|
|
986
|
+
// carries a conversation to another account, and `removeAccount` deletes one.
|
|
987
987
|
accounts(member) {
|
|
988
988
|
if (!this.accountsCtl)
|
|
989
989
|
return Promise.reject(new Error('gateway not ready'));
|
|
@@ -1028,6 +1028,13 @@ export class OpenClawKit {
|
|
|
1028
1028
|
return Promise.reject(new Error('gateway not ready'));
|
|
1029
1029
|
return this.accountsCtl.rename(member, id, name);
|
|
1030
1030
|
}
|
|
1031
|
+
/** Remove one account: a member-agent account through `authLogout` with its profileIds, an account agent through
|
|
1032
|
+
* `clear`, an empty `list`, `agents.delete` with `deleteFiles: false`, removal of its state dir, then the entry. */
|
|
1033
|
+
removeAccount(member, id) {
|
|
1034
|
+
if (!this.accountsCtl)
|
|
1035
|
+
return Promise.reject(new Error('gateway not ready'));
|
|
1036
|
+
return this.accountsCtl.remove(member, id);
|
|
1037
|
+
}
|
|
1031
1038
|
defaults(member) {
|
|
1032
1039
|
if (!this.accountsCtl)
|
|
1033
1040
|
return Promise.reject(new Error('gateway not ready'));
|
|
@@ -38,6 +38,13 @@ export type FakeGateway = {
|
|
|
38
38
|
provider: string;
|
|
39
39
|
type: string;
|
|
40
40
|
} & Record<string, unknown>): void;
|
|
41
|
+
/** A late engine refresh racing a removal (O14 S8): writes one profile of `provider` onto `agentId` whole, as the
|
|
42
|
+
* pin would under its store lock. With `order: false` it lands with no order entry; `expires` (default: valid)
|
|
43
|
+
* may name an expired credential. */
|
|
44
|
+
refresh(agentId: string, provider: string, o?: {
|
|
45
|
+
order?: boolean;
|
|
46
|
+
expires?: number;
|
|
47
|
+
}): void;
|
|
41
48
|
} & {
|
|
42
49
|
transport: GatewayTransport;
|
|
43
50
|
};
|
|
@@ -858,6 +858,19 @@ export function fakeGateway(script, o) {
|
|
|
858
858
|
storeOf(agentId)[profileId] = profile;
|
|
859
859
|
setPrimaryOfId(agentId, `${profile.provider}/default@${profileId}`);
|
|
860
860
|
},
|
|
861
|
+
refresh: (agentId, provider, o) => {
|
|
862
|
+
const agent = ensureAgent(agentId);
|
|
863
|
+
if (!agent.providers.includes(provider))
|
|
864
|
+
agent.providers.push(provider);
|
|
865
|
+
const profileId = `${provider}:default`;
|
|
866
|
+
storeOf(agentId)[profileId] = { provider, type: 'oauth', status: 'ok', access: 'fake-access',
|
|
867
|
+
refresh: 'fake-refresh', expires: o?.expires ?? Date.now() + 30 * 86_400_000,
|
|
868
|
+
accountId: `fake-refresh-${agentId}` };
|
|
869
|
+
if (o?.order !== false)
|
|
870
|
+
orderOf(agentId)[provider] = [profileId];
|
|
871
|
+
else if (orderOf(agentId)[provider])
|
|
872
|
+
delete orderOf(agentId)[provider];
|
|
873
|
+
},
|
|
861
874
|
transport: factory({ port: 0, token: 'test', identityPath: '', bridgeSock: '' }),
|
|
862
875
|
};
|
|
863
876
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@byokit/openclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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",
|