@evomap/evolver-proxy 2.0.19 → 2.0.24

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.
@@ -4,7 +4,7 @@ import { readFileSync, realpathSync, rmSync } from 'node:fs';
4
4
  import { homedir } from 'node:os';
5
5
  import { join, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
- import { bootstrap as coreBootstrap, daemon, hub as hubNs, mailbox, util } from '@evomap/evolver-core';
7
+ import { bootstrap as coreBootstrap, daemon, hub as hubNs, mailbox, util, verify } from '@evomap/evolver-core';
8
8
  import { AtpHubClient, connectPublicHub, globalFetchLike, isNodeSecret, parseNodeSecretVersion } from '@evomap/evolver-adapter-public';
9
9
  import { ProxyDaemon } from '../daemon/proxyDaemon.js';
10
10
  import { resolveHubMode, resolveHubUrl } from '../daemon/selectHub.js';
@@ -382,6 +382,7 @@ export function proxyUsage(command = 'evolver-proxy') {
382
382
  '',
383
383
  'Useful options are configured through env or EVOLVER_ENV_FILE:',
384
384
  ' EVOLVER_IPC_PORT, EVOLVER_IPC_TOKEN, EVOLVER_PROXY_SETTINGS_FILE',
385
+ ' EVOLVER_NATIVE_PUBLISH_VERIFIER=1, EVOLVER_PUBLISH_VALIDATION_ROOT=<dir> (必须显式设置)',
385
386
  ' EVOLVER_SELF_UPDATE, EVOLVER_LLM_TRACE_CAPTURE_BODIES',
386
387
  '',
387
388
  ].join('\n');
@@ -656,6 +657,7 @@ export function createProxyDaemonDeps(options) {
656
657
  const env = options.env ?? process.env;
657
658
  const traceBackfill = resolveTraceBackfillConfig(env);
658
659
  const heartbeatIntervalMs = positiveIntegerEnv(env['HEARTBEAT_INTERVAL_MS']);
660
+ const publishExecutionVerifier = resolveNativePublishExecutionVerifier(env);
659
661
  return {
660
662
  hub: options.runtime.hub,
661
663
  ...(options.hubMode ? { hubMode: options.hubMode } : {}),
@@ -673,6 +675,59 @@ export function createProxyDaemonDeps(options) {
673
675
  ...(options.runtime.helloMode ? { helloMode: options.runtime.helloMode } : {}),
674
676
  ...(selfUpdate ? { selfUpdate } : {}),
675
677
  ...(traceBackfill ? { traceBackfill } : {}),
678
+ ...(publishExecutionVerifier ? { publishExecutionVerifier } : {}),
679
+ };
680
+ }
681
+ /**
682
+ * 仅在显式启用且具备完整 OS 隔离时接入本地发布验证器;默认仍保持 draft-only。
683
+ * 这样不会把普通桌面进程意外变成可发布执行器,也不会在 Windows/macOS 上静默降级为弱隔离。
684
+ */
685
+ function resolveNativePublishExecutionVerifier(env) {
686
+ if (env['EVOLVER_NATIVE_PUBLISH_VERIFIER']?.trim() !== '1')
687
+ return undefined;
688
+ // 原生发布验证器只能在完整的 OS 隔离可用时装配。Windows/macOS 目前没有
689
+ // 与 Linux namespace、只读文件系统和 cgroup 等价的实现,必须保持 draft-only,
690
+ // 不能先暴露一个运行时必然失败的“验证器”能力。
691
+ try {
692
+ if (!verify.readOnlyIsolationAvailable())
693
+ return undefined;
694
+ }
695
+ catch {
696
+ // 隔离探测本身失败也必须保持 fail-closed,而不能阻止代理启动。
697
+ return undefined;
698
+ }
699
+ // 发布验证必须绑定到调用方明确配置的项目根目录;回退到 daemon 当前目录会让验证对象与发布对象脱钩。
700
+ const configuredRoot = env['EVOLVER_PUBLISH_VALIDATION_ROOT']?.trim();
701
+ if (!configuredRoot)
702
+ return undefined;
703
+ const validationRoot = resolve(configuredRoot);
704
+ return async (input, signal) => {
705
+ const commands = Array.isArray(input.validation)
706
+ ? input.validation.filter((value) => typeof value === 'string').map((value) => value.trim()).filter(Boolean)
707
+ : [];
708
+ if (commands.length === 0
709
+ || commands.length > 8
710
+ || !Array.isArray(input.validation)
711
+ || input.validation.length !== commands.length
712
+ || commands.some((command) => command.length > 180
713
+ || !verify.isValidationCommandAllowed(command)
714
+ || verify.sanitizeExecutionCommand(command).blocked)
715
+ || signal.aborted)
716
+ return null;
717
+ const result = await verify.runSandboxedValidation(commands, validationRoot, {
718
+ requireIsolation: true,
719
+ signal,
720
+ });
721
+ if (signal.aborted || !result.passed || result.results.length !== commands.length)
722
+ return null;
723
+ return {
724
+ validation: commands,
725
+ trace: result.results.map((row) => ({
726
+ command: row.cmd,
727
+ exit: row.exitCode ?? 1,
728
+ ...(row.stdoutSummary ? { summary: row.stdoutSummary } : {}),
729
+ })),
730
+ };
676
731
  };
677
732
  }
678
733
  function positiveIntegerEnv(value) {
@@ -6,6 +6,7 @@ import type { AtpOrderConsentGate } from './atpConsent.js';
6
6
  import { type PublishRecallVerifierPort } from './publishRecallVerifier.js';
7
7
  type HubCapability = hubNs.HubCapability;
8
8
  type AssetStoreProvider = assetstore.AssetStoreProvider;
9
+ type ConversationDistillExecutionVerifier = (input: hubNs.ConversationDistillInput, signal: AbortSignal) => Promise<hubNs.ConversationDistillVerifiedExecution | null>;
9
10
  export declare const DEFAULT_IPC_PORT = 19820;
10
11
  export interface ProxyDaemonDeps {
11
12
  hub: HubCapability;
@@ -75,6 +76,12 @@ export interface ProxyDaemonDeps {
75
76
  publishRecallVerifier?: PublishRecallVerifierPort;
76
77
  /** Optional deterministic sanitizer environment for composition tests. Production omits this to scan process.env. */
77
78
  publishSanitizeEnv?: Record<string, string | undefined>;
79
+ /**
80
+ * 宿主专属的真实执行验证器。缺失时,conversation distill 只能返回草稿,绝不持久化或排队发布。
81
+ */
82
+ publishExecutionVerifier?: ConversationDistillExecutionVerifier;
83
+ /** 验证器响应的硬超时,防止发布请求因宿主执行器挂起而永久占用连接。 */
84
+ publishExecutionVerifierTimeoutMs?: number;
78
85
  }
79
86
  export interface ProxyTickReport {
80
87
  outbound: OutboundResult;
@@ -144,6 +151,8 @@ export declare class ProxyDaemon {
144
151
  private readonly collaborationFacade;
145
152
  private readonly publishRecallVerifier;
146
153
  private readonly proxyHandler;
154
+ private readonly hub;
155
+ private readonly recipeComposeStarted;
147
156
  private ipc;
148
157
  private readonly now;
149
158
  private readonly random;
@@ -178,6 +187,8 @@ export declare class ProxyDaemon {
178
187
  private scheduledForceUpdateKey;
179
188
  private traceBackfillDraining;
180
189
  private loopWakeHandler;
190
+ /** 守护进程停止时取消宿主验证。 */
191
+ private publishAbortController;
181
192
  constructor(deps: ProxyDaemonDeps);
182
193
  /**
183
194
  * core handler(确定性, 不经 agent): 目前只接 force_update(#108). 其他 core 类型(asset_publish_result/
@@ -231,6 +242,7 @@ export declare class ProxyDaemon {
231
242
  private cacheRemoteAssetSearch;
232
243
  private publishAssetSubmitSynchronously;
233
244
  private createSynchronousAssetSubmitEnvelope;
245
+ private composeRecipeAfterAcceptedSubmit;
234
246
  private currentHubMode;
235
247
  private handleHubModeBoundOutbound;
236
248
  private publishSynchronousBundle;
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { dirname, join } from 'node:path';
3
- import { mailbox, hub as hubNs, shadow as shadow_, assetstore, wire, util } from '@evomap/evolver-core';
3
+ import { mailbox, hub as hubNs, shadow as shadow_, assetstore, wire, util, verify } from '@evomap/evolver-core';
4
4
  import { AuthError, HubClientError, HubUnreachableError } from '@evomap/evolver-adapter-public';
5
5
  import { SyncEngine, SYNC_INTERVALS } from '../sync/engine.js';
6
6
  import { LifecycleManager } from '../lifecycle/manager.js';
@@ -10,6 +10,7 @@ import { backfillProxyTraceUploads } from '../llm/traceBackfill.js';
10
10
  import { hubAuthFailureHint } from './selectHub.js';
11
11
  import { CollaborationFacade } from './collaborationFacade.js';
12
12
  import { PublishRecallVerifier, resolvePublishRecallConfig, } from './publishRecallVerifier.js';
13
+ const DEFAULT_PUBLISH_EXECUTION_VERIFY_TIMEOUT_MS = 30_000;
13
14
  export const DEFAULT_IPC_PORT = 19820;
14
15
  // V1 local-proxy compatibility contract; independent of the V2 mailbox envelope schema.
15
16
  const PROXY_PROTOCOL_VERSION = '0.1.0';
@@ -35,6 +36,27 @@ const SYNC_ASSET_SUBMIT_TYPE_RANK = {
35
36
  const SYNC_ASSET_SUBMIT_SCOPE_STATE_KEY = 'sync_asset_submit:idempotency_scope:v1';
36
37
  const SYNC_ASSET_SUBMIT_DIRECT_RETRY_GRACE_MS = 30_000;
37
38
  const DEFAULT_SYNC_ASSET_SUBMIT_RESPONSE_TIMEOUT_MS = 15_000;
39
+ function withSynchronousPublishStatus(value, publishStatus) {
40
+ const body = isRecordValue(value) ? { ...value } : { receipt: value };
41
+ return { ...body, publish_status: publishStatus, queued: false };
42
+ }
43
+ function legacySynchronousResultStatus(result) {
44
+ if (result.ok)
45
+ return 'accepted';
46
+ // 202 表示兼容层已持久化但尚未完成;校验或终态/传输失败即使在 mailbox
47
+ // 中保留可重试意图,聚合仍归类为 failed。
48
+ return result.statusCode === 202 || result.error === 'publish_pending' ? 'pending' : 'failed';
49
+ }
50
+ function summarizeLegacySynchronousResults(results) {
51
+ const statuses = new Set(results.map(legacySynchronousResultStatus));
52
+ // publish_status 表示聚合终态,而 queued 独立表示是否仍有 durable mailbox 工作。
53
+ // 因此 pending+failed 必须是 failed/true:不能掩盖失败,也不能谎报没有排队。
54
+ if (statuses.has('failed') || statuses.size === 0)
55
+ return { publishStatus: 'failed', queued: statuses.has('pending') };
56
+ if (statuses.has('pending'))
57
+ return { publishStatus: 'pending', queued: true };
58
+ return { publishStatus: 'accepted', queued: false };
59
+ }
38
60
  class OutboundHubModeMismatchError extends Error {
39
61
  retryable = true;
40
62
  retryAfterMs = 1_000;
@@ -64,6 +86,8 @@ export class ProxyDaemon {
64
86
  collaborationFacade;
65
87
  publishRecallVerifier;
66
88
  proxyHandler;
89
+ hub;
90
+ recipeComposeStarted = new Set();
67
91
  ipc;
68
92
  now;
69
93
  random;
@@ -98,6 +122,8 @@ export class ProxyDaemon {
98
122
  scheduledForceUpdateKey;
99
123
  traceBackfillDraining = false;
100
124
  loopWakeHandler;
125
+ /** 守护进程停止时取消宿主验证。 */
126
+ publishAbortController = new AbortController();
101
127
  constructor(deps) {
102
128
  this.deps = deps;
103
129
  this.now = deps.now ?? (() => Date.now());
@@ -124,6 +150,7 @@ export class ProxyDaemon {
124
150
  this.assetStore = deps.assetStore ?? (assetStoreDir ? new assetstore.LocalJsonlProvider(assetStoreDir) : undefined);
125
151
  this.atp = deps.atp;
126
152
  const hubToUse = shadow ? shadow_.shadowHubCapability(deps.hub, deps.shadowSink, 'shadow') : deps.hub;
153
+ this.hub = hubToUse;
127
154
  const hubBindings = hubNs.makeHubBindings(hubToUse, deps.publishSanitizeEnv
128
155
  ? { sanitize: { env: deps.publishSanitizeEnv } }
129
156
  : {});
@@ -196,9 +223,7 @@ export class ProxyDaemon {
196
223
  catch { /* best-effort */ }
197
224
  }
198
225
  // Recipe is the preferred public artifact. Failure here must not retry the already-accepted asset publish.
199
- if (envelope.type === 'asset_submit') {
200
- void hubNs.composeRecipeAfterAssetPublish(hubToUse, asRecord(envelope.payload)).catch(() => { });
201
- }
226
+ this.composeRecipeAfterAcceptedSubmit(envelope);
202
227
  },
203
228
  onOutboundTerminal: (envelope, error) => {
204
229
  this.collaborationFacade.handleOutboundTerminal(envelope, error);
@@ -264,6 +289,8 @@ export class ProxyDaemon {
264
289
  async start() {
265
290
  if (this.started)
266
291
  throw new Error('ProxyDaemon 已启动');
292
+ if (this.publishAbortController.signal.aborted)
293
+ this.publishAbortController = new AbortController();
267
294
  try {
268
295
  this.daemon.start();
269
296
  this.ipc = new mailbox.MailboxIpcServer({
@@ -511,6 +538,9 @@ export class ProxyDaemon {
511
538
  async stop() {
512
539
  this.started = false;
513
540
  this.lifecycleArmed = false;
541
+ if (!this.publishAbortController.signal.aborted) {
542
+ this.publishAbortController.abort(new Error('proxy_daemon_stopped'));
543
+ }
514
544
  this.nextTickDueAt = undefined;
515
545
  if (this.forceUpdateTimer) {
516
546
  clearTimeout(this.forceUpdateTimer);
@@ -813,6 +843,65 @@ export class ProxyDaemon {
813
843
  ctx.json(200, { results, assets: results, query: body });
814
844
  return true;
815
845
  }
846
+ if (ctx.route === 'POST /recipe/search') {
847
+ const body = (await ctx.readJson());
848
+ if (hubModeMismatch(body.expected_hub_mode, this.deps.hubMode)) {
849
+ ctx.json(409, { error: 'proxy_hub_mode_mismatch' });
850
+ return true;
851
+ }
852
+ const recipes = this.hub.recipes;
853
+ if (!recipes) {
854
+ ctx.json(501, { error: 'recipe_unsupported' });
855
+ return true;
856
+ }
857
+ const limit = boundedRequestLimit(body.limit, 10, 50);
858
+ if (limit === undefined) {
859
+ ctx.json(400, { error: 'invalid_limit' });
860
+ return true;
861
+ }
862
+ const q = [body.q, body.query, body.text].find((value) => typeof value === 'string' && value.trim().length > 0);
863
+ const cursor = typeof body.cursor === 'string' && body.cursor.trim() ? body.cursor.trim() : undefined;
864
+ const sort = typeof body.sort === 'string' && body.sort.trim() ? body.sort.trim() : undefined;
865
+ const request = {
866
+ ...(q ? { q } : {}),
867
+ limit,
868
+ ...(cursor ? { cursor } : {}),
869
+ ...(sort ? { sort } : {}),
870
+ };
871
+ const receipt = q ? await recipes.search(request) : await recipes.list(request);
872
+ ctx.json(200, {
873
+ recipes: receipt.recipes,
874
+ ...(receipt.nextCursor ? { nextCursor: receipt.nextCursor } : {}),
875
+ ...(receipt.hasMore !== undefined ? { hasMore: receipt.hasMore } : {}),
876
+ query: body,
877
+ });
878
+ return true;
879
+ }
880
+ if (ctx.route === 'POST /recipe/express') {
881
+ const body = asRecord(await ctx.readJson());
882
+ if (hubModeMismatch(body['expected_hub_mode'], this.deps.hubMode)) {
883
+ ctx.json(409, { error: 'proxy_hub_mode_mismatch' });
884
+ return true;
885
+ }
886
+ const recipes = this.hub.recipes;
887
+ if (!recipes) {
888
+ ctx.json(501, { error: 'recipe_unsupported' });
889
+ return true;
890
+ }
891
+ const recipeId = typeof body['recipe_id'] === 'string'
892
+ ? body['recipe_id']
893
+ : typeof body['recipeId'] === 'string'
894
+ ? body['recipeId']
895
+ : '';
896
+ if (!recipeId.trim()) {
897
+ ctx.json(400, { error: 'recipe_id_required' });
898
+ return true;
899
+ }
900
+ const inputPayload = asRecord(body['input_payload']) ?? asRecord(body['inputPayload']) ?? {};
901
+ const receipt = await recipes.express(recipeId.trim(), { inputPayload });
902
+ ctx.json(200, receipt);
903
+ return true;
904
+ }
816
905
  if (ctx.route === 'POST /asset/fetch') {
817
906
  const body = (await ctx.readJson());
818
907
  if (hubModeMismatch(body.expected_hub_mode, this.deps.hubMode)) {
@@ -885,11 +974,12 @@ export class ProxyDaemon {
885
974
  ctx.json(400, { error: 'mode=sync requires a full asset bundle' });
886
975
  return true;
887
976
  }
888
- await this.publishAssetSubmitSynchronously(ctx, outboundBundle);
977
+ await this.publishAssetSubmitSynchronously(ctx, outboundBundle, hubNs.recipeComposeRequested(body));
889
978
  return true;
890
979
  }
891
980
  const payload = {
892
981
  assets: outboundBundle,
982
+ compose_recipe: hubNs.recipeComposeRequested(body),
893
983
  [OUTBOUND_HUB_MODE_FIELD]: this.currentHubMode(),
894
984
  };
895
985
  const requestId = typeof body['request_id'] === 'string' && ASYNC_ASSET_SUBMIT_REQUEST_ID.test(body['request_id'])
@@ -968,17 +1058,66 @@ export class ProxyDaemon {
968
1058
  }
969
1059
  if (ctx.route === 'POST /conversation/distill') {
970
1060
  const body = (await ctx.readJson());
1061
+ const publishRequested = body['publish'] === true;
1062
+ const abortPublish = () => {
1063
+ if (ctx.signal?.aborted)
1064
+ return true;
1065
+ if (!this.publishAbortController.signal.aborted)
1066
+ return false;
1067
+ // 守护进程停止时仍需结束开放的响应,否则 IPC server.close() 会等待该连接而无法完成停机。
1068
+ if (!ctx.res.destroyed && !ctx.res.writableEnded) {
1069
+ // 'blocked' (not 'failed'): the request was never queued, it was intercepted by
1070
+ // shutdown; the 503 + error field already explains why.
1071
+ ctx.json(503, { error: 'proxy_shutting_down', publish_status: 'blocked', queued: false });
1072
+ }
1073
+ return true;
1074
+ };
971
1075
  if (hubModeMismatch(body.expected_hub_mode, this.deps.hubMode)) {
972
1076
  ctx.json(409, { error: 'proxy_hub_mode_mismatch' });
973
1077
  return true;
974
1078
  }
975
- const distill = await hubNs.distillConversation(body, { persist: body.persist === true, store: this.assetStore });
1079
+ // 请求已取消时禁止继续生成或持久化发布草稿,避免把取消前的陈旧状态留下。
1080
+ if (publishRequested && abortPublish())
1081
+ return true;
1082
+ const verifiedExecution = publishRequested
1083
+ ? await resolveVerifiedExecutionAfterPreflight(this.deps.publishExecutionVerifier, body, this.deps.publishExecutionVerifierTimeoutMs, [this.publishAbortController.signal, ...(ctx.signal ? [ctx.signal] : [])])
1084
+ : undefined;
1085
+ if (publishRequested && abortPublish())
1086
+ return true;
1087
+ const distill = await hubNs.distillConversation(body, {
1088
+ // 调用方明确要求持久化时,即使发布被证据或质量闸门拦截,也保留可审查草稿;永不因此进入队列。
1089
+ persist: body.persist === true,
1090
+ store: this.assetStore,
1091
+ ...(verifiedExecution ? { verifiedExecution } : {}),
1092
+ });
1093
+ if (publishRequested && abortPublish())
1094
+ return true;
976
1095
  if (!distill.ok) {
977
- ctx.json(200, { ...distill, queued: false, submission: null });
1096
+ ctx.json(200, {
1097
+ ...distill,
1098
+ queued: false,
1099
+ submission: null,
1100
+ publish_status: publishRequested ? 'blocked' : 'not_requested',
1101
+ });
978
1102
  return true;
979
1103
  }
980
1104
  let submission = null;
981
- if (body['publish'] === true) {
1105
+ if (publishRequested) {
1106
+ if (abortPublish())
1107
+ return true;
1108
+ // 未达到可复用质量阈值的蒸馏结果仍可作为有用草稿,
1109
+ // 但不得进入出站发布队列。安全与内容完整性闸门保持严格,质量负责晋级。
1110
+ if (distill.publishable !== true) {
1111
+ const reason = distill.quality.ok && verifiedExecution === undefined ? 'execution_evidence' : 'quality_gate';
1112
+ ctx.json(200, {
1113
+ ...distill,
1114
+ queued: false,
1115
+ submission: null,
1116
+ publish_blocked: reason,
1117
+ publish_status: 'blocked',
1118
+ });
1119
+ return true;
1120
+ }
982
1121
  const env = mailbox.createEnvelope({
983
1122
  type: 'asset_submit',
984
1123
  payload: {
@@ -998,7 +1137,12 @@ export class ProxyDaemon {
998
1137
  this.notifyNewOutbound();
999
1138
  submission = { id: env.id, message_id: env.id, receiptId: r.receiptId, status: 'pending', stored: r.stored };
1000
1139
  }
1001
- ctx.json(200, { ...distill, queued: submission !== null, submission });
1140
+ ctx.json(200, {
1141
+ ...distill,
1142
+ queued: submission !== null,
1143
+ submission,
1144
+ publish_status: publishRequested ? 'queued' : 'not_requested',
1145
+ });
1002
1146
  return true;
1003
1147
  }
1004
1148
  if (ctx.route === 'POST /agent/search') {
@@ -1145,25 +1289,39 @@ export class ProxyDaemon {
1145
1289
  staleUntil: now + this.assetSearchCacheTtlMs + this.assetSearchStaleGraceMs,
1146
1290
  });
1147
1291
  }
1148
- async publishAssetSubmitSynchronously(ctx, items) {
1292
+ async publishAssetSubmitSynchronously(ctx, items, composeRecipe = true) {
1293
+ const abortSynchronousPublish = () => {
1294
+ if (ctx.signal?.aborted)
1295
+ return true;
1296
+ if (!this.publishAbortController.signal.aborted)
1297
+ return false;
1298
+ if (!ctx.res.destroyed && !ctx.res.writableEnded) {
1299
+ ctx.json(503, { error: 'proxy_shutting_down', publish_status: 'failed', queued: false });
1300
+ }
1301
+ return true;
1302
+ };
1149
1303
  const classified = classifySynchronousAssetSubmit(items);
1150
1304
  if (!classified.ok) {
1151
1305
  ctx.json(422, { error: classified.error, code: 'invalid_asset_submit' });
1152
1306
  return;
1153
1307
  }
1154
1308
  if (classified.kind === 'wire') {
1155
- const envelope = this.createSynchronousAssetSubmitEnvelope(classified.bundle, undefined, ctx.now);
1309
+ if (abortSynchronousPublish())
1310
+ return;
1311
+ const envelope = this.createSynchronousAssetSubmitEnvelope(classified.bundle, undefined, ctx.now, composeRecipe);
1156
1312
  this.writeSynchronousAssetSubmitOutcome(ctx, await this.publishSynchronousBundle(envelope));
1157
1313
  return;
1158
1314
  }
1159
1315
  const results = [];
1160
1316
  for (const item of classified.items) {
1161
- const converted = await convertLegacyLooseAsset(item);
1317
+ const converted = await convertLegacyLooseAsset(item, this.deps.publishExecutionVerifier, this.deps.publishExecutionVerifierTimeoutMs, ctx.signal, this.publishAbortController.signal);
1162
1318
  if (!converted.ok) {
1163
1319
  results.push({ ok: false, error: converted.error, statusCode: 422 });
1164
1320
  continue;
1165
1321
  }
1166
- const envelope = this.createSynchronousAssetSubmitEnvelope(converted.bundle, 'v1_loose_asset_compat', ctx.now);
1322
+ if (abortSynchronousPublish())
1323
+ return;
1324
+ const envelope = this.createSynchronousAssetSubmitEnvelope(converted.bundle, 'v1_loose_asset_compat', ctx.now, composeRecipe);
1167
1325
  const outcome = await this.publishSynchronousBundle(envelope);
1168
1326
  if (outcome.kind === 'accepted') {
1169
1327
  const receipt = outcome.receipt;
@@ -1192,13 +1350,16 @@ export class ProxyDaemon {
1192
1350
  });
1193
1351
  }
1194
1352
  }
1353
+ const summary = summarizeLegacySynchronousResults(results);
1195
1354
  ctx.json(200, {
1196
1355
  published: results.filter((result) => result.ok).length,
1197
1356
  total: results.length,
1198
1357
  results,
1358
+ publish_status: summary.publishStatus,
1359
+ queued: summary.queued,
1199
1360
  });
1200
1361
  }
1201
- createSynchronousAssetSubmitEnvelope(bundle, source, now) {
1362
+ createSynchronousAssetSubmitEnvelope(bundle, source, now, composeRecipe = true) {
1202
1363
  const canonicalBundle = [...bundle].sort(compareSynchronousAssetSubmitAssets);
1203
1364
  const runtimeNamespace = this.deps.runtimeNamespace ?? 'default';
1204
1365
  const idempotencyKey = synchronousAssetSubmitKey(this.synchronousAssetSubmitScope, runtimeNamespace, this.currentHubMode(), canonicalBundle);
@@ -1208,6 +1369,7 @@ export class ProxyDaemon {
1208
1369
  payload: {
1209
1370
  ...(source ? { source } : {}),
1210
1371
  assets: canonicalBundle,
1372
+ compose_recipe: composeRecipe,
1211
1373
  [OUTBOUND_HUB_MODE_FIELD]: this.currentHubMode(),
1212
1374
  },
1213
1375
  idempotencyKey,
@@ -1215,6 +1377,15 @@ export class ProxyDaemon {
1215
1377
  now,
1216
1378
  });
1217
1379
  }
1380
+ composeRecipeAfterAcceptedSubmit(envelope) {
1381
+ if (envelope.type !== 'asset_submit')
1382
+ return;
1383
+ const key = envelope.idempotencyKey || envelope.id;
1384
+ if (this.recipeComposeStarted.has(key))
1385
+ return;
1386
+ this.recipeComposeStarted.add(key);
1387
+ void hubNs.composeRecipeAfterAssetPublish(this.hub, asRecord(envelope.payload)).catch(() => { });
1388
+ }
1218
1389
  currentHubMode() {
1219
1390
  return this.deps.hubMode ?? 'public';
1220
1391
  }
@@ -1284,6 +1455,7 @@ export class ProxyDaemon {
1284
1455
  this.publishRecallVerifier.observeAcceptedPublish(envelope, receipt);
1285
1456
  }
1286
1457
  catch { /* best-effort */ }
1458
+ this.composeRecipeAfterAcceptedSubmit(envelope);
1287
1459
  }
1288
1460
  if (this.store.getById(envelope.id)?.status !== 'in_flight')
1289
1461
  this.store.complete(envelope.id, this.now());
@@ -1365,6 +1537,7 @@ export class ProxyDaemon {
1365
1537
  this.publishRecallVerifier.observeAcceptedPublish(envelope, receipt);
1366
1538
  }
1367
1539
  catch { /* best-effort */ }
1540
+ this.composeRecipeAfterAcceptedSubmit(envelope);
1368
1541
  }
1369
1542
  return receipt;
1370
1543
  }
@@ -1437,13 +1610,19 @@ export class ProxyDaemon {
1437
1610
  }
1438
1611
  writeSynchronousAssetSubmitOutcome(ctx, outcome) {
1439
1612
  if (outcome.kind === 'accepted') {
1440
- ctx.json(200, outcome.receipt);
1613
+ ctx.json(200, withSynchronousPublishStatus(outcome.receipt, 'accepted'));
1441
1614
  }
1442
1615
  else if (outcome.kind === 'failed') {
1443
- ctx.json(outcome.statusCode, outcome.body);
1616
+ ctx.json(outcome.statusCode, withSynchronousPublishStatus(outcome.body, 'failed'));
1444
1617
  }
1445
1618
  else {
1446
- ctx.json(202, { status: 'pending', message_id: outcome.messageId, durable: true });
1619
+ ctx.json(202, {
1620
+ status: 'pending',
1621
+ message_id: outcome.messageId,
1622
+ durable: true,
1623
+ publish_status: 'pending',
1624
+ queued: true,
1625
+ });
1447
1626
  }
1448
1627
  }
1449
1628
  async handleAtpRoute(ctx) {
@@ -1687,14 +1866,119 @@ function isClearlyLegacyLooseAsset(value) {
1687
1866
  return false;
1688
1867
  return ['content', 'summary', 'strategy'].some((key) => Object.prototype.hasOwnProperty.call(value, key));
1689
1868
  }
1690
- async function convertLegacyLooseAsset(value) {
1869
+ async function resolveVerifiedExecution(verifier, input, expectedValidation, timeoutMs = DEFAULT_PUBLISH_EXECUTION_VERIFY_TIMEOUT_MS, parentSignals = []) {
1870
+ if (!verifier)
1871
+ return undefined;
1872
+ if (parentSignals.some((signal) => signal.aborted))
1873
+ return undefined;
1874
+ const boundedTimeout = Number.isSafeInteger(timeoutMs) && timeoutMs > 0
1875
+ ? timeoutMs
1876
+ : DEFAULT_PUBLISH_EXECUTION_VERIFY_TIMEOUT_MS;
1877
+ const controller = new AbortController();
1878
+ let timer;
1879
+ let resolveAborted;
1880
+ const aborted = new Promise((resolve) => {
1881
+ resolveAborted = () => resolve(null);
1882
+ });
1883
+ const onParentAbort = () => {
1884
+ if (!controller.signal.aborted)
1885
+ controller.abort(new Error('publish_verification_aborted'));
1886
+ resolveAborted();
1887
+ };
1888
+ for (const signal of parentSignals)
1889
+ signal.addEventListener('abort', onParentAbort, { once: true });
1890
+ try {
1891
+ const timeout = new Promise((resolve) => {
1892
+ timer = setTimeout(() => {
1893
+ if (!controller.signal.aborted)
1894
+ controller.abort(new Error('publish_verification_timeout'));
1895
+ resolve(null);
1896
+ }, boundedTimeout);
1897
+ timer.unref?.();
1898
+ });
1899
+ const candidate = await Promise.race([verifier(input, controller.signal), timeout, aborted]);
1900
+ if (!candidate || !Array.isArray(candidate.trace) || candidate.trace.length === 0)
1901
+ return undefined;
1902
+ if (controller.signal.aborted)
1903
+ return undefined;
1904
+ if (candidate.trace.some((row) => (!row
1905
+ || typeof row.command !== 'string'
1906
+ || row.command.trim().length === 0
1907
+ || !Number.isInteger(row.exit)
1908
+ || row.exit !== 0)))
1909
+ return undefined;
1910
+ if (candidate.trace.length !== expectedValidation.length)
1911
+ return undefined;
1912
+ if (candidate.trace.some((row, index) => row.command.trim() !== expectedValidation[index]))
1913
+ return undefined;
1914
+ // Compare the authoritative raw receipt first, then cross the common execution-evidence outlet exactly once.
1915
+ // A command that required redaction no longer identifies the executed program and must not unlock publication.
1916
+ const sanitized = verify.sanitizeExecutionPayload({ ...candidate, validation: expectedValidation });
1917
+ return sanitized.blocked ? undefined : sanitized.value;
1918
+ }
1919
+ catch {
1920
+ return undefined;
1921
+ }
1922
+ finally {
1923
+ if (timer !== undefined)
1924
+ clearTimeout(timer);
1925
+ for (const signal of parentSignals)
1926
+ signal.removeEventListener('abort', onParentAbort);
1927
+ if (!controller.signal.aborted)
1928
+ controller.abort(new Error('publish_verification_complete'));
1929
+ }
1930
+ }
1931
+ function declaredValidationCommands(input) {
1932
+ const raw = input.validation ?? input.verification ?? (input.execution && typeof input.execution === 'object' && !Array.isArray(input.execution)
1933
+ ? input.execution['validation']
1934
+ : undefined);
1935
+ if (!Array.isArray(raw) || raw.length === 0 || raw.length > 8)
1936
+ return undefined;
1937
+ const commands = raw.map((command) => typeof command === 'string' ? command.trim() : '');
1938
+ if (commands.some((command) => command.length === 0
1939
+ || command.length > 180
1940
+ || !verify.isValidationCommandAllowed(command)
1941
+ || verify.sanitizeExecutionCommand(command).blocked)) {
1942
+ return undefined;
1943
+ }
1944
+ return commands;
1945
+ }
1946
+ async function resolveVerifiedExecutionAfterPreflight(verifier, input, timeoutMs, parentSignals = []) {
1947
+ if (!verifier)
1948
+ return undefined;
1949
+ if (parentSignals.some((signal) => signal.aborted))
1950
+ return undefined;
1951
+ const preflight = await hubNs.distillConversation(input, { persist: false });
1952
+ if (parentSignals.some((signal) => signal.aborted) || !preflight.ok || !preflight.quality.ok)
1953
+ return undefined;
1954
+ const validation = declaredValidationCommands(input);
1955
+ if (!validation)
1956
+ return undefined;
1957
+ // 只把通过质量与命令策略预检的最小验证输入交给宿主,绝不转发调用方的 execution/status/trace。
1958
+ return resolveVerifiedExecution(verifier, { validation }, validation, timeoutMs, parentSignals);
1959
+ }
1960
+ async function convertLegacyLooseAsset(value, verifyExecution, verifyExecutionTimeoutMs, ...parentSignals) {
1691
1961
  const normalized = legacyLooseDistillInput(value);
1692
1962
  if (!normalized.ok)
1693
1963
  return normalized;
1694
1964
  try {
1695
- const distilled = await hubNs.distillConversation(normalized.input, { persist: false });
1696
- if (!distilled.ok) {
1965
+ const verifiedExecution = await resolveVerifiedExecutionAfterPreflight(verifyExecution, normalized.input, verifyExecutionTimeoutMs, parentSignals.filter((signal) => signal !== undefined));
1966
+ const distilled = await hubNs.distillConversation(normalized.input, {
1967
+ persist: false,
1968
+ ...(verifiedExecution ? { verifiedExecution } : {}),
1969
+ });
1970
+ if (!distilled.ok)
1697
1971
  return { ok: false, error: `legacy_distill_${safeIdentifier(distilled.reason)}` };
1972
+ // Mirror the /conversation/distill route: a draft that passed the quality gate but was
1973
+ // blocked only by the missing host execution evidence must not be reported as a
1974
+ // quality-gate failure.
1975
+ if (distilled.publishable !== true) {
1976
+ return {
1977
+ ok: false,
1978
+ error: distilled.quality.ok && verifiedExecution === undefined
1979
+ ? 'legacy_distill_execution_evidence'
1980
+ : 'legacy_distill_quality_gate',
1981
+ };
1698
1982
  }
1699
1983
  const gene = {
1700
1984
  ...distilled.gene,
@@ -1826,7 +2110,7 @@ function parseLegacyCategory(value) {
1826
2110
  return { ok: false, error: 'legacy category is invalid' };
1827
2111
  }
1828
2112
  function deterministicDistilledAsset(asset) {
1829
- const draft = { ...asset, asset_id: '' };
2113
+ const draft = wire.stripGeneHints({ ...asset, asset_id: '' });
1830
2114
  // `_source` is local distiller provenance and is not part of the current GEP Gene schema.
1831
2115
  // It also contains a wall-clock timestamp, so it must not influence compatibility asset ids.
1832
2116
  delete draft['_source'];