@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.
- package/dist/bin/evolver-proxy.js +56 -1
- package/dist/daemon/proxyDaemon.d.ts +12 -0
- package/dist/daemon/proxyDaemon.js +305 -21
- package/dist/lifecycle/claimNudge.js +1 -1
- package/dist/lifecycle/deployGuard.js +1 -1
- package/dist/lifecycle/legacyNodeId.js +1 -1
- package/dist/lifecycle/manager.js +1 -1
- package/dist/llm/bodyCapture.js +1 -1
- package/dist/llm/index.js +1 -1
- package/dist/llm/server.js +1 -1
- package/dist/llm/traceBackfill.js +1 -1
- package/dist/llm/traceConfig.js +1 -1
- package/dist/llm/traceControl.js +1 -1
- package/dist/llm/traceEnvelope.js +1 -1
- package/dist/llm/traceSink.js +1 -1
- package/dist/llm/traceUploadPayload.js +1 -1
- package/dist/llm/upstream.js +1 -1
- package/dist/router/cachePassthrough.js +1 -1
- package/dist/router/features.js +1 -1
- package/dist/router/index.js +1 -1
- package/dist/router/messagesRoute.js +1 -1
- package/dist/router/modelRouter.js +1 -1
- package/dist/router/providerRoutes.js +1 -1
- package/dist/router/sseScan.js +1 -1
- package/dist/selfUpdate/builtinKey.d.ts +4 -2
- package/dist/selfUpdate/builtinKey.js +4 -2
- package/dist/selfUpdate/unixController.js +3 -1
- package/dist/selfUpdate/windowsController.js +3 -1
- package/dist/sync/engine.js +1 -1
- package/package.json +3 -3
|
@@ -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
|
-
|
|
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
|
-
|
|
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, {
|
|
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 (
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
|
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
|
|
1696
|
-
|
|
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'];
|