evolcore 0.0.11 → 0.0.12

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 (102) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/codex-managed-hook.mjs +80 -0
  3. package/dist/agents/claude-runner.js +35 -2
  4. package/dist/agents/codex-app-server-client.js +120 -14
  5. package/dist/agents/codex-runner.js +273 -35
  6. package/dist/agents/ecagent-runner.js +36 -4
  7. package/dist/agents/gemini-runner.js +1 -0
  8. package/dist/aun/aid/client.js +11 -21
  9. package/dist/aun/aid/managed-operation.js +107 -0
  10. package/dist/aun/msg/group.js +81 -12
  11. package/dist/aun/msg/managed-operation.js +804 -0
  12. package/dist/aun/msg/mention-schema.js +20 -0
  13. package/dist/aun/msg/p2p.js +7 -0
  14. package/dist/channels/aun.js +288 -108
  15. package/dist/channels/daemon.js +13 -0
  16. package/dist/cli/agent-command.js +56 -8
  17. package/dist/cli/agent.js +170 -6
  18. package/dist/cli/aun-commands.js +270 -69
  19. package/dist/cli/bench.js +12 -3
  20. package/dist/cli/daemon-commands.js +31 -31
  21. package/dist/cli/fs-command.js +60 -0
  22. package/dist/cli/handoff-command.js +3 -0
  23. package/dist/cli/index.js +30 -2
  24. package/dist/cli/managed-command-guard.js +41 -0
  25. package/dist/cli/model.js +11 -4
  26. package/dist/cli/queue-command.js +11 -1
  27. package/dist/cli/response.js +71 -0
  28. package/dist/cli/restart-monitor.js +4 -18
  29. package/dist/cli/trigger-command.js +4 -0
  30. package/dist/config/builtin-roles.js +4 -0
  31. package/dist/config/contact-book-store.js +11 -3
  32. package/dist/config/contact-request-service.js +161 -15
  33. package/dist/config/mention-mode.js +8 -24
  34. package/dist/core/auth/agent-delegation.js +0 -1
  35. package/dist/core/auth/operation-authorizer.js +30 -7
  36. package/dist/core/auth/operation-catalog.js +187 -25
  37. package/dist/core/bootstrap-messages.js +18 -0
  38. package/dist/core/bootstrap-service.js +63 -4
  39. package/dist/core/capability/providers/claude-capability-provider.js +9 -31
  40. package/dist/core/capability/providers/codex-capability-provider.js +4 -15
  41. package/dist/core/capability/skill-discovery.js +55 -0
  42. package/dist/core/channel-loader.js +1 -1
  43. package/dist/core/command/cli-intent-parser.js +9 -0
  44. package/dist/core/command/command-handler.js +467 -6
  45. package/dist/core/command/connect-menu.js +15 -0
  46. package/dist/core/command/group-menu.js +4 -4
  47. package/dist/core/command/menu-catalog.js +366 -0
  48. package/dist/core/command/menu-handler.js +147 -35
  49. package/dist/core/command/menu-protocol.js +71 -0
  50. package/dist/core/command/slash-gate.js +5 -2
  51. package/dist/core/command/slash-handler.js +41 -21
  52. package/dist/core/daemon-file-cache.js +2 -1
  53. package/dist/core/data-migration.js +9 -2
  54. package/dist/core/evolagent.js +4 -2
  55. package/dist/core/handoff/dispatcher.js +5 -2
  56. package/dist/core/handoff/runtime.js +145 -21
  57. package/dist/core/handoff/store.js +24 -0
  58. package/dist/core/handoff/types.js +1 -0
  59. package/dist/core/message/logical-queue-bridge.js +1 -0
  60. package/dist/core/message/mention-schema.js +126 -0
  61. package/dist/core/message/message-bridge.js +236 -78
  62. package/dist/core/message/message-log.js +1 -0
  63. package/dist/core/message/message-queue.js +1 -0
  64. package/dist/core/message/response-engine.js +54 -36
  65. package/dist/core/permission/ec-command-parser.js +156 -10
  66. package/dist/core/permission/sandbox-runtime.js +22 -1
  67. package/dist/core/permission/tool-policy.js +73 -30
  68. package/dist/core/protected-paths.js +7 -2
  69. package/dist/core/session/session-manager.js +21 -2
  70. package/dist/eck/manifest-engine.js +39 -13
  71. package/dist/index.js +384 -98
  72. package/dist/ipc.js +218 -9
  73. package/dist/response-system/coordinator.js +6 -4
  74. package/dist/response-system/engines/v1/proactive-flow.js +26 -8
  75. package/dist/response-system/modes/single-session/index.js +14 -1
  76. package/dist/response-system/selector.js +6 -5
  77. package/dist/trigger/anomaly-store.js +4 -0
  78. package/dist/trigger/script-executor.js +1 -0
  79. package/dist/utils/codex-app-server-registry.js +90 -0
  80. package/dist/utils/codex-cli.js +5 -1
  81. package/dist/utils/cross-platform.js +15 -0
  82. package/dist/utils/npm-ops.js +5 -12
  83. package/dist/utils/tool-summary.js +11 -0
  84. package/kits/docs/channels/aun.md +1 -1
  85. package/kits/docs/context-assembly.md +2 -2
  86. package/kits/docs/evolcore/agent.md +11 -6
  87. package/kits/docs/evolcore/aid.md +14 -0
  88. package/kits/docs/prompt-loading-architecture.md +1 -2
  89. package/kits/docs/venues/group.md +1 -1
  90. package/kits/eck_manifest.json +0 -12
  91. package/kits/rules/04-relation.md +1 -1
  92. package/kits/rules/05-venue.md +2 -2
  93. package/kits/schemas/single-session.schema.1.json +3 -3
  94. package/kits/schemas/single-session.schema.2.json +3 -3
  95. package/kits/templates/roles/admin.json +22 -0
  96. package/kits/templates/roles/member.json +27 -0
  97. package/kits/templates/roles/visitor.json +33 -3
  98. package/kits/templates/system-fragments/bootstrap.md +1 -1
  99. package/kits/templates/system-fragments/channel.md +1 -1
  100. package/kits/templates/system-fragments/session.md +1 -1
  101. package/package.json +2 -2
  102. package/dist/core/command/evol-menu-version-gate.js +0 -39
package/dist/cli/index.js CHANGED
@@ -3,7 +3,9 @@
3
3
  process.env.AUN_LOG_INI_DISABLE = '1';
4
4
  import fs from 'fs';
5
5
  import path from 'path';
6
- import { getPackageRoot } from '../paths.js';
6
+ import { getPackageRoot, resolvePaths } from '../paths.js';
7
+ import { ipcQuery } from '../ipc.js';
8
+ import { AGENT_DELEGATION_TOKEN_ENV } from '../core/auth/agent-delegation.js';
7
9
  import { cmdInit } from './init.js';
8
10
  import { cmdInitWechat, cmdInitFeishu, cmdInitDingtalk, cmdInitQQBot, cmdInitWecom, cmdInitAun } from './init-channel.js';
9
11
  import { isHelpFlag, wantsHelp, getArgValue } from './help.js';
@@ -16,6 +18,7 @@ import { cmdQueue } from './queue-command.js';
16
18
  import { cmdStart, cmdStop, cmdRestart, cmdStatus, cmdLogs, cmdWatchCommand, cmdMv, cmdDiagnose } from './daemon-commands.js';
17
19
  import { logCliCommand } from './command-log.js';
18
20
  import { isInitCancelledError, printInitCancelHint } from './init-cancel.js';
21
+ import { assertManagedTopLevelCommandAllowed, ManagedCommandNotAllowedError } from './managed-command-guard.js';
19
22
  const MAIN_HELP = `用法: ec <command> [选项]
20
23
 
21
24
  服务:
@@ -87,6 +90,16 @@ process.on('warning', (w) => { if (w.name === 'ExperimentalWarning')
87
90
  return; process.stderr.write((w.stack ?? String(w)) + '\n'); });
88
91
  export async function main(args) {
89
92
  const cmd = args[0] || 'start';
93
+ try {
94
+ assertManagedTopLevelCommandAllowed(cmd, args);
95
+ }
96
+ catch (error) {
97
+ if (!(error instanceof ManagedCommandNotAllowedError))
98
+ throw error;
99
+ console.error(`❌ [${error.code}] ${error.message}`);
100
+ process.exitCode = 1;
101
+ return;
102
+ }
90
103
  logCliCommand(cmd, args);
91
104
  if (cmd === '--version' || cmd === '-v' || cmd === '-V') {
92
105
  const pkg = JSON.parse(fs.readFileSync(path.join(getPackageRoot(), 'package.json'), 'utf-8'));
@@ -229,7 +242,22 @@ export async function main(args) {
229
242
  printLifecycleHelp('status');
230
243
  break;
231
244
  }
232
- await cmdStatus();
245
+ if (process.env.EVOLCORE_SESSION_ID) {
246
+ const sessionId = process.env.EVOLCORE_SESSION_ID;
247
+ const delegationToken = process.env[AGENT_DELEGATION_TOKEN_ENV];
248
+ const result = await ipcQuery(resolvePaths().socket, {
249
+ type: 'status.auth', sessionId, delegationToken,
250
+ });
251
+ if (!result?.ok) {
252
+ console.error(`❌ ${result?.error || 'status is not allowed in this task'}`);
253
+ process.exitCode = 1;
254
+ break;
255
+ }
256
+ await cmdStatus();
257
+ }
258
+ else {
259
+ await cmdStatus();
260
+ }
233
261
  break;
234
262
  case 'logs':
235
263
  if (wantsHelp(args.slice(1))) {
@@ -0,0 +1,41 @@
1
+ import { wantsHelp } from './help.js';
2
+ const MANAGED_TASK_COMMANDS = new Set([
3
+ 'aid',
4
+ 'agent',
5
+ 'config',
6
+ 'contact',
7
+ 'ctl',
8
+ 'fs',
9
+ 'group',
10
+ 'handoff',
11
+ 'model',
12
+ 'msg',
13
+ 'queue',
14
+ 'response',
15
+ 'status',
16
+ 'storage',
17
+ 'trigger',
18
+ 'version',
19
+ '-v',
20
+ '-V',
21
+ '--version',
22
+ ]);
23
+ export class ManagedCommandNotAllowedError extends Error {
24
+ command;
25
+ code = 'NOT_ALLOWED';
26
+ constructor(command) {
27
+ super(`Command ${command} is not available in a managed Agent task`);
28
+ this.command = command;
29
+ this.name = 'ManagedCommandNotAllowedError';
30
+ }
31
+ }
32
+ /**
33
+ * Local maintenance commands do not have daemon authorization entrypoints.
34
+ * Keep them outside managed tasks so they cannot read or mutate host state.
35
+ */
36
+ export function assertManagedTopLevelCommandAllowed(command, args, env = process.env) {
37
+ if (!env.EVOLCORE_SESSION_ID || wantsHelp(args.slice(1)) || command === '--help' || command === '-h')
38
+ return;
39
+ if (!MANAGED_TASK_COMMANDS.has(command))
40
+ throw new ManagedCommandNotAllowedError(command);
41
+ }
package/dist/cli/model.js CHANGED
@@ -46,10 +46,8 @@ function fail(formatJson, code, message, data) {
46
46
  process.exit(1);
47
47
  }
48
48
  function isManagedSessionEnv(args) {
49
- return !!process.env.EVOLCORE_SESSION_ID
50
- && !getArgValue(args, '--self')
51
- && !getArgValue(args, '--peer')
52
- && !getArgValue(args, '--role');
49
+ void args;
50
+ return !!process.env.EVOLCORE_SESSION_ID;
53
51
  }
54
52
  function isManagedTargetedWriteEnv(args) {
55
53
  return !!process.env.EVOLCORE_SESSION_ID
@@ -368,6 +366,11 @@ async function cmdInfo(args, formatJson) {
368
366
  const modelId = args[1] && !args[1].startsWith('--') ? args[1] : undefined;
369
367
  if (!modelId)
370
368
  fail(formatJson, 'MISSING_MODEL_ID', 'info 需要 <model-id>');
369
+ if (isManagedSessionEnv(args)) {
370
+ const out = await runCtlCommand(`/model info ${modelId}`, formatJson);
371
+ printCtlResult(out, formatJson);
372
+ return;
373
+ }
371
374
  const sel = parseSelector(args, formatJson);
372
375
  const self = getArgValue(args, '--self');
373
376
  const info = await getModelInfo(modelId, self);
@@ -587,5 +590,9 @@ export async function cmdModel(args) {
587
590
  console.log(HELP);
588
591
  return;
589
592
  }
593
+ if (process.env.EVOLCORE_SESSION_ID
594
+ && (getArgValue(args, '--self') || getArgValue(args, '--peer') || getArgValue(args, '--role'))) {
595
+ fail(formatJson, 'NOT_ALLOWED', 'Managed model commands cannot select --self, --peer, or --role; they always use the authenticated current relation');
596
+ }
590
597
  await dispatch(sub, args, formatJson);
591
598
  }
@@ -1,5 +1,6 @@
1
1
  import { resolvePaths } from '../paths.js';
2
2
  import { ipcQuery } from '../ipc.js';
3
+ import { AGENT_DELEGATION_TOKEN_ENV } from '../core/auth/agent-delegation.js';
3
4
  import { getArgValue, isHelpFlag, wantsHelp } from './help.js';
4
5
  export async function cmdQueue(args) {
5
6
  if (args.length === 0 || isHelpFlag(args[0]) || wantsHelp(args)) {
@@ -72,7 +73,16 @@ export async function cmdQueue(args) {
72
73
  : interrupt ? 'interrupt'
73
74
  : undefined;
74
75
  const socketPath = resolvePaths().socket;
75
- const result = await ipcQuery(socketPath, {
76
+ const managed = process.env.EVOLCORE_SESSION_ID;
77
+ const result = await ipcQuery(socketPath, managed ? {
78
+ type: 'queue.op',
79
+ agent,
80
+ action,
81
+ messageId: cancelMsgId,
82
+ sessionKey,
83
+ sessionId: managed,
84
+ delegationToken: process.env[AGENT_DELEGATION_TOKEN_ENV],
85
+ } : {
76
86
  type: 'queue-snapshot',
77
87
  agent,
78
88
  action,
@@ -15,6 +15,9 @@ import { readField, writeField, determineFieldScope, normalizePeer, ModelScopeEr
15
15
  import { readFieldWithSource, resolveAgentConfig, ConfigTarget } from '../config/config-manager.js';
16
16
  import { ResponseModeRegistry } from '../response-system/registry.js';
17
17
  import { registerBuiltinModes } from '../response-system/modes/index.js';
18
+ import { ipcQuery } from '../ipc.js';
19
+ import { resolvePaths } from '../paths.js';
20
+ import { AGENT_DELEGATION_TOKEN_ENV } from '../core/auth/agent-delegation.js';
18
21
  const RESPONSE_MODE_FIELD = 'responseMode';
19
22
  const RESPONSE_MODE_PARAMS_FIELD = 'responseModeParams';
20
23
  /**
@@ -54,6 +57,71 @@ function fail(formatJson, code, message) {
54
57
  }
55
58
  process.exit(1);
56
59
  }
60
+ async function managedConfig(fieldArgs, formatJson) {
61
+ const sessionId = process.env.EVOLCORE_SESSION_ID;
62
+ const delegationToken = process.env[AGENT_DELEGATION_TOKEN_ENV];
63
+ if (!sessionId)
64
+ fail(formatJson, 'NO_SESSION', 'EVOLCORE_SESSION_ID is not set');
65
+ if (!delegationToken)
66
+ fail(formatJson, 'DELEGATION_REQUIRED', `${AGENT_DELEGATION_TOKEN_ENV} is not set`);
67
+ const response = await ipcQuery(resolvePaths().socket, {
68
+ type: 'config.op',
69
+ argv: ['config', ...fieldArgs],
70
+ sessionId,
71
+ delegationToken,
72
+ }, 10_000);
73
+ if (!response)
74
+ fail(formatJson, 'DAEMON_UNAVAILABLE', 'Unable to connect to the evolcore daemon');
75
+ if (!response.ok || !response.result?.ok) {
76
+ fail(formatJson, response.code || 'CONFIG_AUTH_FAILED', response.error || 'response config operation failed');
77
+ }
78
+ return response.result;
79
+ }
80
+ async function runManagedResponse(args, formatJson) {
81
+ if (getArgValue(args, '--self') || getArgValue(args, '--peer')) {
82
+ fail(formatJson, 'NOT_ALLOWED', 'Managed response commands always use the authenticated current relation');
83
+ }
84
+ const sub = args[0];
85
+ if (sub === 'set') {
86
+ const id = args[1];
87
+ if (!id)
88
+ fail(formatJson, 'MISSING_ID', 'set requires a mode id');
89
+ if (!buildRegistry().has(id))
90
+ fail(formatJson, 'UNKNOWN_MODE', `Unknown mode: ${id}`);
91
+ await managedConfig(['set', RESPONSE_MODE_FIELD, id], formatJson);
92
+ return emit(formatJson, { ok: true, scope: 'relation', mode: id }, () => `✓ 已设置响应模式为 ${id}(作用域: relation)`);
93
+ }
94
+ if (sub === 'reset') {
95
+ await managedConfig(['unset', RESPONSE_MODE_FIELD], formatJson);
96
+ return emit(formatJson, { ok: true, scope: 'relation' }, () => '✓ 已清除当前关系的响应模式设置');
97
+ }
98
+ if (sub === 'current') {
99
+ const modeResult = await managedConfig(['get', RESPONSE_MODE_FIELD], formatJson);
100
+ const paramsResult = await managedConfig(['get', RESPONSE_MODE_PARAMS_FIELD], formatJson);
101
+ const configuredMode = typeof modeResult.value === 'string' ? modeResult.value : undefined;
102
+ const mode = configuredMode ?? buildRegistry().getPreferredId();
103
+ return emit(formatJson, {
104
+ ok: true,
105
+ scope: 'relation',
106
+ responseMode: mode ?? null,
107
+ source: configuredMode ? 'config' : 'preferred',
108
+ sourceTarget: modeResult.source?.target ?? null,
109
+ responseModeParams: paramsResult.value ?? null,
110
+ }, () => `当前响应模式: ${mode ?? '(无)'}`);
111
+ }
112
+ if (sub === 'config') {
113
+ if (args[1] === 'set') {
114
+ fail(formatJson, 'NOT_ALLOWED', 'responseModeParams cannot be modified as an opaque object from a managed task');
115
+ }
116
+ const result = await managedConfig(['get', RESPONSE_MODE_PARAMS_FIELD], formatJson);
117
+ const params = result.value ?? {};
118
+ const modeId = args[1] && !args[1].startsWith('--') ? args[1] : undefined;
119
+ if (modeId)
120
+ return emit(formatJson, { ok: true, mode: modeId, config: params[modeId] ?? {} }, () => `${modeId} 配置: ${JSON.stringify(params[modeId] ?? {}, null, 2)}`);
121
+ return emit(formatJson, { ok: true, responseModeParams: params }, () => `所有模式配置: ${JSON.stringify(params, null, 2)}`);
122
+ }
123
+ fail(formatJson, 'NOT_ALLOWED', `response ${sub} is not available in a managed task`);
124
+ }
57
125
  /** 解析 --self / --peer 为作用域选择器 */
58
126
  function parseSelector(args, formatJson) {
59
127
  const self = getArgValue(args, '--self');
@@ -331,6 +399,9 @@ export async function cmdResponse(args) {
331
399
  console.log(HELP);
332
400
  return;
333
401
  }
402
+ if (process.env.EVOLCORE_SESSION_ID && sub !== 'list' && sub !== 'info') {
403
+ return runManagedResponse(args, formatJson);
404
+ }
334
405
  switch (sub) {
335
406
  case 'list': return cmdList(args, formatJson);
336
407
  case 'current': return cmdCurrent(args, formatJson);
@@ -6,8 +6,7 @@ import { resolvePaths, getPackageRoot, daemonControlDir } from '../paths.js';
6
6
  import { loadDaemonConfig } from '../config-store.js';
7
7
  import * as platform from '../utils/cross-platform.js';
8
8
  import { EventBus } from '../core/event-bus.js';
9
- import { tryUpgrade, tryUpgradeAunSdk, tryUpgradeGlobalPkg, resolveGlobalPkg } from '../utils/npm-ops.js';
10
- import { resolveAunCoreSdkPkg, AUN_CORE_SDK_PKG } from '../aun/aid/client.js';
9
+ import { tryUpgrade, tryUpgradeGlobalPkg, resolveGlobalPkg } from '../utils/npm-ops.js';
11
10
  import { isValidAid } from '../aun/aid/index.js';
12
11
  import { msgSend } from '../aun/msg/index.js';
13
12
  import { scanInstances, cleanupInstances, writeRestartMonitor, removeRestartMonitor, isRestartMonitorWinner, findOrphanProcesses, killOrphans } from '../utils/instance-registry.js';
@@ -181,21 +180,7 @@ export async function cmdRestartMonitor() {
181
180
  await notifyDaemonOwners(p, `⚠️ evolcore 升级失败,使用当前版本继续\n${upgrade.error ?? ''}`.trim(), log);
182
181
  break;
183
182
  }
184
- // AUN SDK 版本检查与升级
185
- const aunUpgrade = await tryUpgradeAunSdk(resolveAunCoreSdkPkg, AUN_CORE_SDK_PKG);
186
- switch (aunUpgrade.status) {
187
- case 'upgraded':
188
- log(`✅ AUN SDK upgraded: ${aunUpgrade.from} → ${aunUpgrade.to}`);
189
- await notifyDaemonOwners(p, `📦 AUN SDK 已升级 ${aunUpgrade.from} → ${aunUpgrade.to}`, log);
190
- break;
191
- case 'no-update':
192
- break;
193
- case 'failed':
194
- log(`⚠ AUN SDK upgrade failed (${aunUpgrade.from} → ${aunUpgrade.to}): ${aunUpgrade.error}`);
195
- await notifyDaemonOwners(p, `⚠️ AUN SDK 升级失败,使用当前版本继续\n${aunUpgrade.error ?? ''}`.trim(), log);
196
- break;
197
- }
198
- // EC Web 版本检查与升级(已安装才检查)
183
+ // EC Web 是独立全局包,已安装时单独检查升级。
199
184
  const ecwebUpgrade = await tryUpgradeGlobalPkg(() => resolveGlobalPkg(WEB_PACKAGE_NAME), WEB_PACKAGE_NAME);
200
185
  switch (ecwebUpgrade.status) {
201
186
  case 'upgraded':
@@ -392,7 +377,7 @@ async function spawnAndWaitReady(p, log, timeout) {
392
377
  const out = fs.openSync(stdoutLog, 'a');
393
378
  const err = fs.openSync(stdoutLog, 'a');
394
379
  const appMain = path.join(getPackageRoot(), 'dist', 'index.js');
395
- const child = spawn('node', ['--no-warnings=ExperimentalWarning', appMain], {
380
+ const child = spawn(process.execPath, ['--no-warnings=ExperimentalWarning', appMain], {
396
381
  detached: true,
397
382
  stdio: ['ignore', out, err],
398
383
  windowsHide: true,
@@ -489,6 +474,7 @@ async function invokeClaude(claudePath, p, attempt, maxAttempts, timeout, log) {
489
474
  timeout,
490
475
  // Windows npm CLIs are .cmd shims; execFile needs a shell to run them.
491
476
  shell: platform.isWindows,
477
+ windowsHide: platform.isWindows,
492
478
  env: { ...process.env, CLAUDE_CODE_ENTRYPOINT: 'cli' },
493
479
  maxBuffer: 10 * 1024 * 1024,
494
480
  });
@@ -31,6 +31,10 @@ export async function cmdTrigger(args) {
31
31
  }
32
32
  return;
33
33
  }
34
+ if (process.env.EVOLCORE_SESSION_ID
35
+ && (hasOption(rest, '--file') || hasOption(rest, '--prompt-file') || hasOption(rest, '--script-path'))) {
36
+ throw new Error('Managed tasks cannot read trigger definitions, prompts, or scripts from local files; use inline prompt content');
37
+ }
34
38
  try {
35
39
  switch (sub) {
36
40
  case 'list':
@@ -35,11 +35,15 @@ export function getManagementCommandPermissions(role) {
35
35
  'role.revoke': { allow: true, scopes: ['agent'] },
36
36
  'role.policy.read': { allow: true, scopes: ['agent'] },
37
37
  'role.relation.read': { allow: true, scopes: ['agent'] },
38
+ 'agent.baseagent.update': { allow: true, scopes: ['agent'], constraints: { requireAgentOwner: true, targetCurrentAgentOnly: true } },
38
39
  'observable.current': { allow: true, scopes: ['agent'], constraints: { requireAgentOwner: true } },
40
+ 'observable.update': { allow: true, scopes: ['agent'], constraints: { requireAgentOwner: true, targetCurrentAgentOnly: true } },
39
41
  'config.get': { allow: true, scopes: ['agent', 'relation'] },
40
42
  'config.set': { allow: true, scopes: ['agent', 'relation'] },
41
43
  'config.unset': { allow: true, scopes: ['agent', 'relation'] },
42
44
  'role.policy.write': { allow: true, dangerous: true, scopes: ['agent'], constraints: { requireAgentAdmin: true } },
45
+ 'ec.aid.show': { allow: true, dangerous: true, scopes: ['agent'], constraints: { targetCurrentAgentOnly: true, requireAgentAdmin: true } },
46
+ 'ec.aid.agentmd.put': { allow: true, dangerous: true, scopes: ['agent'], constraints: { targetCurrentAgentOnly: true, requireAgentAdmin: true } },
43
47
  '*': { allow: true },
44
48
  // Reload is valid from an Agent's own channel and the daemon control plane.
45
49
  // The control-channel gate still requires a daemon owner for cross-Agent reloads.
@@ -184,17 +184,25 @@ function mutateContactBookPreparedSyncInternal(selfAid, prepare) {
184
184
  value: prepared.value,
185
185
  };
186
186
  }
187
- prepared.beforeCommit?.({
187
+ const preparedChanges = prepared.beforeCommit?.({
188
188
  contact: next,
189
189
  previousContactRevision: previousRevision,
190
190
  contactRevision: nextRevision,
191
191
  });
192
192
  const before = readTextOrNull(file);
193
193
  const after = `${JSON.stringify(next, null, 2)}\n`;
194
- const changes = [{ file, before, after }];
194
+ const additionalChanges = preparedChanges === undefined
195
+ ? []
196
+ : Array.isArray(preparedChanges) ? preparedChanges : [preparedChanges];
197
+ const changes = [
198
+ { file, before, after },
199
+ ...additionalChanges,
200
+ ];
201
+ validateTransactionChanges(selfAid, changes);
195
202
  writeJournal(selfAid, { schemaVersion: 1, state: 'prepared', selfAid, changes });
196
203
  try {
197
- applyTransactionChange(changes[0]);
204
+ for (const change of changes)
205
+ applyTransactionChange(change);
198
206
  writeJournal(selfAid, { schemaVersion: 1, state: 'committed', selfAid, changes });
199
207
  removeJournal(selfAid);
200
208
  }
@@ -1,6 +1,5 @@
1
1
  import crypto from 'crypto';
2
2
  import fs from 'fs';
3
- import path from 'path';
4
3
  import { isValidAid } from '../aun/aid/validation.js';
5
4
  import { agentContactOperations } from '../paths.js';
6
5
  import { listStaticAgentAdmins, listStaticAgentOwners } from './peer-role-resolver.js';
@@ -12,6 +11,7 @@ export const CONTACT_REQUEST_MAX_PER_WINDOW = 3;
12
11
  export const CONTACT_REQUEST_MAX_PENDING = 100;
13
12
  export const CONTACT_REQUEST_MAX_RETAINED = 1000;
14
13
  export const CONTACT_REQUEST_NOTE_MAX_LENGTH = 500;
14
+ export const CONTACT_REQUEST_ID_MAX_LENGTH = 128;
15
15
  export async function mutateContactWithOperation(input) {
16
16
  const at = new Date(input.now ?? Date.now()).toISOString();
17
17
  const logFile = agentContactOperations(input.selfAid);
@@ -26,7 +26,7 @@ export async function mutateContactWithOperation(input) {
26
26
  mutation: input.mutation,
27
27
  actor: input.actorId,
28
28
  value: undefined,
29
- beforeCommit: ({ contactRevision: nextRevision }) => appendOperationRecords(logFile, [operationRecord({
29
+ beforeCommit: ({ contactRevision: nextRevision }) => operationLogChange(logFile, [operationRecord({
30
30
  at,
31
31
  op: input.operation,
32
32
  primaryId: input.primaryId,
@@ -73,7 +73,17 @@ export async function submitContactRequest(input) {
73
73
  && Date.parse(record.at) > windowStart
74
74
  && Date.parse(record.at) <= nowMs)).length;
75
75
  if (recentCount >= CONTACT_REQUEST_MAX_PER_WINDOW) {
76
- return preparedWithExpirations(expiration, { code: 'rate-limited', contactRevision: '' }, logFile);
76
+ return preparedWithExpirations(expiration, {
77
+ code: 'rate-limited',
78
+ ...(effectiveEntry?.status === 'pending' && effectiveEntry.pendingRequest
79
+ ? {
80
+ requestId: effectiveEntry.pendingRequest.id,
81
+ submittedAt: effectiveEntry.pendingRequest.submittedAt,
82
+ expiresAt: effectiveEntry.pendingRequest.expiresAt,
83
+ }
84
+ : {}),
85
+ contactRevision: '',
86
+ }, logFile);
77
87
  }
78
88
  const counts = countRequestRecordsAfterExpirations(contact, nowMs);
79
89
  const isExistingPending = effectiveEntry?.status === 'pending';
@@ -103,13 +113,14 @@ export async function submitContactRequest(input) {
103
113
  mutation: { type: 'batch', mutations },
104
114
  actor: applicantAid,
105
115
  value,
106
- beforeCommit: ({ contactRevision }) => appendOperationRecords(logFile, [
116
+ beforeCommit: ({ contactRevision }) => operationLogChange(logFile, [
107
117
  ...expiration.events.map(event => ({ ...event, contactRevision })),
108
118
  operationRecord({
109
119
  at: nowIso,
110
120
  op,
111
121
  primaryId: applicantAid,
112
122
  requestId,
123
+ expiresAt,
113
124
  sourceChannelKey: input.sourceChannelKey,
114
125
  ...(note ? { note } : {}),
115
126
  contactRevision,
@@ -143,6 +154,10 @@ export async function reviewContactRequest(input) {
143
154
  if (!entry || entry.status !== 'pending' || entry.pendingRequest?.id !== input.requestId) {
144
155
  return preparedNoop({ code: 'request-stale', contactRevision });
145
156
  }
157
+ const requestMetadata = {
158
+ requestId: entry.pendingRequest.id,
159
+ expiresAt: entry.pendingRequest.expiresAt,
160
+ };
146
161
  if (Date.parse(entry.pendingRequest.expiresAt) <= nowMs) {
147
162
  const mutation = {
148
163
  type: 'expire-request',
@@ -153,8 +168,8 @@ export async function reviewContactRequest(input) {
153
168
  return {
154
169
  mutation,
155
170
  actor: input.actorId,
156
- value: { code: 'request-expired', contactRevision },
157
- beforeCommit: ({ contactRevision: nextRevision }) => appendOperationRecords(logFile, [operationRecord({
171
+ value: { code: 'request-expired', contactRevision, ...requestMetadata },
172
+ beforeCommit: ({ contactRevision: nextRevision }) => operationLogChange(logFile, [operationRecord({
158
173
  at: nowIso,
159
174
  op: 'expire',
160
175
  primaryId: input.primaryId,
@@ -177,8 +192,8 @@ export async function reviewContactRequest(input) {
177
192
  now: nowIso,
178
193
  },
179
194
  actor: input.actorId,
180
- value: { code, contactRevision },
181
- beforeCommit: ({ contactRevision: nextRevision }) => appendOperationRecords(logFile, [operationRecord({
195
+ value: { code, contactRevision, ...requestMetadata },
196
+ beforeCommit: ({ contactRevision: nextRevision }) => operationLogChange(logFile, [operationRecord({
182
197
  at: nowIso,
183
198
  op: input.decision === 'approve' ? 'approve' : input.decision === 'reject' ? 'reject' : 'block',
184
199
  primaryId: input.primaryId,
@@ -208,7 +223,7 @@ export async function expirePendingContactRequests(selfAid, now = Date.now()) {
208
223
  mutation: { type: 'batch', mutations: expiration.mutations },
209
224
  actor: 'contact-request-expiry',
210
225
  value: expiration.mutations.length,
211
- beforeCommit: ({ contactRevision }) => appendOperationRecords(logFile, expiration.events.map(event => ({ ...event, contactRevision }))),
226
+ beforeCommit: ({ contactRevision }) => operationLogChange(logFile, expiration.events.map(event => ({ ...event, contactRevision }))),
212
227
  };
213
228
  });
214
229
  return result.value;
@@ -226,6 +241,91 @@ export async function listPendingContactRequests(selfAid, now = Date.now()) {
226
241
  }))));
227
242
  return result.value;
228
243
  }
244
+ /** Return only the authenticated applicant's own admission/request state. */
245
+ export async function queryOwnContactRequestStatus(input) {
246
+ const applicantAid = String(input.applicantAid || '').trim();
247
+ if (!isValidAid(applicantAid))
248
+ return protocolResult('NOT_ALLOWED');
249
+ const now = input.now ?? Date.now();
250
+ const rawRequestId = input.requestId;
251
+ if (rawRequestId !== undefined && typeof rawRequestId !== 'string')
252
+ return protocolResult('NOT_ALLOWED');
253
+ const requestedRequestId = rawRequestId?.trim();
254
+ if (requestedRequestId !== undefined
255
+ && (!requestedRequestId || requestedRequestId.length > CONTACT_REQUEST_ID_MAX_LENGTH)) {
256
+ return protocolResult('NOT_ALLOWED');
257
+ }
258
+ const owners = listAgentOwners(input.selfAid);
259
+ const result = await mutateContactBookPrepared(input.selfAid, ({ contact }) => {
260
+ const logFile = agentContactOperations(input.selfAid);
261
+ const expiration = collectExpiredMutations(contact, now);
262
+ const entry = effectiveEntryAfterExpirations(contact.contacts[applicantAid], now);
263
+ const operations = readOperationLog(logFile).filter(record => record.primaryId === applicantAid);
264
+ const latestRequestOperation = [...operations].reverse().find(record => (!!record.requestId
265
+ && (record.op === 'request' || record.op === 'resubmit' || record.op === 'approve'
266
+ || record.op === 'reject' || record.op === 'expire' || record.op === 'block')));
267
+ if (requestedRequestId !== undefined && latestRequestOperation?.requestId !== requestedRequestId) {
268
+ return preparedNoop(protocolResult('NOT_ALLOWED'));
269
+ }
270
+ const requestMetadata = requestMetadataFor(operations, latestRequestOperation?.requestId);
271
+ let value;
272
+ if (owners.includes(applicantAid)
273
+ || (entry && (entry.status === undefined || entry.status === 'active'))) {
274
+ value = protocolResult('APPROVED', requestMetadata);
275
+ }
276
+ else if (entry?.status === 'blocked') {
277
+ value = protocolResult('NOT_ALLOWED');
278
+ }
279
+ else if (entry?.status === 'pending' && entry.pendingRequest) {
280
+ value = protocolResult('PENDING', {
281
+ requestId: entry.pendingRequest.id,
282
+ expiresAt: entry.pendingRequest.expiresAt,
283
+ });
284
+ }
285
+ else if (entry?.status === 'declined') {
286
+ value = protocolResult('EXPIRED', {
287
+ requestId: latestRequestOperation?.op === 'expire' ? latestRequestOperation.requestId : requestMetadata.requestId,
288
+ expiresAt: entry.declinedAt ?? requestMetadata.expiresAt,
289
+ });
290
+ }
291
+ else if (latestRequestOperation?.op === 'reject') {
292
+ value = protocolResult('REJECTED', requestMetadata);
293
+ }
294
+ else if (latestRequestOperation?.op === 'expire') {
295
+ value = protocolResult('EXPIRED', requestMetadata);
296
+ }
297
+ else if (owners.length === 0) {
298
+ value = protocolResult('NOT_ALLOWED');
299
+ }
300
+ else {
301
+ value = protocolResult('CONTACT_REQUIRED');
302
+ }
303
+ if (expiration.mutations.length === 0)
304
+ return preparedNoop(value);
305
+ return {
306
+ mutation: { type: 'batch', mutations: expiration.mutations },
307
+ actor: 'contact-request-expiry',
308
+ value,
309
+ beforeCommit: ({ contactRevision }) => operationLogChange(logFile, expiration.events.map(event => ({ ...event, contactRevision }))),
310
+ };
311
+ });
312
+ return result.value;
313
+ }
314
+ export function contactRequestSubmissionProtocolResult(result) {
315
+ const metadata = { requestId: result.requestId, expiresAt: result.expiresAt };
316
+ switch (result.code) {
317
+ case 'submitted':
318
+ case 'resubmitted':
319
+ return protocolResult('SUBMITTED', metadata);
320
+ case 'already-contact':
321
+ case 'owner':
322
+ return protocolResult('APPROVED', metadata);
323
+ case 'rate-limited':
324
+ return protocolResult('RATE_LIMITED', metadata);
325
+ default:
326
+ return protocolResult('NOT_ALLOWED', metadata);
327
+ }
328
+ }
229
329
  export function findContactRequestNote(selfAid, requestId) {
230
330
  const records = readOperationLog(agentContactOperations(selfAid));
231
331
  return [...records].reverse().find(record => (record.requestId === requestId
@@ -237,6 +337,24 @@ function normalizeNote(value) {
237
337
  return null;
238
338
  return note;
239
339
  }
340
+ function requestMetadataFor(operations, requestId) {
341
+ if (!requestId)
342
+ return {};
343
+ const submitted = [...operations].reverse().find(record => (record.requestId === requestId && (record.op === 'request' || record.op === 'resubmit')));
344
+ const submittedAt = submitted ? Date.parse(submitted.at) : Number.NaN;
345
+ return {
346
+ requestId,
347
+ expiresAt: submitted?.expiresAt
348
+ ?? (Number.isFinite(submittedAt) ? new Date(submittedAt + CONTACT_REQUEST_TTL_MS).toISOString() : undefined),
349
+ };
350
+ }
351
+ function protocolResult(status, metadata = {}) {
352
+ return {
353
+ status,
354
+ requestId: metadata.requestId ?? null,
355
+ expiresAt: metadata.expiresAt ?? null,
356
+ };
357
+ }
240
358
  function collectExpiredMutations(book, now) {
241
359
  const mutations = [];
242
360
  const events = [];
@@ -286,7 +404,7 @@ function preparedWithExpirations(expiration, value, logFile) {
286
404
  mutation: { type: 'batch', mutations: expiration.mutations },
287
405
  actor: 'contact-request-expiry',
288
406
  value,
289
- beforeCommit: ({ contactRevision }) => appendOperationRecords(logFile, expiration.events.map(event => ({ ...event, contactRevision }))),
407
+ beforeCommit: ({ contactRevision }) => operationLogChange(logFile, expiration.events.map(event => ({ ...event, contactRevision }))),
290
408
  };
291
409
  }
292
410
  function preparedNoop(value) {
@@ -310,7 +428,14 @@ function readOperationLog(file) {
310
428
  const record = JSON.parse(line);
311
429
  if (!record || typeof record !== 'object' || typeof record.at !== 'string'
312
430
  || !Number.isFinite(Date.parse(record.at)) || typeof record.op !== 'string'
313
- || typeof record.primaryId !== 'string')
431
+ || !['request', 'resubmit', 'approve', 'reject', 'expire', 'block', 'unblock', 'manual-add'].includes(record.op)
432
+ || typeof record.primaryId !== 'string' || !isValidAid(record.primaryId)
433
+ || (record.requestId !== undefined && typeof record.requestId !== 'string')
434
+ || (record.expiresAt !== undefined && (!isValidIsoDate(record.expiresAt)))
435
+ || (record.actorId !== undefined && typeof record.actorId !== 'string')
436
+ || (record.sourceChannelKey !== undefined && typeof record.sourceChannelKey !== 'string')
437
+ || (record.note !== undefined && (typeof record.note !== 'string' || record.note.length > CONTACT_REQUEST_NOTE_MAX_LENGTH))
438
+ || typeof record.contactRevision !== 'string')
314
439
  throw new Error('invalid record');
315
440
  records.push(record);
316
441
  }
@@ -320,11 +445,32 @@ function readOperationLog(file) {
320
445
  }
321
446
  return records;
322
447
  }
323
- function appendOperationRecords(file, records) {
448
+ function isValidIsoDate(value) {
449
+ return Number.isFinite(Date.parse(value));
450
+ }
451
+ function operationLogChange(file, records) {
324
452
  if (records.length === 0)
325
- return;
326
- fs.mkdirSync(path.dirname(file), { recursive: true });
327
- fs.appendFileSync(file, `${records.map(record => JSON.stringify(record)).join('\n')}\n`, 'utf8');
453
+ return undefined;
454
+ // Validate the existing log while the Contact Book lock is held. The log is
455
+ // part of the same journal transaction as contact.json, so a malformed log
456
+ // must fail closed before either file is changed.
457
+ readOperationLog(file);
458
+ let before;
459
+ try {
460
+ before = fs.readFileSync(file, 'utf8');
461
+ }
462
+ catch (error) {
463
+ if (error?.code === 'ENOENT')
464
+ before = null;
465
+ else
466
+ throw error;
467
+ }
468
+ const prefix = before && !before.endsWith('\n') ? `${before}\n` : (before ?? '');
469
+ return {
470
+ file,
471
+ before,
472
+ after: `${prefix}${records.map(record => JSON.stringify(record)).join('\n')}\n`,
473
+ };
328
474
  }
329
475
  function operationRecord(input) {
330
476
  return { id: `evt_${crypto.randomUUID().replace(/-/g, '')}`, ...input };