@byokit/openclaw 0.5.1 → 0.6.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +37 -3
  2. package/README.md +172 -2
  3. package/dist/auth-store.js +1 -1
  4. package/dist/bridge.d.ts +10 -2
  5. package/dist/bridge.js +61 -14
  6. package/dist/browser/broker.d.ts +63 -0
  7. package/dist/browser/broker.js +990 -0
  8. package/dist/browser/host.d.ts +98 -0
  9. package/dist/browser/host.js +945 -0
  10. package/dist/browser/resume.d.ts +10 -0
  11. package/dist/browser/resume.js +23 -0
  12. package/dist/browser/store.d.ts +25 -0
  13. package/dist/browser/store.js +151 -0
  14. package/dist/browser/verify.d.ts +17 -0
  15. package/dist/browser/verify.js +38 -0
  16. package/dist/browser.d.ts +212 -0
  17. package/dist/browser.js +4 -0
  18. package/dist/config.d.ts +28 -0
  19. package/dist/config.js +130 -0
  20. package/dist/day-usage.d.ts +76 -0
  21. package/dist/day-usage.js +104 -0
  22. package/dist/device.d.ts +32 -2
  23. package/dist/device.js +224 -4
  24. package/dist/engine-patches.d.ts +36 -0
  25. package/dist/engine-patches.js +320 -0
  26. package/dist/engine-status.d.ts +13 -0
  27. package/dist/engine-status.js +31 -0
  28. package/dist/engine.d.ts +13 -1
  29. package/dist/engine.js +99 -52
  30. package/dist/index.d.ts +6 -1
  31. package/dist/index.js +2 -0
  32. package/dist/kit.d.ts +28 -3
  33. package/dist/kit.js +319 -12
  34. package/dist/link.d.ts +5 -2
  35. package/dist/link.js +333 -4
  36. package/dist/routes.d.ts +19 -8
  37. package/dist/routes.js +44 -11
  38. package/dist/routes.json +3775 -356
  39. package/dist/runs.js +8 -2
  40. package/dist/signin.d.ts +1 -1
  41. package/dist/signin.js +19 -4
  42. package/dist/testing/browser.d.ts +50 -0
  43. package/dist/testing/browser.js +166 -0
  44. package/dist/testing/index.d.ts +1 -0
  45. package/dist/testing/index.js +1 -0
  46. package/dist/testing/model-stub.d.ts +1 -0
  47. package/dist/testing/model-stub.js +3 -1
  48. package/dist/types.d.ts +50 -3
  49. package/dist/usage-boots.d.ts +8 -0
  50. package/dist/usage-boots.js +26 -0
  51. package/dist/usage.d.ts +51 -0
  52. package/dist/usage.js +61 -0
  53. package/dist/words.json +42 -0
  54. package/engine/OPENCLAW-LICENSE +24 -0
  55. package/engine/patches.json +59 -0
  56. package/engine/patches.schema.json +41 -0
  57. package/package.json +12 -4
  58. package/plugin/index.js +78 -5
  59. package/plugin/usage.js +125 -0
package/dist/kit.js CHANGED
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { setTimeout as delay } from 'node:timers/promises';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { PROTOCOL_VERSION } from "./constants.js";
6
+ import { atomic } from "./engine-patches.js";
6
7
  import { Approvals } from "./approvals.js";
7
8
  import { Bridge } from "./bridge.js";
8
9
  import { Engine } from "./engine.js";
@@ -15,7 +16,10 @@ import { outputSchema } from "./output.js";
15
16
  import { createKeys } from "./keys.js";
16
17
  import { routes as routeTable } from "./routes.js";
17
18
  import { providers as engineProviders, signIn as startSignIn, signOut as engineSignOut } from "./signin.js";
18
- import { reconcileConfig, memoryLimited as configMemoryLimited } from "./config.js";
19
+ import { reconcileConfig, browserToolPolicySafe, browserProfileAcknowledged, browserSessionMayRun, memoryLimited as configMemoryLimited, configKeyPath, readConfigKey, writeConfigKey } from "./config.js";
20
+ import { createBrowserHost } from "./browser/host.js";
21
+ import { fileSignInStore } from "./browser/store.js";
22
+ import { launchBroker } from "./browser/broker.js";
19
23
  const operatorMethods = new Set([
20
24
  'agent',
21
25
  'agent.identity.get',
@@ -431,17 +435,213 @@ export class OpenClawKit {
431
435
  starting;
432
436
  stopping = false;
433
437
  failures = 0;
438
+ browser;
439
+ browserHost;
440
+ brokers = new Map();
441
+ browserSafe = false;
442
+ browserAttaching = new Map();
443
+ browserPolicySessions = new Set();
444
+ browserProfiles = {};
445
+ browserCapabilities = new Set();
446
+ browserConfig() { return { profiles: this.browserProfiles, tools: this.toolNames() }; }
447
+ async launchBrowser(member) {
448
+ let owned;
449
+ const broker = await launchBroker({ executablePath: this.o.browser.executablePath,
450
+ profileDir: join(this.o.stateDir, 'browser', member, 'profile'), member,
451
+ onExit: () => {
452
+ if (!this.stopping && owned && this.browserHost?.brokerBinding(member)?.broker === owned)
453
+ void this.browserHost.browserGone(member, owned).catch(() => { });
454
+ } });
455
+ owned = broker;
456
+ try {
457
+ const endpoint = broker.endpoint().cdpUrl, url = new URL(endpoint);
458
+ this.browserCapabilities.add(endpoint);
459
+ const token = url.searchParams.get('token');
460
+ if (!token)
461
+ throw new Error('browser capability unavailable');
462
+ this.browserCapabilities.add(token);
463
+ const id = url.pathname.split('/').at(-1);
464
+ if (id && /^[a-f0-9]{32}$/i.test(id))
465
+ this.browserCapabilities.add(id);
466
+ await broker.fence(true);
467
+ return broker;
468
+ }
469
+ catch (error) {
470
+ await broker.close();
471
+ throw error;
472
+ }
473
+ }
474
+ async prepareBrowsers() {
475
+ if (!this.o.browser || this.browserHost)
476
+ return;
477
+ await this.engine.prepare();
478
+ const c = JSON.parse(readFileSync(join(this.engine.root, 'openclaw.json'), 'utf8'));
479
+ if (browserToolPolicySafe(c, this.toolNames())) {
480
+ const members = this.o.browser.members === 'all' ? Object.keys(c.agents?.entries ?? { main: {} }) : this.o.browser.members;
481
+ for (const member of members)
482
+ if (this.browserMember(member) && !this.brokers.has(member)) {
483
+ const broker = await this.launchBrowser(member);
484
+ this.brokers.set(member, broker);
485
+ this.browserProfiles[`byokit-${member}`] = { ...broker.endpoint(), attachOnly: true };
486
+ }
487
+ }
488
+ this.browserHost = await createBrowserHost({ brokers: this.brokers, store: fileSignInStore(this.o.stateDir),
489
+ options: this.o.browser, authorize: (grant, member) => !!grant && this.browserMember(member),
490
+ park: async (_member, key) => this.abort(key), resume: async () => 'unknown',
491
+ // Production cannot raise while unprotected. Never substitute a guessed registrable site.
492
+ siteOf: () => { throw new Error('browser sign-in handoff unavailable'); },
493
+ ping: (member, kind) => {
494
+ for (const fn of this.listeners)
495
+ fn({ event: 'byokit.browser', payload: { member, kind } });
496
+ if (kind === 'state' && this.browserHost)
497
+ void this.publishBrowserProfile(member).catch(() => { });
498
+ },
499
+ // Host recovery owns the registry transition. Never recursively attach or publish a still-unowned endpoint.
500
+ restart: member => this.launchBrowser(member),
501
+ });
502
+ const controller = this.browserHost;
503
+ this.browser = {
504
+ state: member => this.browserState(member), signIns: controller.signIns.bind(controller),
505
+ takeover: controller.takeover.bind(controller), confirmOrigin: controller.confirmOrigin.bind(controller),
506
+ done: controller.done.bind(controller), notNow: controller.notNow.bind(controller),
507
+ reopen: controller.reopen.bind(controller), retry: controller.retry.bind(controller),
508
+ cancel: controller.cancel.bind(controller),
509
+ thumbnail: (source, by) => this.browserSafe ? controller.thumbnail(source, by) : Promise.resolve({ state: 'off' }),
510
+ live: (source, options, on) => {
511
+ if (!this.browserSafe)
512
+ throw new Error('browser tool policy refused');
513
+ return controller.live(source, options, on);
514
+ }, forget: controller.forget.bind(controller),
515
+ ...{ revokeGrant: controller.revokeGrant.bind(controller) },
516
+ };
517
+ }
518
+ async publishBrowserProfile(member) {
519
+ const binding = this.browserHost?.brokerBinding(member);
520
+ if (!binding)
521
+ return;
522
+ const { broker, generation, endpoint } = binding;
523
+ const name = `byokit-${member}`;
524
+ if (this.browserProfiles[name]?.cdpUrl === endpoint.cdpUrl)
525
+ return;
526
+ this.brokers.set(member, broker);
527
+ await broker.fence(true);
528
+ this.browserProfiles[name] = { ...endpoint, attachOnly: true };
529
+ try {
530
+ await this.patchConfig({});
531
+ const current = this.browserHost?.brokerBinding(member);
532
+ if (current?.broker !== broker || current.generation !== generation)
533
+ throw new Error('browser binding changed');
534
+ await this.checkBrowserTools(); // acknowledges the exact profile and rechecks policy/no-open request before unfencing
535
+ }
536
+ catch {
537
+ this.browserSafe = false;
538
+ await broker.fence(true);
539
+ throw new Error('browser endpoint unavailable');
540
+ }
541
+ }
542
+ browserMember(member) {
543
+ return MEMBER_ID.test(member) && !member.startsWith(KEY_PREFIX) && !!this.o.browser
544
+ && (this.o.browser.members === 'all' || this.o.browser.members.includes(member));
545
+ }
546
+ browserState(member) {
547
+ if (!this.browserSafe)
548
+ return { member, phase: 'blocked', why: 'unsafe-tools' };
549
+ return this.browserHost?.state(member) ?? { member, phase: 'off', why: 'no-browser' };
550
+ }
551
+ async checkBrowserTools() {
552
+ if (!this.o.browser)
553
+ return true;
554
+ this.browserSafe = false;
555
+ try {
556
+ const configPath = join(this.engine.root, 'openclaw.json');
557
+ const disk = readFileSync(configPath, 'utf8');
558
+ const snapshot = await this.request()('config.get');
559
+ const c = snapshot.config;
560
+ const owned = JSON.parse(disk);
561
+ if (!browserToolPolicySafe(c, this.toolNames()))
562
+ return false;
563
+ const roster = await this.request()('agents.list');
564
+ if (!Array.isArray(roster.agents) || !roster.agents.length)
565
+ return false;
566
+ const safe = new Set(['browser', 'request_sign_in', ...this.toolNames()]);
567
+ for (const { id } of roster.agents) {
568
+ if (!MEMBER_ID.test(id))
569
+ return false;
570
+ const sessionKey = `agent:${id}:byokit-browser-policy`;
571
+ if (!this.browserPolicySessions.has(sessionKey)) {
572
+ // The published tools.effective route requires an existing session. No message/task or model submission.
573
+ await this.request()('sessions.create', { agentId: id, key: sessionKey, label: 'Browser tool policy' });
574
+ this.browserPolicySessions.add(sessionKey);
575
+ }
576
+ const policy = await this.request()('tools.effective', { agentId: id, sessionKey });
577
+ if (policy.agentId !== id || !Array.isArray(policy.groups)
578
+ || policy.groups.some(group => !Array.isArray(group.tools) || group.tools.some(tool => !safe.has(tool.id))))
579
+ return false;
580
+ }
581
+ const stable = disk === readFileSync(configPath, 'utf8');
582
+ for (const [member, broker] of this.brokers) {
583
+ const binding = this.browserHost?.brokerBinding(member);
584
+ const profile = c.browser?.profiles?.[`byokit-${member}`];
585
+ if (!binding || binding.broker !== broker || !browserProfileAcknowledged(profile, owned.browser?.profiles?.[`byokit-${member}`], binding.endpoint.cdpUrl, snapshot, stable))
586
+ return false;
587
+ if (!this.browserHost?.signIns(member).some(r => ['waiting', 'held', 'checking'].includes(r.state))) {
588
+ await broker.fence(false);
589
+ const current = this.browserHost?.brokerBinding(member);
590
+ if (current?.broker !== broker || current.generation !== binding.generation)
591
+ return false;
592
+ }
593
+ }
594
+ this.browserSafe = true;
595
+ return true;
596
+ }
597
+ catch {
598
+ return false;
599
+ }
600
+ finally {
601
+ if (!this.browserSafe)
602
+ for (const broker of this.brokers.values())
603
+ await broker.fence(true);
604
+ }
605
+ }
606
+ async browserGate(run, tool) {
607
+ if (!this.o.browser)
608
+ return undefined;
609
+ const safe = await this.checkBrowserTools();
610
+ const allowed = new Set(['browser', 'request_sign_in', ...this.toolNames()]);
611
+ if (!safe || !allowed.has(tool))
612
+ return 'browser tool policy refused';
613
+ if ((tool === 'browser' || tool === 'request_sign_in') && !this.browserMember(run.member))
614
+ return 'browser member unavailable';
615
+ if ((tool === 'browser' || tool === 'request_sign_in') && this.browserHost?.signIns(run.member)
616
+ .some(r => ['waiting', 'held', 'checking'].includes(r.state)))
617
+ return 'Waiting for the person to sign in';
618
+ if (tool === 'request_sign_in')
619
+ return 'browser sign-in handoff unavailable';
620
+ return undefined;
621
+ }
434
622
  constructor(o) {
435
623
  if (o.tools?.length && !o.host)
436
624
  throw new Error('host required when tools are registered');
625
+ if (o.browser && o.gateBuiltins === false)
626
+ throw new Error('gate-off');
627
+ if (o.browser && (!o.browser.executablePath.startsWith('/') || (o.browser.members !== 'all'
628
+ && (!Array.isArray(o.browser.members) || o.browser.members.some(m => !MEMBER_ID.test(m) || m.startsWith(KEY_PREFIX))))))
629
+ throw new Error('invalid browser options');
437
630
  // The engine lowercases and alias-maps a tool name before the gate hook sees it (bash -> exec, cron ->
438
631
  // automations); a name it would rewrite reaches the gate as a builtin, so refuse it here.
439
632
  for (const t of o.tools ?? []) {
440
- if (!/^[a-z][a-z0-9_]*$/.test(t.name) || t.name === 'bash' || t.name === 'cron')
633
+ if (!/^[a-z][a-z0-9_]*$/.test(t.name) || t.name === 'bash' || t.name === 'cron'
634
+ || (o.browser && ['browser', 'request_sign_in', 'exec', 'process', 'code_execution', 'terminal', 'read', 'write', 'edit', 'apply_patch', 'gateway'].includes(t.name)))
441
635
  throw new Error(`invalid tool name: ${t.name}`);
442
636
  }
443
637
  this.o = o;
444
- this.engine = new Engine({ ...o, pluginId: o.plugin?.id ?? 'byokit', tools: o.tools ?? [],
638
+ this.engine = new Engine({ ...o, pluginId: o.plugin?.id ?? 'byokit', tools: [...o.tools ?? [], ...(o.browser ? [{
639
+ name: 'request_sign_in', description: 'Ask the person to sign in to the current browser site.',
640
+ parameters: { type: 'object', additionalProperties: false, properties: {
641
+ note: { type: 'string', maxLength: 140 }, targetId: { type: 'string' }
642
+ } },
643
+ }] : [])],
644
+ ...(o.browser ? { browserConfig: () => this.browserConfig() } : {}),
445
645
  gateBuiltins: o.gateBuiltins !== false, spawnEngine: o.spawnEngine !== false,
446
646
  onState: (s) => this.setState(s), onExit: () => this.closed('engine exited') });
447
647
  this.keys = createKeys({ root: this.engine.root,
@@ -453,14 +653,46 @@ export class OpenClawKit {
453
653
  request: (method, params, co) => this.request()(method, params, co),
454
654
  bridge: { resolveAsk: (id, d) => slot.bridge?.resolveAsk(id, d) ?? false },
455
655
  });
456
- this.bridge = new Bridge({ path: this.engine.bridgeSock, host: o.host, tools: new Set((o.tools ?? []).map((t) => t.name)),
656
+ const host = o.browser ? {
657
+ gate: async (run, tool, input, info) => {
658
+ const refusal = await this.browserGate(run, tool);
659
+ if (refusal)
660
+ return { allow: false, reason: refusal };
661
+ const result = await o.host?.gate(run, tool, input, info) ?? { allow: false, reason: 'tool host unavailable' };
662
+ const changed = await this.browserGate(run, tool);
663
+ return changed ? { allow: false, reason: changed } : result;
664
+ },
665
+ call: async (run, tool, input, signal) => {
666
+ const refusal = await this.browserGate(run, tool);
667
+ if (refusal)
668
+ throw new Error(refusal);
669
+ if (!o.host)
670
+ throw new Error('tool host unavailable');
671
+ return o.host.call(run, tool, input, signal);
672
+ },
673
+ } : o.host;
674
+ this.bridge = new Bridge({ path: this.engine.bridgeSock, host, tools: new Set([...this.toolNames(), ...(o.browser ? ['request_sign_in'] : [])]),
675
+ ...(o.browser ? { browserCapabilities: () => [...this.browserCapabilities],
676
+ beforeAgentRun: async (key, runId) => {
677
+ // No action-id submission proof or redispatch claim. Open and unproven resumes are always refused.
678
+ if (!await this.checkBrowserTools() || !this.browserHost)
679
+ return false;
680
+ return browserSessionMayRun(this.browserHost.signIns(), key, this.bridge.isRegisteredRun(key, runId));
681
+ } } : {}),
457
682
  permitted: o.permitted ?? (() => true), approvalTimeoutMs: o.approvalTimeoutMs ?? 180_000,
458
683
  onAsk: (a) => this.approvalsCtl.add(a), onAskGone: (id) => this.approvalsCtl.remove(id) });
459
684
  slot.bridge = this.bridge;
460
685
  }
461
- setState(s) { this.current = s; this.o.onState?.(s); }
686
+ setState(s) {
687
+ this.current = { ...s, ...(this.engine.patchSet !== undefined ? { patchSet: this.engine.patchSet } : {}) };
688
+ this.o.onState?.(this.current);
689
+ }
462
690
  get state() { return this.current; }
463
- prepare() { return this.engine.prepare(); }
691
+ async prepare() {
692
+ await this.engine.prepare();
693
+ if (this.engine.patchSet !== undefined)
694
+ this.setState(this.current);
695
+ }
464
696
  start() {
465
697
  if (this.current.phase === 'ready')
466
698
  return Promise.resolve();
@@ -476,6 +708,7 @@ export class OpenClawKit {
476
708
  async connect() {
477
709
  let transport;
478
710
  try {
711
+ await this.prepareBrowsers();
479
712
  const ctx = await this.engine.start();
480
713
  if (!ctx || this.stopping)
481
714
  return;
@@ -504,18 +737,26 @@ export class OpenClawKit {
504
737
  this.off.push(transport.onEvent((e) => this.approvalsCtl.handleEvent(e)));
505
738
  this.failures = 0;
506
739
  this.setState({ phase: 'ready' });
740
+ if (this.o.browser)
741
+ await this.checkBrowserTools();
507
742
  // Replays the engine's native approval lists over the now-live transport (N9).
508
743
  await this.approvalsCtl.resync();
509
744
  }
510
745
  catch (error) {
511
746
  const needsUpdate = this.current.phase === 'needs-update';
747
+ const patchFailure = this.current.why === 'engine-patch';
512
748
  if (transport && this.transport === transport)
513
749
  await this.disconnect();
514
750
  await this.engine.stop();
751
+ await this.closeBrowsers();
515
752
  if (error instanceof Error && 'code' in error && error.code === 'engine-already-running') {
516
753
  this.setState({ phase: 'failed', why: 'engine-already-running' });
517
754
  throw error;
518
755
  }
756
+ if (patchFailure) {
757
+ this.setState({ phase: 'failed', why: 'engine-patch' });
758
+ throw error;
759
+ }
519
760
  if (needsUpdate)
520
761
  this.setState({ phase: 'needs-update', why: 'version' });
521
762
  if (!needsUpdate && !this.stopping)
@@ -529,6 +770,7 @@ export class OpenClawKit {
529
770
  this.transport = undefined;
530
771
  this.greeting = undefined;
531
772
  this.members = undefined;
773
+ this.browserPolicySessions.clear();
532
774
  this.bridge.stop();
533
775
  await transport?.stop();
534
776
  }
@@ -552,6 +794,20 @@ export class OpenClawKit {
552
794
  this.stopping = true;
553
795
  await this.disconnect();
554
796
  await this.engine.stop();
797
+ await this.closeBrowsers();
798
+ }
799
+ async closeBrowsers() {
800
+ if (this.browserHost)
801
+ await this.browserHost.close();
802
+ else
803
+ for (const broker of this.brokers.values())
804
+ await broker.close();
805
+ this.browserHost = undefined;
806
+ this.browser = undefined;
807
+ this.browserSafe = false;
808
+ this.brokers.clear();
809
+ for (const profile of Object.keys(this.browserProfiles))
810
+ delete this.browserProfiles[profile];
555
811
  }
556
812
  request() {
557
813
  if (!this.transport || this.current.phase !== 'ready')
@@ -559,6 +815,8 @@ export class OpenClawKit {
559
815
  return this.transport.request.bind(this.transport);
560
816
  }
561
817
  call(method, params, o) {
818
+ if (this.o.browser && /^(config\.(apply|patch|set)|agents\.(create|update|delete)|plugins\.(install|uninstall|setEnabled|refresh))$/.test(method))
819
+ return Promise.reject(new Error('browser policy changes require guarded patchConfig'));
562
820
  try {
563
821
  return this.request()(method, params, o);
564
822
  }
@@ -585,10 +843,28 @@ export class OpenClawKit {
585
843
  this.listeners.add(listener);
586
844
  return () => { this.listeners.delete(listener); };
587
845
  }
588
- ensureMember(member) {
846
+ async ensureMember(member) {
589
847
  if (!this.members)
590
- return Promise.reject(new Error('gateway not ready'));
591
- return this.members.ensure(member);
848
+ throw new Error('gateway not ready');
849
+ const result = await this.members.ensure(member);
850
+ if (!this.o.browser || !this.browserMember(member) || this.brokers.has(member) || !this.browserHost)
851
+ return result;
852
+ let attaching = this.browserAttaching.get(member);
853
+ if (!attaching) {
854
+ attaching = (async () => {
855
+ if (!await this.checkBrowserTools())
856
+ return;
857
+ const broker = await this.launchBrowser(member);
858
+ await this.browserHost.attachBroker(member, broker);
859
+ this.brokers.set(member, broker);
860
+ await broker.fence(true);
861
+ await this.publishBrowserProfile(member);
862
+ })();
863
+ this.browserAttaching.set(member, attaching);
864
+ void attaching.finally(() => { this.browserAttaching.delete(member); }).catch(() => { });
865
+ }
866
+ await attaching;
867
+ return result;
592
868
  }
593
869
  // sign-in (5.7): every body here is the module's, with this kit's transport, members and ports.
594
870
  signInCtx() {
@@ -634,7 +910,7 @@ export class OpenClawKit {
634
910
  };
635
911
  },
636
912
  ensure: (member) => this.ensureMember(member),
637
- bridge: this.bridge,
913
+ bridge: { register: (run, tools, runId) => this.bridge.register(run, tools && this.o.browser ? [...tools, 'request_sign_in'] : tools, runId) },
638
914
  tools: new Set(this.toolNames()),
639
915
  });
640
916
  }
@@ -642,7 +918,13 @@ export class OpenClawKit {
642
918
  toolNames() {
643
919
  return (this.o.tools ?? []).map((t) => t.name);
644
920
  }
645
- run(spec, on) {
921
+ async run(spec, on) {
922
+ if (this.o.browser) {
923
+ if (!await this.checkBrowserTools())
924
+ return { ok: false, kind: 'other', message: 'browser tool policy refused' };
925
+ if (!await this.browserHost?.beforeRun(spec.member, this.runKey(spec.sessionKey, spec)))
926
+ return { ok: false, kind: 'other', message: 'Waiting for the person to sign in' };
927
+ }
646
928
  if (spec.auth !== undefined && spec.auth !== 'apiKey')
647
929
  return Promise.reject(new Error('Choose a supported account option.'));
648
930
  // Snapshot before account readiness awaits; invalid schemas never reach the Gateway.
@@ -703,12 +985,37 @@ export class OpenClawKit {
703
985
  const safe = reconcileConfig(response.config, { root: this.engine.root, stateDir: this.o.stateDir,
704
986
  port: Number(readFileSync(join(this.engine.root, 'port'), 'utf8')), pluginId: this.o.plugin?.id ?? 'byokit',
705
987
  pluginDir: join(this.engine.root, 'plugin'), policyPath: fileURLToPath(new URL('../policy/policy.mjs', import.meta.url)), app: patch,
706
- installPolicy: this.o.installPolicy });
988
+ installPolicy: this.o.installPolicy, ...(this.o.browser ? { browser: this.browserConfig() } : {}) });
989
+ if (this.o.browser && !browserToolPolicySafe(safe, this.toolNames()))
990
+ throw new Error('browser tool policy refused');
991
+ if (this.o.browser) {
992
+ this.browserSafe = false;
993
+ for (const broker of this.brokers.values())
994
+ await broker.fence(true);
995
+ }
707
996
  await this.request()('config.patch', { raw: JSON.stringify(safe), baseHash: response.hash, ...(o?.agentId ? { agentId: o.agentId } : {}) });
708
997
  }
709
998
  memoryLimited(member) {
710
999
  const config = JSON.parse(readFileSync(join(this.engine.root, 'openclaw.json'), 'utf8'));
711
1000
  return configMemoryLimited(config, member);
712
1001
  }
1002
+ // One key, not the whole config: stock config.get redacts token-bearing values and needs a gateway round
1003
+ // trip, so it can never hand back a value the app may write unchanged. These read and write that one
1004
+ // dotted key in the file prepare() owns; the engine applies it at its next boot.
1005
+ getConfigKey(key) {
1006
+ const config = JSON.parse(readFileSync(join(this.engine.root, 'openclaw.json'), 'utf8'));
1007
+ return readConfigKey(config, configKeyPath(key));
1008
+ }
1009
+ setConfigKey(key, value) {
1010
+ const path = join(this.engine.root, 'openclaw.json');
1011
+ const text = readFileSync(path, 'utf8');
1012
+ const config = JSON.parse(text);
1013
+ const previous = writeConfigKey(config, configKeyPath(key), value);
1014
+ // prepare() writes exactly this shape, so an unchanged value leaves the file byte for byte alone.
1015
+ const next = JSON.stringify(config, null, 2) + '\n';
1016
+ if (next !== text)
1017
+ atomic(path, next);
1018
+ return previous;
1019
+ }
713
1020
  doctorContext() { return this.engine.doctorContext(); }
714
1021
  }
package/dist/link.d.ts CHANGED
@@ -1,14 +1,17 @@
1
+ import type { BrowserHost } from './browser.ts';
1
2
  import { type IncomingMessage, type ServerResponse } from 'node:http';
2
3
  import { type Grant, type Host, type HostOptions } from '@byokit/link';
3
4
  import { type ServeIngress, type Via } from '@byokit/reach';
4
5
  import type { PushAction, RelayClient } from '@byokit/relay';
5
6
  import type { OpenClawKit } from './kit.ts';
6
7
  import type { Member } from './types.ts';
7
- export declare function openclawLink(kit: OpenClawKit, o: {
8
+ export declare function openclawLink(kit: OpenClawKit & {
9
+ browser?: BrowserHost;
10
+ }, o: {
8
11
  memberOf: (grant: Grant) => Member | undefined;
9
12
  passThrough?: (method: string, grant: Grant) => boolean;
10
13
  relay?: RelayClient;
11
- }): Pick<HostOptions, 'handle' | 'stream' | 'allow'> & {
14
+ }): Pick<HostOptions, 'handle' | 'stream' | 'allow' | 'onGrantRemoved'> & {
12
15
  onAction(a: PushAction): Promise<unknown>;
13
16
  };
14
17
  export declare function serve(o: {