@byokit/openclaw 0.10.0 → 0.11.1

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.
@@ -0,0 +1,224 @@
1
+ // Account sealing (5.15, O14): the gateway method `byokit.accounts` over one agent's own auth-profile store.
2
+ // list/seal/ready/adopt/clear. Runs beside the other plugin methods; no credential value ever crosses an answer.
3
+ // `who` is a one-way identity: the first 16 hex digits of sha256(`<provider>\n<accountId>`), so the account id
4
+ // itself never leaves the engine.
5
+ import { createHash } from 'node:crypto';
6
+ import { isDeepStrictEqual } from 'node:util';
7
+
8
+ // Local-only store I/O (as keys.js): never read through inherited auth and never sync an external CLI.
9
+ const LOCAL = { filterExternalAuthProfiles: false, syncExternalCli: false };
10
+ // An account agent is `<member>--<6 hex>` (D9); a member id never contains `--`.
11
+ const ACCOUNT_AGENT = /--[0-9a-f]{6}$/;
12
+ const AGENT_ID = /^[a-z][a-z0-9-]{0,31}$/;
13
+ const EXPIRING_MS = 300_000; // auth selection's refresh margin: at or under this, a token is "expiring"
14
+ const OAUTH_WARN_MS = 86_400_000; // auth-health's default oauth warn window: a day out is still "expiring"
15
+
16
+ const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
17
+ const primaryOf = (entry) => {
18
+ const ref = entry?.model;
19
+ return typeof ref === 'string' ? ref : object(ref) && typeof ref.primary === 'string' ? ref.primary : undefined;
20
+ };
21
+ const hasSecret = (value, ref) => (typeof value === 'string' && value.length > 0) || object(ref);
22
+ /** The sign-in identity for an OAuth profile that stores the provider's account id; api_key/token never have one. */
23
+ const whoOf = (credential) => credential?.type === 'oauth' && typeof credential.accountId === 'string' && credential.accountId.length > 0
24
+ ? createHash('sha256').update(`${credential.provider}\n${credential.accountId}`).digest('hex').slice(0, 16) : undefined;
25
+ /** The engine's token expiry state (credential-state.ts): missing, invalid, expired, or valid. */
26
+ const expiryState = (expires, now) => {
27
+ if (expires === undefined) return 'missing';
28
+ if (typeof expires !== 'number' || !Number.isFinite(expires) || expires <= 0 || expires > 8.64e13) return 'invalid';
29
+ return expires <= now ? 'expired' : 'valid';
30
+ };
31
+ /** The engine's profile status (auth-health.ts), from the credential alone: static, ok, expiring, expired or missing. */
32
+ const statusOf = (credential, now) => {
33
+ if (credential.type === 'api_key') return hasSecret(credential.key, credential.keyRef) ? 'static' : 'missing';
34
+ if (credential.type === 'token') {
35
+ if (!hasSecret(credential.token, credential.tokenRef)) return 'missing';
36
+ const state = expiryState(credential.expires, now);
37
+ if (state === 'invalid') return 'missing';
38
+ if (state === 'expired') return 'expired';
39
+ if (credential.expires === undefined) return 'static';
40
+ return credential.expires - now <= EXPIRING_MS ? 'expiring' : 'ok';
41
+ }
42
+ if (!hasSecret(credential.access, undefined) && !hasSecret(credential.refresh, undefined)) return 'missing';
43
+ const state = expiryState(credential.expires, now);
44
+ if (state === 'invalid' || state === 'missing') return 'missing';
45
+ if (state === 'expired') return 'expired';
46
+ return credential.expires - now <= OAUTH_WARN_MS ? 'expiring' : 'ok';
47
+ };
48
+
49
+ /** A read (`fn` returns nothing) or a write when `fn` sets `store.changed`. Resolves the store, or null on lock contention. */
50
+ function withStore(updateAuthProfileStoreWithLock, agentDir, fn) {
51
+ return updateAuthProfileStoreWithLock({ agentDir, saveOptions: LOCAL, updater(store) { return fn(store); } });
52
+ }
53
+ /** Keep only `profileId` of `provider`; on an account agent drop every other provider too. */
54
+ const sealProfiles = (profiles, provider, profileId, account) => account
55
+ ? { [profileId]: { ...profiles[profileId] } }
56
+ : Object.fromEntries(Object.entries(profiles).filter(([id, p]) => p.provider !== provider || id === profileId));
57
+
58
+ export function registerAccounts(api) {
59
+ api.registerGatewayMethod('byokit.accounts', async ({ params, respond }) => {
60
+ // Every failure answers { ok: false }; no engine error or store contents ever leave this method.
61
+ try { respond(true, await handle(params)); }
62
+ catch { respond(true, { ok: false }); }
63
+ }, { scope: 'operator.admin' });
64
+ }
65
+
66
+ async function handle(params) {
67
+ const { readFile } = await import('node:fs/promises');
68
+ const config = JSON.parse(await readFile(process.env.OPENCLAW_CONFIG_PATH, 'utf8'));
69
+ const { resolveAgentDir } = await import('openclaw/plugin-sdk/agent-runtime');
70
+ const { updateAuthProfileStoreWithLock } = await import('openclaw/plugin-sdk/provider-auth');
71
+ // adopt names both agents itself: it is the one action that does not address a single agent.
72
+ if (params?.action === 'adopt') return adopt(params, config, resolveAgentDir, updateAuthProfileStoreWithLock);
73
+
74
+ const agentId = params?.agentId;
75
+ if (typeof agentId !== 'string' || !AGENT_ID.test(agentId)) return { ok: false };
76
+ const entry = config.agents?.entries?.[agentId];
77
+ if (!entry) return { ok: false };
78
+ const agentDir = resolveAgentDir(config, agentId);
79
+ const read = (fn) => withStore(updateAuthProfileStoreWithLock, agentDir, fn);
80
+
81
+ if (params.action === 'list') {
82
+ const now = Date.now();
83
+ let rows;
84
+ const store = await read((s) => {
85
+ rows = Object.entries(s.profiles ?? {}).map(([profileId, credential]) => {
86
+ const row = { provider: credential.provider, profileId, type: credential.type, status: statusOf(credential, now) };
87
+ const who = whoOf(credential);
88
+ if (who) row.who = who;
89
+ return row;
90
+ });
91
+ });
92
+ return store && rows ? { ok: true, profiles: rows } : { ok: false };
93
+ }
94
+
95
+ if (params.action === 'ready') {
96
+ const provider = params.provider;
97
+ if (typeof provider !== 'string' || !provider) return { ok: false };
98
+ const now = Date.now();
99
+ let answer;
100
+ const store = await read((s) => {
101
+ const order = s.order?.[provider];
102
+ const profileId = Array.isArray(order) && order.length === 1 ? order[0] : undefined;
103
+ const credential = profileId ? s.profiles?.[profileId] : undefined;
104
+ if (!credential || credential.provider !== provider) return;
105
+ const status = statusOf(credential, now);
106
+ if (status === 'missing' || status === 'expired') return;
107
+ answer = { type: credential.type };
108
+ if (typeof credential.expires === 'number') answer.expiresAt = credential.expires;
109
+ const who = whoOf(credential);
110
+ if (who) answer.who = who;
111
+ });
112
+ return store && answer ? { ok: true, ...answer } : { ok: false };
113
+ }
114
+
115
+ if (params.action === 'seal') {
116
+ const provider = params.provider;
117
+ if (typeof provider !== 'string' || !provider) return { ok: false };
118
+ const primary = primaryOf(entry);
119
+ const at = typeof primary === 'string' ? primary.lastIndexOf('@') : -1;
120
+ if (at < 1) return { ok: false };
121
+ const profileId = primary.slice(at + 1);
122
+ const account = ACCOUNT_AGENT.test(agentId);
123
+ let who;
124
+ let sealed = false;
125
+ const store = await updateAuthProfileStoreWithLock({ agentDir, saveOptions: LOCAL, updater(s) {
126
+ const credential = s.profiles?.[profileId];
127
+ // No such profile, or the named profile belongs to another provider; on a member agent an api_key is refused.
128
+ if (!credential || credential.provider !== provider || (!account && credential.type === 'api_key')) return false;
129
+ who = whoOf(credential);
130
+ s.profiles = sealProfiles(s.profiles, provider, profileId, account);
131
+ s.order = account ? { [provider]: [profileId] } : { ...(s.order ?? {}), [provider]: [profileId] };
132
+ if (object(s.lastGood) && provider in s.lastGood) delete s.lastGood[provider];
133
+ sealed = true;
134
+ return true;
135
+ } });
136
+ return store && sealed ? { ok: true, ...(who ? { who } : {}) } : { ok: false };
137
+ }
138
+
139
+ if (params.action === 'clear') {
140
+ const cleared = await updateAuthProfileStoreWithLock({ agentDir, saveOptions: LOCAL, updater(s) {
141
+ s.profiles = {}; s.order = {}; delete s.lastGood; return true;
142
+ } });
143
+ return cleared ? { ok: true } : { ok: false };
144
+ }
145
+
146
+ return { ok: false };
147
+ }
148
+
149
+ /** Move `from`'s sealed profile of `provider` into `to` and point `to`'s model at it, or answer { ok: false } with both
150
+ * stores and the model as they were. The three writes are separate transactions, so any failure after the first is
151
+ * undone in reverse: a partial move (or a model pin to a profile no store holds) must never outlive the answer. */
152
+ async function adopt(params, config, resolveAgentDir, updateAuthProfileStoreWithLock) {
153
+ const { from, to, provider, who } = params;
154
+ if (typeof from !== 'string' || !AGENT_ID.test(from) || typeof to !== 'string' || !AGENT_ID.test(to)
155
+ || typeof provider !== 'string' || !provider || typeof who !== 'string' || !who) return { ok: false };
156
+ if (from === to) return { ok: false };
157
+ if (!config.agents?.entries?.[from] || !config.agents?.entries?.[to]) return { ok: false };
158
+ const fromDir = resolveAgentDir(config, from);
159
+ const toDir = resolveAgentDir(config, to);
160
+ // from's sealed profile (order[provider] names exactly it) must carry exactly this who, or nothing changes.
161
+ let fromId;
162
+ let moved;
163
+ await withStore(updateAuthProfileStoreWithLock, fromDir, (s) => {
164
+ const order = s.order?.[provider];
165
+ fromId = Array.isArray(order) && order.length === 1 ? order[0] : undefined;
166
+ const credential = fromId ? s.profiles?.[fromId] : undefined;
167
+ if (credential && credential.provider === provider && whoOf(credential) === who) moved = credential;
168
+ });
169
+ if (!moved) return { ok: false };
170
+ // The model's current primary names the shape (string or object) and the base ref the new pin keeps.
171
+ const target = primaryOf(config.agents.entries[to]);
172
+ if (typeof target !== 'string') return { ok: false };
173
+ const cut = target.lastIndexOf('@');
174
+ const base = cut > 0 ? target.slice(0, cut) : target;
175
+ const { updateConfig } = await import('openclaw/plugin-sdk/config-runtime');
176
+ const { setAgentEffectiveModelPrimary } = await import('openclaw/plugin-sdk/agent-runtime');
177
+ const pin = (primary) => updateConfig((cfg) => { setAgentEffectiveModelPrimary(cfg, to, primary, { forceAgent: true }); return cfg; });
178
+
179
+ const account = ACCOUNT_AGENT.test(to);
180
+ let pre;
181
+ const restoreTo = () => updateAuthProfileStoreWithLock({ agentDir: toDir, saveOptions: LOCAL, updater(s) {
182
+ s.profiles = pre.profiles;
183
+ if (pre.order === undefined) delete s.order; else s.order = pre.order;
184
+ if (pre.lastGood === undefined) delete s.lastGood; else s.lastGood = pre.lastGood;
185
+ return true;
186
+ } });
187
+ try {
188
+ // Rewrite to's model primary @ suffix to from's sealed id before any store move: the pin would otherwise keep
189
+ // pinning the profile we are about to delete.
190
+ await pin(`${base}@${fromId}`);
191
+ // Move the record unchanged into to's store, sealed to it (dropping to's old profile of provider), then clear it
192
+ // from from. The grant stays shared; each agent holds only its own view (D11).
193
+ const sealed = await updateAuthProfileStoreWithLock({ agentDir: toDir, saveOptions: LOCAL, updater(s) {
194
+ pre = { profiles: s.profiles, order: s.order, lastGood: object(s.lastGood) ? { ...s.lastGood } : s.lastGood };
195
+ s.profiles = { ...sealProfiles(s.profiles, provider, fromId, account), [fromId]: moved };
196
+ s.order = { ...(s.order ?? {}), [provider]: [fromId] };
197
+ if (object(s.lastGood) && provider in s.lastGood) delete s.lastGood[provider];
198
+ return true;
199
+ } });
200
+ if (!sealed) { await pin(target); return { ok: false }; }
201
+ let clearedOk = false;
202
+ const cleared = await updateAuthProfileStoreWithLock({ agentDir: fromDir, saveOptions: LOCAL, updater(s) {
203
+ // A refresh between the read and this clear must not be deleted with the stale snapshot: refuse when the
204
+ // live record no longer equals the moved one (a who check alone would miss it; the account id never
205
+ // changes on refresh), and the caller rolls the move back.
206
+ if (!isDeepStrictEqual(s.profiles?.[fromId], moved)) return false;
207
+ if (s.profiles) delete s.profiles[fromId];
208
+ if (s.order?.[provider]) {
209
+ s.order[provider] = s.order[provider].filter((id) => id !== fromId);
210
+ if (!s.order[provider].length) delete s.order[provider];
211
+ }
212
+ clearedOk = true;
213
+ return true;
214
+ } });
215
+ if (cleared && clearedOk) return { ok: true };
216
+ await restoreTo();
217
+ await pin(target);
218
+ return { ok: false };
219
+ } catch {
220
+ await pin(target).catch(() => {});
221
+ if (pre) await restoreTo().catch(() => {});
222
+ return { ok: false };
223
+ }
224
+ }
package/plugin/index.js CHANGED
@@ -5,6 +5,7 @@ import { connect } from 'node:net';
5
5
  import { readFileSync } from 'node:fs';
6
6
  import { registerKeys } from './keys.js';
7
7
  import { registerUsage } from './usage.js';
8
+ import { registerAccounts } from './accounts.js';
8
9
 
9
10
  /** TypeBox's Kind is a global symbol, so these schemas are valid TypeBox without importing typebox. */
10
11
  const Kind = Symbol.for('TypeBox.Kind');
@@ -146,7 +147,7 @@ export default {
146
147
  jsonSchema: { type: 'object', additionalProperties: false, properties: {} },
147
148
  },
148
149
  register(api) {
149
- if (api.registerGatewayMethod) { registerKeys(api); registerUsage(api); }
150
+ if (api.registerGatewayMethod) { registerKeys(api); registerUsage(api); registerAccounts(api); }
150
151
  if (BROWSER) {
151
152
  if (typeof api.registerAgentToolResultMiddleware !== 'function') throw new Error('browser result protection unavailable');
152
153
  api.registerAgentToolResultMiddleware(async (event, ctx) => {