@evomap/evolver-proxy 2.0.0-beta.13 → 2.0.0-beta.15

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.
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { mailbox } from '@evomap/evolver-core';
3
+ import { PrivateNodeCredentialStore } from '../private/nodeCredentialStore.js';
3
4
  import { loadEnvFileFromEnv } from './envFile.js';
4
5
  import { type SelfUpdatePolicy } from '../selfUpdate/policy.js';
5
6
  import { type ReleaseBinaryOptions } from '../selfUpdate/releaseBinary.js';
@@ -11,8 +12,9 @@ import type { AtpProxyClient, ProxyDaemonDeps, ProxyTickReport } from '../daemon
11
12
  import type { HelloLifecycleMode, HelloResult, HeartbeatOptions, HeartbeatResult } from '../lifecycle/manager.js';
12
13
  import type { InboundResult } from '../sync/engine.js';
13
14
  /** evolver-proxy 系统级 daemon 入口(M6-7). EVOMAP_HUB_MODE/URL/NODE_SECRET 选址. */
14
- interface RunProxyMainOptions {
15
+ export interface RunProxyMainOptions {
15
16
  environmentPrepared?: boolean;
17
+ recoveryPrepared?: SelfUpdateRecoveryResult;
16
18
  }
17
19
  export declare function runProxyMain(options?: RunProxyMainOptions): Promise<void>;
18
20
  export declare function recoverBoundDurableSelfUpdate(options: Omit<SelfUpdateRecoveryOptions, 'beforeJournalMutation'>): Promise<SelfUpdateRecoveryResult>;
@@ -45,7 +47,8 @@ export interface RunProxyCliOptions {
45
47
  env?: NodeJS.ProcessEnv;
46
48
  platform?: NodeJS.Platform;
47
49
  processExecPath?: string;
48
- runMain?: () => Promise<void>;
50
+ runMain?: (options?: RunProxyMainOptions) => Promise<void>;
51
+ recoverStartup?: typeof recoverBoundDurableSelfUpdate;
49
52
  runUnixRecoveryController?: typeof maybeRunUnixRecoveryController;
50
53
  runWindowsRecoveryController?: typeof maybeRunWindowsRecoveryController;
51
54
  runWindowsUpdaterWorker?: typeof maybeRunWindowsUpdaterWorkerFromArgv;
@@ -73,14 +76,22 @@ export interface RuntimeDeps {
73
76
  env?: Record<string, string | undefined>;
74
77
  now?: () => number;
75
78
  privateImporter?: PrivateAdapterImporter;
79
+ privateNodeCredentialStore?: Pick<PrivateNodeCredentialStore, 'read' | 'write'>;
76
80
  }
77
81
  export type PublicNodeSecretSource = 'env' | 'store' | 'hub_rotate' | 'legacy_file';
78
82
  export interface PublicNodeSecretSelection {
79
83
  nodeSecret: string | undefined;
84
+ nodeId?: string;
80
85
  nodeSecretVersion?: number;
81
86
  source: PublicNodeSecretSource;
82
87
  storeSecret?: string;
83
88
  }
89
+ export interface VerifiedPublicSender {
90
+ senderId: () => string | undefined;
91
+ adopt: (nodeId: string) => void;
92
+ }
93
+ export declare function createVerifiedPublicSender(initialNodeId?: string): VerifiedPublicSender;
94
+ export declare function adoptVerifiedPublicNodeId(store: mailbox.MailboxStore, selection: PublicNodeSecretSelection, sender: VerifiedPublicSender, nodeId: string): void;
84
95
  export interface HubRuntime {
85
96
  hub: ProxyDaemonDeps['hub'];
86
97
  hello: (opts: {
@@ -113,6 +124,7 @@ export declare function runProxyLoop(daemon: ProxyLoopDaemon, options?: ProxyLoo
113
124
  interface CreateProxyDaemonDepsOptions {
114
125
  runtime: HubRuntime;
115
126
  store: mailbox.MailboxStore;
127
+ hubMode?: 'public' | 'private';
116
128
  ipcToken: string;
117
129
  ipcPort?: number;
118
130
  evolverVersion: string;
@@ -139,6 +151,7 @@ export declare function resolvePublicNodeSecret(deps: RuntimeDeps): PublicNodeSe
139
151
  */
140
152
  export declare function clearDivergedPublicNodeSecret(store: mailbox.MailboxStore, env?: NodeJS.ProcessEnv): void;
141
153
  export declare function persistSelectedPublicNodeSecret(store: mailbox.MailboxStore, selection: PublicNodeSecretSelection): void;
154
+ export declare function persistRotatedPublicNodeCredentials(store: mailbox.MailboxStore, selection: PublicNodeSecretSelection, secret: string, version: number | undefined): void;
142
155
  export declare function persistPublicNodeSecretVersion(store: mailbox.MailboxStore, selection: PublicNodeSecretSelection, version: number | undefined): void;
143
156
  export declare function connectHubRuntime(deps: RuntimeDeps): Promise<HubRuntime>;
144
157
  export declare function resolveLegacyNodeSecret(envNodeSecret: string | undefined, storedNodeSecret: string | undefined, storedSource: string | undefined): string | undefined;
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { randomBytes } from 'node:crypto';
2
+ import { createHash, randomBytes } from 'node:crypto';
3
3
  import { readFileSync, realpathSync, rmSync } from 'node:fs';
4
4
  import { homedir } from 'node:os';
5
5
  import { join, resolve, win32 } from 'node:path';
@@ -10,12 +10,13 @@ import { ProxyDaemon } from '../daemon/proxyDaemon.js';
10
10
  import { resolveHubMode, resolveHubUrl } from '../daemon/selectHub.js';
11
11
  import { resolveIpcPort } from '../daemon/ipcConfig.js';
12
12
  import { traceCollectionEnabled } from '../llm/traceConfig.js';
13
- import { connectPrivateProxyHub } from '../private/adapterLoader.js';
13
+ import { connectPrivateProxyHub, resolvePrivateEnterpriseToken, resolvePrivateInvitationToken, resolvePrivateNodeSecret, } from '../private/adapterLoader.js';
14
+ import { PrivateNodeCredentialStore, PrivateNodeCredentialReadError, } from '../private/nodeCredentialStore.js';
14
15
  import { createAtpOrderConsentGate } from '../daemon/atpConsent.js';
15
16
  import { resolveProxyStorePath } from './proxyStorePath.js';
16
17
  import { publishProxySettings } from './proxySettings.js';
17
18
  import { expandHomePath, loadEnvFileFromEnv } from './envFile.js';
18
- import { resolveProxyNodeId } from '../lifecycle/legacyNodeId.js';
19
+ import { readLegacyNodeId, resolveProxyNodeId } from '../lifecycle/legacyNodeId.js';
19
20
  import { getCurrentVersion } from '../selfUpdate/version.js';
20
21
  import { resolveSelfUpdatePolicy } from '../selfUpdate/policy.js';
21
22
  import { atomicReplaceExecutable, downloadGithubReleaseArtifact, resolveGithubReleaseManifest, resolveSelfUpdateTarget, } from '../selfUpdate/releaseBinary.js';
@@ -30,19 +31,23 @@ export async function runProxyMain(options = {}) {
30
31
  process.stdout.write(proxyUsage());
31
32
  return;
32
33
  }
33
- if (!options.environmentPrepared) {
34
- const envFile = loadProxyEnvFile(process.env);
35
- if (envFile.error)
36
- process.stderr.write(`[evolver-proxy] failed to load EVOLVER_ENV_FILE: ${safeLoopMessage(envFile.error)}\n`);
37
- }
38
34
  // Recovery must run before any hub/store/runtime initialization. In particular,
39
- // a broken store must not prevent a pending-health update from restoring the old binary.
40
- const recovery = await recoverBoundDurableSelfUpdate({ env: process.env, processExecPath: process.execPath });
35
+ // a broken store or env-file pointer must not prevent a pending-health update from restoring the old binary.
36
+ const recovery = options.recoveryPrepared
37
+ ?? await recoverBoundDurableSelfUpdate({
38
+ env: proxyRecoveryEnvironment(process.env),
39
+ processExecPath: process.execPath,
40
+ });
41
41
  if (recovery.outcome === 'blocked') {
42
42
  throw new Error(`self_update_recovery_blocked:${recovery.failureCode ?? 'unknown'}`);
43
43
  }
44
44
  if (recovery.restartRequired)
45
45
  process.exit(78);
46
+ if (!options.environmentPrepared) {
47
+ const envFile = loadProxyEnvFile(process.env);
48
+ if (envFile.error)
49
+ throw new Error(`failed to load EVOLVER_ENV_FILE: ${safeLoopMessage(envFile.error)}`);
50
+ }
46
51
  let storePath;
47
52
  let store;
48
53
  let proxyDaemon;
@@ -92,11 +97,21 @@ export async function runProxyMain(options = {}) {
92
97
  },
93
98
  });
94
99
  };
95
- const runtime = await connectHubRuntime({ mode, hubUrl, senderId, store });
100
+ const privateNodeCredentialStore = mode === 'private'
101
+ ? new PrivateNodeCredentialStore(storePath)
102
+ : undefined;
103
+ const runtime = await connectHubRuntime({
104
+ mode,
105
+ hubUrl,
106
+ senderId,
107
+ store,
108
+ ...(privateNodeCredentialStore ? { privateNodeCredentialStore } : {}),
109
+ });
96
110
  proxyDaemon = new ProxyDaemon({
97
111
  ...createProxyDaemonDeps({
98
112
  runtime,
99
113
  store,
114
+ hubMode: mode,
100
115
  ipcToken,
101
116
  ...(ipcPort !== undefined ? { ipcPort } : {}),
102
117
  evolverVersion,
@@ -152,7 +167,17 @@ export function loadProxyEnvFile(env) {
152
167
  const supervisor = env['EVOLVER_SELF_UPDATE_SUPERVISOR'];
153
168
  const stateDir = env['EVOLVER_SELF_UPDATE_STATE_DIR'];
154
169
  const targetPath = env['EVOLVER_SELF_UPDATE_TARGET_PATH'];
170
+ const systemRootBindings = Object.entries(env)
171
+ .filter(([key]) => key.toLowerCase() === 'systemroot');
155
172
  const result = loadEnvFileFromEnv(env);
173
+ for (const key of Object.keys(env)) {
174
+ if (key.toLowerCase() === 'systemroot')
175
+ delete env[key];
176
+ }
177
+ for (const [key, value] of systemRootBindings) {
178
+ if (value !== undefined)
179
+ env[key] = value;
180
+ }
156
181
  if (supervisor === undefined) {
157
182
  delete env['EVOLVER_SELF_UPDATE_SUPERVISOR'];
158
183
  }
@@ -165,6 +190,11 @@ export function loadProxyEnvFile(env) {
165
190
  }
166
191
  return result;
167
192
  }
193
+ function proxyRecoveryEnvironment(env) {
194
+ const recoveryEnv = { ...env };
195
+ loadProxyEnvFile(recoveryEnv);
196
+ return recoveryEnv;
197
+ }
168
198
  function finalizeRecoveryTelemetry(store, recovery) {
169
199
  try {
170
200
  finalizeSelfUpdateRecoveryLastUpdate(store, recovery);
@@ -302,6 +332,10 @@ export function prepareProxyCliEnvironment(argv, env) {
302
332
  if (options.envFile)
303
333
  env['EVOLVER_ENV_FILE'] = options.envFile;
304
334
  const envFile = loadProxyEnvFile(env);
335
+ applyProxyCliPathOptions(options, env);
336
+ return { options, envFile };
337
+ }
338
+ function applyProxyCliPathOptions(options, env) {
305
339
  if (options.home) {
306
340
  env['EVOMAP_DIR'] = options.home;
307
341
  env['EVOLVER_HOME'] = options.home;
@@ -320,7 +354,6 @@ export function prepareProxyCliEnvironment(argv, env) {
320
354
  env['EVOLVER_PROXY_STORE'] = options.store;
321
355
  if (options.settings)
322
356
  env['EVOLVER_PROXY_SETTINGS_FILE'] = options.settings;
323
- return { options, envFile };
324
357
  }
325
358
  export async function runProxyCli(options = {}) {
326
359
  const argv = options.argv ?? process.argv.slice(2);
@@ -356,11 +389,28 @@ export async function runProxyCli(options = {}) {
356
389
  process.stdout.write(proxyUsage(argv[0] === 'proxy' ? 'evolver proxy' : 'evolver-proxy'));
357
390
  return 0;
358
391
  }
392
+ if (cliOptions.envFile)
393
+ env['EVOLVER_ENV_FILE'] = cliOptions.envFile;
394
+ applyProxyCliPathOptions(cliOptions, env);
395
+ const recovery = options.recoverStartup || !options.runMain
396
+ ? await (options.recoverStartup ?? recoverBoundDurableSelfUpdate)({
397
+ env: proxyRecoveryEnvironment(env),
398
+ processExecPath: options.processExecPath ?? process.execPath,
399
+ })
400
+ : undefined;
401
+ if (recovery?.outcome === 'blocked') {
402
+ throw new Error(`self_update_recovery_blocked:${recovery.failureCode ?? 'unknown'}`);
403
+ }
404
+ if (recovery?.restartRequired)
405
+ return 78;
359
406
  const prepared = prepareProxyCliEnvironment(argv, env);
360
407
  if (prepared.envFile.error) {
361
- process.stderr.write(`[evolver-proxy] failed to load EVOLVER_ENV_FILE: ${safeLoopMessage(prepared.envFile.error)}\n`);
408
+ throw new Error(`failed to load EVOLVER_ENV_FILE: ${safeLoopMessage(prepared.envFile.error)}`);
362
409
  }
363
- await (options.runMain ?? (() => runProxyMain({ environmentPrepared: true })))();
410
+ await (options.runMain ?? runProxyMain)({
411
+ environmentPrepared: true,
412
+ ...(recovery ? { recoveryPrepared: recovery } : {}),
413
+ });
364
414
  return 0;
365
415
  }
366
416
  catch (error) {
@@ -376,6 +426,20 @@ if (isDirectRun(import.meta.url, process.argv[1])) {
376
426
  process.exitCode = exitCode;
377
427
  });
378
428
  }
429
+ export function createVerifiedPublicSender(initialNodeId) {
430
+ let verifiedNodeId = initialNodeId;
431
+ return {
432
+ senderId: () => verifiedNodeId,
433
+ adopt: (nodeId) => {
434
+ verifiedNodeId = nodeId;
435
+ },
436
+ };
437
+ }
438
+ export function adoptVerifiedPublicNodeId(store, selection, sender, nodeId) {
439
+ store.setState('node_id', nodeId);
440
+ selection.nodeId = nodeId;
441
+ sender.adopt(nodeId);
442
+ }
379
443
  export async function runProxyLoop(daemon, options = {}) {
380
444
  const minDelayMs = options.minDelayMs ?? 1_000;
381
445
  const errorDelayMs = options.errorDelayMs ?? 5_000;
@@ -451,6 +515,7 @@ export function createProxyDaemonDeps(options) {
451
515
  const traceBackfill = resolveTraceBackfillConfig(options.env ?? process.env);
452
516
  return {
453
517
  hub: options.runtime.hub,
518
+ ...(options.hubMode ? { hubMode: options.hubMode } : {}),
454
519
  store: options.store,
455
520
  ipcToken: options.ipcToken,
456
521
  ...(options.ipcPort !== undefined ? { ipcPort: options.ipcPort } : {}),
@@ -675,29 +740,80 @@ function selfUpdateSupervisorAttested(env) {
675
740
  || supervisor === 'windows-scheduled-task';
676
741
  }
677
742
  export function resolvePublicNodeSecret(deps) {
678
- const envNodeSecret = process.env['EVOMAP_NODE_SECRET'] ?? process.env['A2A_NODE_SECRET'];
679
- // version env precedence MUST mirror the node_secret precedence above (EVOMAP-first) so an operator
680
- // who sets both env pairs always resolves a matched (secret, version). v2 standardizes on EVOMAP_*-first
681
- // for BOTH secret and version; v1 uses A2A_*-first but pairs the two identically. Do not flip one alone.
682
- const envNodeSecretVersion = parseNodeSecretVersion(process.env['EVOMAP_NODE_SECRET_VERSION'] ?? process.env['A2A_NODE_SECRET_VERSION']);
743
+ const explicit = resolveExplicitPublicNodeCredentials(process.env);
683
744
  const storedNodeSecret = deps.store.getState('node_secret');
745
+ const storedNodeId = deps.store.getState('node_id')?.trim() || undefined;
684
746
  const storedSource = deps.store.getState('node_secret_source');
685
747
  const storedNodeSecretVersion = parseNodeSecretVersion(deps.store.getState('node_secret_version'));
686
- const storeSecret = storedNodeSecret && isNodeSecret(storedNodeSecret) ? storedNodeSecret : undefined;
687
- if (storedSource === 'hub_rotate' && storeSecret) {
688
- return { nodeSecret: storeSecret, nodeSecretVersion: storedNodeSecretVersion, source: 'hub_rotate', storeSecret };
748
+ const storeSecret = storedSource?.startsWith('pending_')
749
+ ? undefined
750
+ : storedNodeSecret && isNodeSecret(storedNodeSecret) ? storedNodeSecret : undefined;
751
+ const legacy = readLegacyNodeSecret(process.env);
752
+ const pairedStoreNodeId = storedNodeId
753
+ ?? (explicit.nodeSecret === storeSecret ? explicit.nodeId : undefined)
754
+ ?? (legacy && legacy.nodeSecret === storeSecret ? legacy.nodeId : undefined);
755
+ const completeExplicitOverridesOrphan = Boolean(explicit.nodeId
756
+ && explicit.nodeSecret
757
+ && explicit.nodeSecret !== storeSecret
758
+ && pairedStoreNodeId !== explicit.nodeId);
759
+ if (storedSource === 'hub_rotate' && storeSecret && !completeExplicitOverridesOrphan) {
760
+ return {
761
+ nodeSecret: storeSecret,
762
+ ...(pairedStoreNodeId ? { nodeId: pairedStoreNodeId } : {}),
763
+ nodeSecretVersion: storedNodeSecretVersion,
764
+ source: 'hub_rotate',
765
+ storeSecret,
766
+ };
689
767
  }
690
- if (envNodeSecret) {
691
- const pairedStoreVersion = envNodeSecret === storeSecret ? storedNodeSecretVersion : undefined;
692
- return { nodeSecret: envNodeSecret, nodeSecretVersion: envNodeSecretVersion ?? pairedStoreVersion, source: 'env', storeSecret };
768
+ if (explicit.nodeSecret) {
769
+ const pairedNodeId = explicit.nodeId
770
+ ?? (legacy?.nodeSecret === explicit.nodeSecret ? legacy.nodeId : undefined);
771
+ const pairedStoreVersion = explicit.nodeSecret === storeSecret ? storedNodeSecretVersion : undefined;
772
+ return {
773
+ nodeSecret: explicit.nodeSecret,
774
+ ...(pairedNodeId ? { nodeId: pairedNodeId } : {}),
775
+ nodeSecretVersion: explicit.nodeSecretVersion ?? pairedStoreVersion,
776
+ source: 'env',
777
+ storeSecret,
778
+ };
779
+ }
780
+ if (storeSecret) {
781
+ return {
782
+ nodeSecret: storeSecret,
783
+ ...(pairedStoreNodeId ? { nodeId: pairedStoreNodeId } : {}),
784
+ nodeSecretVersion: storedNodeSecretVersion,
785
+ source: 'store',
786
+ storeSecret,
787
+ };
693
788
  }
694
- if (storeSecret)
695
- return { nodeSecret: storeSecret, nodeSecretVersion: storedNodeSecretVersion, source: 'store', storeSecret };
696
- const legacy = readLegacyNodeSecret(process.env);
697
789
  if (legacy)
698
790
  return { ...legacy, source: 'legacy_file' };
699
791
  return { nodeSecret: undefined, source: 'store' };
700
792
  }
793
+ function resolveExplicitPublicNodeCredentials(env) {
794
+ const evomap = publicCredentialNamespace(env, 'EVOMAP');
795
+ const a2a = publicCredentialNamespace(env, 'A2A');
796
+ if (evomap.nodeId && evomap.nodeSecret)
797
+ return evomap;
798
+ if (a2a.nodeId && a2a.nodeSecret)
799
+ return a2a;
800
+ if (evomap.nodeId && a2a.nodeId && evomap.nodeId === a2a.nodeId) {
801
+ return evomap.nodeSecret ? evomap : a2a.nodeSecret ? a2a : {};
802
+ }
803
+ if (evomap.nodeId || a2a.nodeId)
804
+ return {};
805
+ return evomap.nodeSecret ? evomap : a2a.nodeSecret ? a2a : {};
806
+ }
807
+ function publicCredentialNamespace(env, prefix) {
808
+ const nodeId = env[`${prefix}_NODE_ID`]?.trim() || undefined;
809
+ const nodeSecret = env[`${prefix}_NODE_SECRET`]?.trim() || undefined;
810
+ const nodeSecretVersion = parseNodeSecretVersion(env[`${prefix}_NODE_SECRET_VERSION`]);
811
+ return {
812
+ ...(nodeId ? { nodeId } : {}),
813
+ ...(nodeSecret ? { nodeSecret } : {}),
814
+ ...(nodeSecretVersion !== undefined ? { nodeSecretVersion } : {}),
815
+ };
816
+ }
701
817
  function setOptionalStoreState(store, key, value) {
702
818
  store.setState(key, value ?? '');
703
819
  }
@@ -732,8 +848,13 @@ function readLegacyNodeSecret(env = process.env) {
732
848
  const nodeSecret = readTrimmedFile(join(home, 'node_secret'));
733
849
  if (!nodeSecret || !isNodeSecret(nodeSecret))
734
850
  continue;
851
+ const nodeId = readLegacyNodeId({ candidates: [join(home, 'node_id')] });
735
852
  const nodeSecretVersion = parseNodeSecretVersion(readTrimmedFile(join(home, 'node_secret_version')));
736
- return { nodeSecret, ...(nodeSecretVersion !== undefined ? { nodeSecretVersion } : {}) };
853
+ return {
854
+ ...(nodeId ? { nodeId } : {}),
855
+ nodeSecret,
856
+ ...(nodeSecretVersion !== undefined ? { nodeSecretVersion } : {}),
857
+ };
737
858
  }
738
859
  return undefined;
739
860
  }
@@ -768,9 +889,20 @@ export function clearDivergedPublicNodeSecret(store, env = process.env) {
768
889
  export function persistSelectedPublicNodeSecret(store, selection) {
769
890
  if (selection.source !== 'legacy_file' || !selection.nodeSecret)
770
891
  return;
892
+ store.setState('node_secret_source', 'pending_legacy');
893
+ if (selection.nodeId)
894
+ store.setState('node_id', selection.nodeId);
771
895
  store.setState('node_secret', selection.nodeSecret);
772
- store.setState('node_secret_source', 'legacy_file');
773
896
  setOptionalStoreState(store, 'node_secret_version', selection.nodeSecretVersion !== undefined ? String(selection.nodeSecretVersion) : undefined);
897
+ store.setState('node_secret_source', 'legacy_file');
898
+ }
899
+ export function persistRotatedPublicNodeCredentials(store, selection, secret, version) {
900
+ store.setState('node_secret_source', 'pending_rotate');
901
+ if (selection.nodeId)
902
+ store.setState('node_id', selection.nodeId);
903
+ store.setState('node_secret', secret);
904
+ setOptionalStoreState(store, 'node_secret_version', version !== undefined ? String(version) : undefined);
905
+ store.setState('node_secret_source', 'hub_rotate');
774
906
  }
775
907
  export function persistPublicNodeSecretVersion(store, selection, version) {
776
908
  const currentStoreSecret = store.getState('node_secret');
@@ -801,10 +933,33 @@ function isDirectRun(metaUrl, argv1) {
801
933
  }
802
934
  export async function connectHubRuntime(deps) {
803
935
  if (deps.mode === 'private') {
936
+ const storedInvitationFingerprint = deps.store.getState('private_invitation_fingerprint')?.trim();
937
+ const runtimeEnv = deps.env ?? process.env;
938
+ let storedNodeSecret;
939
+ try {
940
+ storedNodeSecret = deps.privateNodeCredentialStore?.read();
941
+ }
942
+ catch (error) {
943
+ if (!(error instanceof PrivateNodeCredentialReadError)
944
+ || !hasPrivateEnrollmentFallback(runtimeEnv, storedInvitationFingerprint)) {
945
+ throw error;
946
+ }
947
+ }
804
948
  const runtime = await connectPrivateProxyHub({
805
949
  hubUrl: deps.hubUrl,
806
950
  senderId: deps.senderId,
807
- env: deps.env ?? process.env,
951
+ env: runtimeEnv,
952
+ ...(storedNodeSecret ? { storedNodeSecret } : {}),
953
+ ...(storedInvitationFingerprint ? { storedInvitationFingerprint } : {}),
954
+ ...(deps.privateNodeCredentialStore ? {
955
+ onNodeSecretAdopted: (nodeSecret) => {
956
+ deps.privateNodeCredentialStore?.write(nodeSecret);
957
+ deps.store.setState('private_node_secret_source', 'hub_enrollment');
958
+ },
959
+ } : {}),
960
+ onInvitationRedeemed: (fingerprint) => {
961
+ deps.store.setState('private_invitation_fingerprint', fingerprint);
962
+ },
808
963
  ...(deps.now ? { now: deps.now } : {}),
809
964
  ...(deps.privateImporter ? { importer: deps.privateImporter } : {}),
810
965
  });
@@ -820,17 +975,17 @@ export async function connectHubRuntime(deps) {
820
975
  if (!nodeSecret)
821
976
  throw new Error('public legacy 模式需 EVOMAP_NODE_SECRET');
822
977
  persistSelectedPublicNodeSecret(deps.store, selection);
978
+ const verifiedSender = createVerifiedPublicSender(selection.nodeId);
979
+ const senderId = verifiedSender.senderId;
823
980
  const { hub, auth } = connectPublicHub({
824
981
  hubUrl: deps.hubUrl,
825
982
  authMode: 'legacy',
826
983
  nodeSecret,
827
984
  ...(nodeSecretVersion !== undefined ? { nodeSecretVersion } : {}),
828
- senderId: deps.senderId,
985
+ senderId,
829
986
  antiAbuse: { source: 'evolver-proxy', proxyPortConfigured: true },
830
987
  onNodeSecretRotated: (secret, version) => {
831
- deps.store.setState('node_secret', secret);
832
- deps.store.setState('node_secret_source', 'hub_rotate');
833
- setOptionalStoreState(deps.store, 'node_secret_version', version !== undefined ? String(version) : undefined);
988
+ persistRotatedPublicNodeCredentials(deps.store, selection, secret, version);
834
989
  },
835
990
  onNodeSecretVersionUpdated: (version) => {
836
991
  persistPublicNodeSecretVersion(deps.store, selection, version);
@@ -843,11 +998,25 @@ export async function connectHubRuntime(deps) {
843
998
  });
844
999
  return {
845
1000
  hub,
846
- atp: new AtpHubClient({ baseUrl: deps.hubUrl, auth, fetchFn: globalFetchLike, senderId: deps.senderId }),
847
- hello: (opts) => hub.hello(opts),
1001
+ atp: new AtpHubClient({ baseUrl: deps.hubUrl, auth, fetchFn: globalFetchLike, senderId }),
1002
+ hello: async (opts) => {
1003
+ const result = await hub.hello(opts);
1004
+ if (result.nodeId) {
1005
+ adoptVerifiedPublicNodeId(deps.store, selection, verifiedSender, result.nodeId);
1006
+ }
1007
+ return result;
1008
+ },
848
1009
  heartbeat: (opts) => hub.heartbeat(opts),
849
1010
  };
850
1011
  }
1012
+ function hasPrivateEnrollmentFallback(env, storedInvitationFingerprint) {
1013
+ if (resolvePrivateNodeSecret(env) || resolvePrivateEnterpriseToken(env))
1014
+ return true;
1015
+ const invitationToken = resolvePrivateInvitationToken(env);
1016
+ if (!invitationToken)
1017
+ return false;
1018
+ return createHash('sha256').update(invitationToken).digest('hex') !== storedInvitationFingerprint;
1019
+ }
851
1020
  export function resolveLegacyNodeSecret(envNodeSecret, storedNodeSecret, storedSource) {
852
1021
  if (envNodeSecret)
853
1022
  return envNodeSecret;
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
2
3
  import { join } from 'node:path';
3
- import { events } from '@evomap/evolver-core';
4
4
  export class AtpProxySpendConsentError extends Error {
5
5
  source;
6
6
  constructor(source) {
@@ -10,7 +10,10 @@ export class AtpProxySpendConsentError extends Error {
10
10
  }
11
11
  }
12
12
  function atpProxyConsentPath(env = process.env) {
13
- const home = env['EVOMAP_DIR'] ?? env['EVOLVER_HOME'] ?? env['EVOMAP_HOME'] ?? events.evomapHome();
13
+ const home = [env['EVOMAP_DIR'], env['EVOLVER_HOME'], env['EVOMAP_HOME']]
14
+ .map((value) => value?.trim())
15
+ .find((value) => Boolean(value))
16
+ ?? join(homedir(), '.evomap');
14
17
  return join(home, 'evolution', 'atp-autobuy-ack.json');
15
18
  }
16
19
  export function getAtpProxyConsent(env = process.env, ackPath = atpProxyConsentPath(env)) {
@@ -8,6 +8,8 @@ type AssetStoreProvider = assetstore.AssetStoreProvider;
8
8
  export declare const DEFAULT_IPC_PORT = 19820;
9
9
  export interface ProxyDaemonDeps {
10
10
  hub: HubCapability;
11
+ /** Immutable Hub selection for IPC callers that must fail closed across public/private runtimes. */
12
+ hubMode?: 'public' | 'private';
11
13
  /** 二选一: 传 storePath 让 ProxyDaemon 建 store, 或传已建 store(供 hub senderId 共享同一 node_id). */
12
14
  storePath?: string;
13
15
  store?: mailbox.MailboxStore;
@@ -585,6 +585,11 @@ export class ProxyDaemon {
585
585
  return Number.isFinite(n) && n > 0 ? n : null;
586
586
  }
587
587
  async handleProxyRoute(ctx) {
588
+ const expectedHeader = singleHeader(ctx.req.headers['x-evomap-expected-hub-mode']);
589
+ if (hubModeMismatch(expectedHeader, this.deps.hubMode)) {
590
+ ctx.json(409, { error: 'proxy_hub_mode_mismatch' });
591
+ return true;
592
+ }
588
593
  if (await this.collaborationFacade.handle(ctx))
589
594
  return true;
590
595
  const handledAtp = await this.handleAtpRoute(ctx);
@@ -593,6 +598,7 @@ export class ProxyDaemon {
593
598
  if (ctx.route === 'GET /proxy/status') {
594
599
  ctx.json(200, {
595
600
  running: true,
601
+ hub_mode: this.deps.hubMode ?? 'public',
596
602
  node_id: this.lifecycle.nodeId ?? null,
597
603
  outbound_pending: this.store.countPending('proxy', this.deps.runtimeNamespace),
598
604
  inbound_pending: this.store.countPending('agent', this.deps.runtimeNamespace) + this.store.countPending('core', this.deps.runtimeNamespace),
@@ -616,6 +622,10 @@ export class ProxyDaemon {
616
622
  }
617
623
  if (ctx.route === 'POST /asset/search') {
618
624
  const body = (await ctx.readJson());
625
+ if (hubModeMismatch(body.expected_hub_mode, this.deps.hubMode)) {
626
+ ctx.json(409, { error: 'proxy_hub_mode_mismatch' });
627
+ return true;
628
+ }
619
629
  const limit = Math.max(1, Math.min(Number(body.limit ?? 5), 25));
620
630
  const rawSignals = Array.isArray(body.signals) ? body.signals : body.signalsAny;
621
631
  const signalsAny = Array.isArray(rawSignals) ? rawSignals.filter((s) => typeof s === 'string') : undefined;
@@ -638,6 +648,10 @@ export class ProxyDaemon {
638
648
  }
639
649
  if (ctx.route === 'POST /asset/fetch') {
640
650
  const body = (await ctx.readJson());
651
+ if (hubModeMismatch(body.expected_hub_mode, this.deps.hubMode)) {
652
+ ctx.json(409, { error: 'proxy_hub_mode_mismatch' });
653
+ return true;
654
+ }
641
655
  const ids = uniqueStrings([
642
656
  ...(Array.isArray(body.asset_ids) ? body.asset_ids : []),
643
657
  ...(typeof body.asset_id === 'string' ? [body.asset_id] : []),
@@ -664,6 +678,10 @@ export class ProxyDaemon {
664
678
  }
665
679
  if (ctx.route === 'POST /asset/submit') {
666
680
  const body = (await ctx.readJson());
681
+ if (hubModeMismatch(body.expected_hub_mode, this.deps.hubMode)) {
682
+ ctx.json(409, { stored: false, error: 'proxy_hub_mode_mismatch' });
683
+ return true;
684
+ }
667
685
  if (!body.assets && !body.asset_id) {
668
686
  ctx.json(400, { error: 'assets or asset_id is required' });
669
687
  return true;
@@ -679,6 +697,10 @@ export class ProxyDaemon {
679
697
  // Pre-publish dry-run against the hub's quality + content-safety gate (nothing stored, no credits).
680
698
  // Same {assets:[…]} bundle shape as /asset/submit; the adapter wraps it in a GEP-A2A envelope.
681
699
  const body = (await ctx.readJson());
700
+ if (hubModeMismatch(body.expected_hub_mode, this.deps.hubMode)) {
701
+ ctx.json(409, { valid: false, error: 'proxy_hub_mode_mismatch' });
702
+ return true;
703
+ }
682
704
  const bundle = Array.isArray(body.assets)
683
705
  ? body.assets.filter((a) => Boolean(a && typeof a === 'object'))
684
706
  : (body.asset && typeof body.asset === 'object' && !Array.isArray(body.asset) ? [body.asset] : []);
@@ -704,7 +726,12 @@ export class ProxyDaemon {
704
726
  return true;
705
727
  }
706
728
  if (ctx.route === 'POST /asset/reuse-result') {
707
- const parsed = parseReuseResultReport(await ctx.readJson());
729
+ const body = await ctx.readJson();
730
+ if (hubModeMismatch(asRecord(body)['expected_hub_mode'], this.deps.hubMode)) {
731
+ ctx.json(409, { recorded: false, error: 'proxy_hub_mode_mismatch' });
732
+ return true;
733
+ }
734
+ const parsed = parseReuseResultReport(body);
708
735
  if ('error' in parsed) {
709
736
  ctx.json(400, { recorded: false, error: parsed.error });
710
737
  return true;
@@ -723,6 +750,10 @@ export class ProxyDaemon {
723
750
  }
724
751
  if (ctx.route === 'POST /conversation/distill') {
725
752
  const body = (await ctx.readJson());
753
+ if (hubModeMismatch(body.expected_hub_mode, this.deps.hubMode)) {
754
+ ctx.json(409, { error: 'proxy_hub_mode_mismatch' });
755
+ return true;
756
+ }
726
757
  const distill = await hubNs.distillConversation(body, { persist: body.persist === true, store: this.assetStore });
727
758
  if (!distill.ok) {
728
759
  ctx.json(200, { ...distill, queued: false, submission: null });
@@ -929,7 +960,17 @@ function isValidator(value) {
929
960
  return Boolean(value && typeof value === 'object' && typeof value.validate === 'function');
930
961
  }
931
962
  function assetMatchesId(asset, assetId) {
932
- return Boolean(asset && asset.asset_id === assetId);
963
+ if (!asset)
964
+ return false;
965
+ return assetId.startsWith('sha256:')
966
+ ? asset.asset_id === assetId
967
+ : asset.asset_id === assetId || asset['id'] === assetId;
968
+ }
969
+ function hubModeMismatch(expected, actual) {
970
+ return expected !== undefined && expected !== (actual ?? 'public');
971
+ }
972
+ function singleHeader(value) {
973
+ return Array.isArray(value) ? value[0] : value;
933
974
  }
934
975
  function uniqueStrings(values) {
935
976
  const seen = new Set();
@@ -12,10 +12,12 @@ export function resolveHubUrl(env) {
12
12
  return resolvePublicHubUrl(env);
13
13
  }
14
14
  function resolvePrivateHubUrl(env) {
15
- return trimmed(env['EVOMAP_HUB_URL'])
15
+ const url = trimmed(env['EVOMAP_HUB_URL'])
16
16
  ?? trimmed(env['A2A_HUB_URL'])
17
- ?? trimmed(env['EVOLVER_DEFAULT_HUB_URL'])
18
- ?? resolvePublicHubUrl({});
17
+ ?? trimmed(env['EVOLVER_DEFAULT_HUB_URL']);
18
+ if (!url)
19
+ throw new Error('private Hub URL is not configured');
20
+ return url;
19
21
  }
20
22
  function trimmed(value) {
21
23
  const v = value?.trim();
@@ -52,6 +52,14 @@ export interface ConnectPrivateProxyHubOptions {
52
52
  now?: () => number;
53
53
  importer?: DynamicImporter;
54
54
  fetchFn?: PrivateCompatibilityFetch;
55
+ /** Durable credential minted by a previous invitation/SSO enrollment. */
56
+ storedNodeSecret?: string;
57
+ /** Persist a newly minted credential before the process can restart. */
58
+ onNodeSecretAdopted?: (nodeSecret: string) => void;
59
+ /** Fingerprint of the last successfully redeemed one-shot invitation. */
60
+ storedInvitationFingerprint?: string;
61
+ /** Persist the non-secret fingerprint after an invitation is redeemed. */
62
+ onInvitationRedeemed?: (fingerprint: string) => void;
55
63
  }
56
64
  export declare function resolvePrivateEnterpriseToken(env: Record<string, string | undefined>): string | undefined;
57
65
  /** One-shot invitation token (evoinv_…), matching the hub's official onboarding script (A2A_INVITATION_TOKEN).
@@ -1,4 +1,5 @@
1
1
  import { hub as hubNs } from '@evomap/evolver-core';
2
+ import { createHash } from 'node:crypto';
2
3
  import { withPrivateAccountAssetCompatibility, } from './accountAssetCompatibility.js';
3
4
  const DEFAULT_PRIVATE_ADAPTER_MODULE = '@evomap/evolver-adapter-private';
4
5
  export function resolvePrivateEnterpriseToken(env) {
@@ -17,19 +18,26 @@ export function resolvePrivateEnterpriseSubject(env) {
17
18
  }
18
19
  export async function connectPrivateProxyHub(opts) {
19
20
  const invitationToken = resolvePrivateInvitationToken(opts.env);
21
+ const invitationFingerprint = invitationToken
22
+ ? createHash('sha256').update(invitationToken).digest('hex')
23
+ : undefined;
24
+ const usableInvitationToken = invitationFingerprint !== opts.storedInvitationFingerprint
25
+ ? invitationToken
26
+ : undefined;
20
27
  const token = resolvePrivateEnterpriseToken(opts.env);
21
- const nodeSecret = resolvePrivateNodeSecret(opts.env);
28
+ const configuredNodeSecret = resolvePrivateNodeSecret(opts.env);
29
+ const nodeSecret = configuredNodeSecret ?? opts.storedNodeSecret?.trim();
22
30
  if (nodeSecret && !isNodeSecret(nodeSecret)) {
23
31
  throw new Error('Private Hub node_secret 必须是 64 位十六进制字符串');
24
32
  }
25
- if (!token && !invitationToken && !nodeSecret) {
33
+ if (!token && !usableInvitationToken && !nodeSecret) {
26
34
  throw new Error('EVOMAP_HUB_MODE=private 需要 A2A_NODE_SECRET / EVOMAP_NODE_SECRET、A2A_INVITATION_TOKEN 或 EVOMAP_ENTERPRISE_TOKEN');
27
35
  }
28
36
  const moduleName = opts.env['EVOMAP_PRIVATE_ADAPTER_MODULE']?.trim() || DEFAULT_PRIVATE_ADAPTER_MODULE;
29
37
  const connectPrivateHub = await loadConnectPrivateHub(moduleName, opts.importer ?? ((specifier) => import(specifier)));
30
38
  const now = opts.now ?? (() => Date.now());
31
39
  const subject = resolvePrivateEnterpriseSubject(opts.env);
32
- const { hub, auth } = connectPrivateHub({
40
+ const baseConnectionOptions = {
33
41
  hubUrl: opts.hubUrl,
34
42
  senderId: opts.senderId,
35
43
  env: opts.env,
@@ -39,13 +47,34 @@ export async function connectPrivateProxyHub(opts) {
39
47
  exchange: async () => ({ token: token ?? nodeSecret ?? '' }),
40
48
  now,
41
49
  },
42
- ...(nodeSecret ? { nodeSecret } : {}),
43
- ...(!nodeSecret && invitationToken ? { invitationToken } : {}),
44
50
  ...(opts.fetchFn ? { fetchFn: opts.fetchFn } : {}),
51
+ };
52
+ const primaryInvitationToken = !nodeSecret && !configuredNodeSecret ? usableInvitationToken : undefined;
53
+ const { hub, auth } = connectPrivateHub({
54
+ ...baseConnectionOptions,
55
+ ...(nodeSecret ? { nodeSecret } : {}),
56
+ ...(primaryInvitationToken ? { invitationToken: primaryInvitationToken } : {}),
45
57
  });
46
58
  assertPrivateLifecycle(hub, moduleName);
59
+ const storedInvitationFallback = nodeSecret && !configuredNodeSecret && usableInvitationToken
60
+ ? connectPrivateHub({ ...baseConnectionOptions, invitationToken: usableInvitationToken })
61
+ : undefined;
62
+ const enterpriseFallback = primaryInvitationToken && token
63
+ ? connectPrivateHub(baseConnectionOptions)
64
+ : undefined;
65
+ if (storedInvitationFallback)
66
+ assertPrivateLifecycle(storedInvitationFallback.hub, moduleName);
67
+ if (enterpriseFallback)
68
+ assertPrivateLifecycle(enterpriseFallback.hub, moduleName);
47
69
  if (nodeSecret)
48
70
  await adoptReadyNodeSecret(auth, nodeSecret);
71
+ const primaryAdoptions = trackNodeSecretAdoptions(auth);
72
+ const storedInvitationAdoptions = storedInvitationFallback
73
+ ? trackNodeSecretAdoptions(storedInvitationFallback.auth)
74
+ : undefined;
75
+ const enterpriseAdoptions = enterpriseFallback
76
+ ? trackNodeSecretAdoptions(enterpriseFallback.auth)
77
+ : undefined;
49
78
  if (!hub.agentDirectory) {
50
79
  hub.agentDirectory = hubNs.unsupportedAgentDirectoryCapability('private_hub_agent_directory_not_supported');
51
80
  }
@@ -56,14 +85,152 @@ export async function connectPrivateProxyHub(opts) {
56
85
  env: opts.env,
57
86
  ...(opts.fetchFn ? { fetchFn: opts.fetchFn } : {}),
58
87
  });
88
+ let readyHelloPending = Boolean(nodeSecret);
89
+ let invitationRedeemed = false;
90
+ let enterpriseFallbackSelected = false;
91
+ let helloInProgress = false;
92
+ const helloWithFallback = async (fallback, adoptions, helloOpts, rotate) => {
93
+ if (!adoptions?.available) {
94
+ throw new Error('private Hub adapter cannot report node_secret adoption');
95
+ }
96
+ if (rotate)
97
+ await fallback.auth.rotate();
98
+ const { result, adoptedNodeSecret } = await adoptions.capture(() => fallback.hub.hello(helloOpts));
99
+ if (!result.ok)
100
+ return result;
101
+ if (!adoptedNodeSecret)
102
+ throw new Error('private Hub fallback did not adopt a node_secret');
103
+ await adoptReadyNodeSecret(fallback.auth, adoptedNodeSecret);
104
+ await adoptReadyNodeSecret(auth, adoptedNodeSecret);
105
+ opts.onNodeSecretAdopted?.(adoptedNodeSecret);
106
+ return result;
107
+ };
108
+ const helloOnce = async (helloOpts) => {
109
+ if (enterpriseFallbackSelected || invitationRedeemed) {
110
+ if (!enterpriseFallback)
111
+ return { ok: false, error: 'private_invitation_reenrollment_required' };
112
+ return helloWithFallback(enterpriseFallback, enterpriseAdoptions, helloOpts, true);
113
+ }
114
+ if (primaryInvitationToken && !primaryAdoptions.available) {
115
+ throw new Error('private Hub adapter cannot report node_secret adoption');
116
+ }
117
+ let result;
118
+ let adoptedNodeSecret;
119
+ let usedAdapterHello = false;
120
+ if (nodeSecret && readyHelloPending) {
121
+ // A stored credential gets one ready probe before a fresh invitation takes over.
122
+ // Clear this before awaiting so a thrown probe cannot starve the fallback forever.
123
+ if (storedInvitationFallback)
124
+ readyHelloPending = false;
125
+ const tracked = await primaryAdoptions.capture(() => helloWithReadyPrivateCredential(compatibleHub, auth, nodeSecret, opts.senderId, helloOpts, primaryAdoptions.available));
126
+ readyHelloPending = false;
127
+ result = tracked.result;
128
+ adoptedNodeSecret = tracked.adoptedNodeSecret;
129
+ if (result.ok && !adoptedNodeSecret && !await authenticatesWithNodeSecret(auth, nodeSecret)) {
130
+ throw new Error('private Hub ready credential hello did not preserve an active node_secret');
131
+ }
132
+ }
133
+ else if (storedInvitationFallback) {
134
+ result = await helloWithFallback(storedInvitationFallback, storedInvitationAdoptions, helloOpts, false);
135
+ if (result.ok) {
136
+ invitationRedeemed = true;
137
+ if (invitationFingerprint)
138
+ opts.onInvitationRedeemed?.(invitationFingerprint);
139
+ }
140
+ return result;
141
+ }
142
+ else {
143
+ usedAdapterHello = true;
144
+ const tracked = await primaryAdoptions.capture(() => compatibleHub.hello(helloOpts));
145
+ result = tracked.result;
146
+ adoptedNodeSecret = tracked.adoptedNodeSecret;
147
+ }
148
+ if (!result.ok && usedAdapterHello && primaryInvitationToken && enterpriseFallback) {
149
+ const fallbackResult = await helloWithFallback(enterpriseFallback, enterpriseAdoptions, helloOpts, true);
150
+ if (fallbackResult.ok)
151
+ enterpriseFallbackSelected = true;
152
+ return fallbackResult;
153
+ }
154
+ if (result.ok) {
155
+ if (adoptedNodeSecret && adoptedNodeSecret !== nodeSecret) {
156
+ await adoptReadyNodeSecret(auth, adoptedNodeSecret);
157
+ opts.onNodeSecretAdopted?.(adoptedNodeSecret);
158
+ }
159
+ if (usedAdapterHello && primaryInvitationToken) {
160
+ if (!adoptedNodeSecret) {
161
+ throw new Error('private Hub enrollment did not adopt a node_secret');
162
+ }
163
+ invitationRedeemed = true;
164
+ if (invitationFingerprint)
165
+ opts.onInvitationRedeemed?.(invitationFingerprint);
166
+ }
167
+ }
168
+ return result;
169
+ };
170
+ const hello = async (helloOpts) => {
171
+ if (helloInProgress)
172
+ throw new Error('concurrent private Hub hello is not supported');
173
+ helloInProgress = true;
174
+ try {
175
+ return await helloOnce(helloOpts);
176
+ }
177
+ finally {
178
+ helloInProgress = false;
179
+ }
180
+ };
59
181
  return {
60
182
  hub: compatibleHub,
61
183
  auth,
62
- hello: nodeSecret
63
- ? async (helloOpts) => await helloWithReadyPrivateCredential(compatibleHub, auth, nodeSecret, opts.senderId, helloOpts)
64
- : (helloOpts) => compatibleHub.hello(helloOpts),
184
+ hello,
65
185
  };
66
186
  }
187
+ const NODE_SECRET_ADOPTION_TRACKERS = new WeakMap();
188
+ function trackNodeSecretAdoptions(auth) {
189
+ const existing = NODE_SECRET_ADOPTION_TRACKERS.get(auth);
190
+ if (existing)
191
+ return existing;
192
+ const candidate = auth;
193
+ const original = candidate.adoptNodeSecret;
194
+ let capturing = false;
195
+ let available = false;
196
+ let adoptedNodeSecret;
197
+ if (typeof original === 'function') {
198
+ const wrapped = function (nodeSecret) {
199
+ original.call(this, nodeSecret);
200
+ if (capturing)
201
+ adoptedNodeSecret = isNodeSecret(nodeSecret) ? nodeSecret : undefined;
202
+ };
203
+ try {
204
+ candidate.adoptNodeSecret = wrapped;
205
+ available = candidate.adoptNodeSecret === wrapped;
206
+ }
207
+ catch {
208
+ // A frozen adapter cannot expose reliable adoption provenance. Enrollment will fail closed below.
209
+ }
210
+ }
211
+ const tracker = {
212
+ get available() { return available; },
213
+ capture: async (operation) => {
214
+ if (capturing)
215
+ throw new Error('concurrent private Hub credential adoption is not supported');
216
+ capturing = true;
217
+ adoptedNodeSecret = undefined;
218
+ try {
219
+ const result = await operation();
220
+ return {
221
+ result,
222
+ ...(adoptedNodeSecret ? { adoptedNodeSecret } : {}),
223
+ };
224
+ }
225
+ finally {
226
+ capturing = false;
227
+ adoptedNodeSecret = undefined;
228
+ }
229
+ },
230
+ };
231
+ NODE_SECRET_ADOPTION_TRACKERS.set(auth, tracker);
232
+ return tracker;
233
+ }
67
234
  async function loadConnectPrivateHub(moduleName, importer) {
68
235
  let loaded;
69
236
  try {
@@ -94,16 +261,21 @@ async function adoptReadyNodeSecret(auth, nodeSecret) {
94
261
  throw new Error('private Hub adapter cannot activate the configured node_secret');
95
262
  }
96
263
  }
97
- async function helloWithReadyPrivateCredential(hub, auth, nodeSecret, senderId, opts) {
98
- if (await authenticatesWithNodeSecret(auth, nodeSecret))
99
- return readyPrivateHello(senderId);
264
+ async function helloWithReadyPrivateCredential(hub, auth, nodeSecret, senderId, opts, canTrackAdoption = true) {
265
+ const nodeId = trimmedSenderId(senderId);
266
+ if (nodeId && await authenticatesWithNodeSecret(auth, nodeSecret))
267
+ return readyPrivateHello(nodeId);
268
+ if (!canTrackAdoption)
269
+ throw new Error('private Hub adapter cannot report node_secret adoption');
100
270
  return await hub.hello(opts);
101
271
  }
102
272
  async function authenticatesWithNodeSecret(auth, nodeSecret) {
103
273
  const signed = await auth.authenticate({ method: 'GET', path: '/a2a/assets/published-by-me' });
104
274
  const authorization = headerValue(signed.headers, 'authorization');
105
275
  const bodySecret = signed.bodyFields?.['node_secret'];
106
- return authorization === `Bearer ${nodeSecret}` || bodySecret === nodeSecret;
276
+ return authorization !== undefined
277
+ ? authorization === `Bearer ${nodeSecret}`
278
+ : bodySecret === nodeSecret;
107
279
  }
108
280
  function headerValue(headers, name) {
109
281
  const lower = name.toLowerCase();
@@ -116,9 +288,8 @@ function headerValue(headers, name) {
116
288
  function trimmedSenderId(senderId) {
117
289
  return senderId()?.trim() || undefined;
118
290
  }
119
- function readyPrivateHello(senderId) {
120
- const nodeId = trimmedSenderId(senderId);
121
- return { ok: true, ...(nodeId ? { nodeId } : {}) };
291
+ function readyPrivateHello(nodeId) {
292
+ return { ok: true, nodeId };
122
293
  }
123
294
  function isNodeSecret(value) {
124
295
  return /^[a-f0-9]{64}$/i.test(value);
@@ -0,0 +1,23 @@
1
+ export interface WindowsSecretProtector {
2
+ protect(secret: string): string;
3
+ unprotect(protectedValue: string): string;
4
+ preflight?(): void;
5
+ }
6
+ export interface PrivateNodeCredentialStoreOptions {
7
+ platform?: NodeJS.Platform;
8
+ windowsProtector?: WindowsSecretProtector;
9
+ }
10
+ export declare class PrivateNodeCredentialReadError extends Error {
11
+ constructor();
12
+ }
13
+ export declare class PrivateNodeCredentialStore {
14
+ private readonly directory;
15
+ private readonly path;
16
+ private readonly platform;
17
+ private readonly windowsProtector;
18
+ constructor(proxyStorePath: string, options?: PrivateNodeCredentialStoreOptions);
19
+ read(): string | undefined;
20
+ write(nodeSecret: string): void;
21
+ private prepareDirectory;
22
+ private assertRegularFile;
23
+ }
@@ -0,0 +1,210 @@
1
+ import { chmodSync, closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { randomBytes } from 'node:crypto';
4
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
5
+ const DIRECTORY_MODE = 0o700;
6
+ const FILE_MODE = 0o600;
7
+ const NODE_SECRET_RE = /^[a-f0-9]{64}$/i;
8
+ const WINDOWS_PROTECTED_VALUE_RE = /^[a-z0-9+/]+={0,2}$/i;
9
+ const MAX_PROTECTED_VALUE_LENGTH = 16_384;
10
+ export class PrivateNodeCredentialReadError extends Error {
11
+ constructor() {
12
+ super('stored private node credential is unreadable');
13
+ this.name = 'PrivateNodeCredentialReadError';
14
+ }
15
+ }
16
+ export class PrivateNodeCredentialStore {
17
+ directory;
18
+ path;
19
+ platform;
20
+ windowsProtector;
21
+ constructor(proxyStorePath, options = {}) {
22
+ this.platform = options.platform ?? process.platform;
23
+ this.windowsProtector = this.platform === 'win32'
24
+ ? options.windowsProtector ?? createWindowsDpapiProtector()
25
+ : undefined;
26
+ if (this.windowsProtector)
27
+ verifyWindowsProtector(this.windowsProtector);
28
+ this.directory = join(dirname(resolve(proxyStorePath)), '.private-credentials');
29
+ this.path = join(this.directory, this.platform === 'win32' ? 'node-secret.dpapi' : 'node-secret');
30
+ this.prepareDirectory();
31
+ }
32
+ read() {
33
+ if (!existsSync(this.path))
34
+ return undefined;
35
+ this.assertRegularFile(this.path);
36
+ if (this.platform !== 'win32')
37
+ chmodSync(this.path, FILE_MODE);
38
+ const storedValue = readFileSync(this.path, 'utf8').trim();
39
+ try {
40
+ const value = this.windowsProtector
41
+ ? this.windowsProtector.unprotect(assertWindowsProtectedValue(storedValue))
42
+ : storedValue;
43
+ if (!NODE_SECRET_RE.test(value))
44
+ throw new PrivateNodeCredentialReadError();
45
+ return value;
46
+ }
47
+ catch {
48
+ throw new PrivateNodeCredentialReadError();
49
+ }
50
+ }
51
+ write(nodeSecret) {
52
+ if (!NODE_SECRET_RE.test(nodeSecret))
53
+ throw new Error('private node credential is invalid');
54
+ const storedValue = this.windowsProtector
55
+ ? assertWindowsProtectedValue(this.windowsProtector.protect(nodeSecret))
56
+ : nodeSecret;
57
+ this.prepareDirectory();
58
+ if (existsSync(this.path))
59
+ this.assertRegularFile(this.path);
60
+ const temporaryPath = join(this.directory, `.node-secret.${process.pid}.${randomBytes(8).toString('hex')}.tmp`);
61
+ let descriptor;
62
+ try {
63
+ descriptor = openSync(temporaryPath, 'wx', FILE_MODE);
64
+ writeFileSync(descriptor, storedValue, 'utf8');
65
+ fsyncSync(descriptor);
66
+ closeSync(descriptor);
67
+ descriptor = undefined;
68
+ renameSync(temporaryPath, this.path);
69
+ if (this.platform !== 'win32')
70
+ chmodSync(this.path, FILE_MODE);
71
+ if (this.platform !== 'win32')
72
+ syncDirectory(this.directory);
73
+ }
74
+ catch (error) {
75
+ if (descriptor !== undefined)
76
+ closeSync(descriptor);
77
+ if (existsSync(temporaryPath))
78
+ unlinkSync(temporaryPath);
79
+ throw error;
80
+ }
81
+ }
82
+ prepareDirectory() {
83
+ const parent = dirname(this.directory);
84
+ const parentStat = lstatSync(parent);
85
+ if (!parentStat.isDirectory() || parentStat.isSymbolicLink()) {
86
+ throw new Error(`private credential parent must be a real directory: ${parent}`);
87
+ }
88
+ if (this.platform !== 'win32' && (parentStat.mode & 0o022) !== 0) {
89
+ throw new Error(`private credential parent must not be group/world-writable: ${parent}`);
90
+ }
91
+ const directoryExisted = existsSync(this.directory);
92
+ mkdirSync(this.directory, { recursive: true, mode: DIRECTORY_MODE });
93
+ const stat = lstatSync(this.directory);
94
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
95
+ throw new Error(`private credential path must be a real directory: ${this.directory}`);
96
+ }
97
+ if (this.platform !== 'win32')
98
+ chmodSync(this.directory, DIRECTORY_MODE);
99
+ if (!directoryExisted && this.platform !== 'win32')
100
+ syncDirectory(parent);
101
+ }
102
+ assertRegularFile(path) {
103
+ const stat = lstatSync(path);
104
+ if (!stat.isFile() || stat.isSymbolicLink()) {
105
+ throw new Error(`private credential must be a regular file: ${path}`);
106
+ }
107
+ }
108
+ }
109
+ function assertWindowsProtectedValue(value) {
110
+ const trimmed = value.trim();
111
+ if (!trimmed || trimmed.length > MAX_PROTECTED_VALUE_LENGTH || !WINDOWS_PROTECTED_VALUE_RE.test(trimmed)) {
112
+ throw new Error('stored private node credential ciphertext is invalid');
113
+ }
114
+ return trimmed;
115
+ }
116
+ function createWindowsDpapiProtector() {
117
+ const systemRoot = process.env['SystemRoot']?.trim();
118
+ if (!systemRoot || !isAbsolute(systemRoot)) {
119
+ throw new Error('Windows private credential persistence requires an absolute SystemRoot');
120
+ }
121
+ const executable = join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
122
+ let executableIsSafe = false;
123
+ try {
124
+ const executableStat = lstatSync(executable);
125
+ executableIsSafe = executableStat.isFile() && !executableStat.isSymbolicLink();
126
+ }
127
+ catch {
128
+ // Normalize filesystem errors so local paths are not included in startup logs.
129
+ }
130
+ if (!executableIsSafe) {
131
+ throw new Error('Windows private credential persistence requires Windows PowerShell');
132
+ }
133
+ return {
134
+ preflight: () => {
135
+ const canary = randomBytes(32).toString('hex');
136
+ if (runWindowsPowerShell(executable, WINDOWS_DPAPI_PREFLIGHT_SCRIPT, canary) !== canary) {
137
+ throw new Error('Windows private credential protection preflight failed');
138
+ }
139
+ },
140
+ protect: (secret) => runWindowsPowerShell(executable, WINDOWS_DPAPI_PROTECT_SCRIPT, secret),
141
+ unprotect: (protectedValue) => runWindowsPowerShell(executable, WINDOWS_DPAPI_UNPROTECT_SCRIPT, protectedValue),
142
+ };
143
+ }
144
+ function verifyWindowsProtector(protector) {
145
+ try {
146
+ if (protector.preflight) {
147
+ protector.preflight();
148
+ return;
149
+ }
150
+ const canary = randomBytes(32).toString('hex');
151
+ const protectedCanary = assertWindowsProtectedValue(protector.protect(canary));
152
+ if (protector.unprotect(protectedCanary) !== canary) {
153
+ throw new Error('round trip mismatch');
154
+ }
155
+ }
156
+ catch {
157
+ throw new Error('Windows private credential protection preflight failed');
158
+ }
159
+ }
160
+ function runWindowsPowerShell(executable, script, input) {
161
+ try {
162
+ return execFileSync(executable, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], {
163
+ encoding: 'utf8',
164
+ input,
165
+ maxBuffer: 64 * 1024,
166
+ stdio: ['pipe', 'pipe', 'ignore'],
167
+ timeout: 15_000,
168
+ windowsHide: true,
169
+ }).trim();
170
+ }
171
+ catch {
172
+ throw new Error('Windows private credential protection failed');
173
+ }
174
+ }
175
+ const WINDOWS_DPAPI_PREFLIGHT_SCRIPT = [
176
+ "$ErrorActionPreference = 'Stop'",
177
+ 'Add-Type -AssemblyName System.Security',
178
+ '$plain = [Console]::In.ReadToEnd()',
179
+ '$bytes = [Text.Encoding]::UTF8.GetBytes($plain)',
180
+ '$scope = [Security.Cryptography.DataProtectionScope]::CurrentUser',
181
+ '$protected = [Security.Cryptography.ProtectedData]::Protect($bytes, $null, $scope)',
182
+ '$restored = [Security.Cryptography.ProtectedData]::Unprotect($protected, $null, $scope)',
183
+ '[Console]::Out.Write([Text.Encoding]::UTF8.GetString($restored))',
184
+ ].join('; ');
185
+ const WINDOWS_DPAPI_PROTECT_SCRIPT = [
186
+ "$ErrorActionPreference = 'Stop'",
187
+ 'Add-Type -AssemblyName System.Security',
188
+ '$plain = [Console]::In.ReadToEnd()',
189
+ '$bytes = [Text.Encoding]::UTF8.GetBytes($plain)',
190
+ '$scope = [Security.Cryptography.DataProtectionScope]::CurrentUser',
191
+ '$protected = [Security.Cryptography.ProtectedData]::Protect($bytes, $null, $scope)',
192
+ '[Console]::Out.Write([Convert]::ToBase64String($protected))',
193
+ ].join('; ');
194
+ const WINDOWS_DPAPI_UNPROTECT_SCRIPT = [
195
+ "$ErrorActionPreference = 'Stop'",
196
+ 'Add-Type -AssemblyName System.Security',
197
+ '$protected = [Convert]::FromBase64String([Console]::In.ReadToEnd())',
198
+ '$scope = [Security.Cryptography.DataProtectionScope]::CurrentUser',
199
+ '$bytes = [Security.Cryptography.ProtectedData]::Unprotect($protected, $null, $scope)',
200
+ '[Console]::Out.Write([Text.Encoding]::UTF8.GetString($bytes))',
201
+ ].join('; ');
202
+ function syncDirectory(path) {
203
+ const descriptor = openSync(path, 'r');
204
+ try {
205
+ fsyncSync(descriptor);
206
+ }
207
+ finally {
208
+ closeSync(descriptor);
209
+ }
210
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evomap/evolver-proxy",
3
- "version": "2.0.0-beta.13",
3
+ "version": "2.0.0-beta.15",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "系统级 mailbox/hub 同步 daemon (Node)",
@@ -26,8 +26,8 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@aws-sdk/client-bedrock-runtime": "^3.1053.0",
29
- "@evomap/evolver-adapter-public": "2.0.0-beta.13",
30
- "@evomap/evolver-core": "2.0.0-beta.13"
29
+ "@evomap/evolver-adapter-public": "2.0.0-beta.15",
30
+ "@evomap/evolver-core": "2.0.0-beta.15"
31
31
  },
32
32
  "repository": {
33
33
  "type": "git",