@worca/app 1.3.0 → 1.4.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -6
- package/agents/clarify.meta.json +1 -0
- package/agents/memoryDefragmenter.meta.json +2 -1
- package/agents/reviewer.meta.json +60 -0
- package/agents/worca-cc-code-reviewer.md +33 -0
- package/agents/worca-cc-memory-defragmenter.md +5 -3
- package/agents/workspaceScanner.meta.json +1 -0
- package/package.json +14 -10
- package/scripts/git-diff.mjs +25 -0
- package/scripts/gitDiff.meta.json +18 -0
- package/scripts/js-inline.mjs +11 -0
- package/scripts/js.meta.json +22 -0
- package/scripts/py-inline.py +27 -0
- package/scripts/py.meta.json +22 -0
- package/scripts/shell.meta.json +24 -0
- package/skills/worca/SKILL.md +3 -2
- package/src/cli/models.mjs +247 -0
- package/src/cli/render.mjs +72 -4
- package/src/cli/schedule.mjs +494 -0
- package/src/cli/worca-cc.mjs +1001 -22
- package/src/core/agent-registry.mjs +75 -23
- package/src/core/agent-store.mjs +51 -2
- package/src/core/artifacts.mjs +73 -10
- package/src/core/ask/events.mjs +119 -1
- package/src/core/ask/limits.mjs +32 -4
- package/src/core/ask/mcp-stdio.mjs +12 -0
- package/src/core/ask/model-deps.mjs +126 -0
- package/src/core/ask/model-proposal.mjs +370 -0
- package/src/core/ask/models.mjs +12 -0
- package/src/core/ask/policy-deps.mjs +124 -0
- package/src/core/ask/policy-proposal.mjs +363 -0
- package/src/core/ask/prompt.mjs +74 -9
- package/src/core/ask/proposal.mjs +54 -5
- package/src/core/ask/schedule-deps.mjs +83 -0
- package/src/core/ask/schedule-spec.mjs +310 -0
- package/src/core/ask/script-deps.mjs +357 -0
- package/src/core/ask/source-deps.mjs +52 -0
- package/src/core/ask/source-spec.mjs +157 -0
- package/src/core/ask/spawn.mjs +1 -0
- package/src/core/ask/store.mjs +6 -3
- package/src/core/ask/tool-deps.mjs +4 -0
- package/src/core/ask/tools.mjs +657 -37
- package/src/core/ask/turn.mjs +109 -2
- package/src/core/ask-files.mjs +406 -0
- package/src/core/ask-forms.mjs +195 -0
- package/src/core/ask-projection.mjs +72 -0
- package/src/core/bridge/errors.mjs +84 -0
- package/src/core/bridge/provider-ops.mjs +281 -0
- package/src/core/bridge/providers/copilot.mjs +269 -0
- package/src/core/bridge/providers/endpoint.mjs +257 -0
- package/src/core/bridge/registry.mjs +88 -0
- package/src/core/bridge/semaphore.mjs +73 -0
- package/src/core/bridge/server.mjs +184 -0
- package/src/core/bridge/telemetry.mjs +53 -0
- package/src/core/bridge/translate/request.mjs +252 -0
- package/src/core/bridge/translate/response.mjs +82 -0
- package/src/core/bridge/translate/stream.mjs +242 -0
- package/src/core/bridge/upstream.mjs +209 -0
- package/src/core/chat/command-router.mjs +58 -4
- package/src/core/chat/notifier.mjs +14 -1
- package/src/core/chat/renderers.mjs +35 -0
- package/src/core/claude-runner.mjs +126 -19
- package/src/core/config.mjs +212 -31
- package/src/core/cost-budget.mjs +3 -2
- package/src/core/db.mjs +169 -15
- package/src/core/failure-policy.mjs +10 -0
- package/src/core/fs-browse.mjs +16 -4
- package/src/core/git-info.mjs +22 -0
- package/src/core/graph/builtin-workflows.mjs +3 -1
- package/src/core/graph/exec-io.mjs +71 -0
- package/src/core/graph/executor.mjs +139 -70
- package/src/core/graph/human-evidence.mjs +131 -0
- package/src/core/graph/python-probe.mjs +172 -0
- package/src/core/graph/registry-ports.mjs +10 -6
- package/src/core/graph/scheduler.mjs +39 -24
- package/src/core/graph/script-child.mjs +81 -0
- package/src/core/graph/script-runner.mjs +597 -0
- package/src/core/graph/worca_script.py +207 -0
- package/src/core/guardrail-store.mjs +16 -0
- package/src/core/human-backfill.mjs +108 -0
- package/src/core/human-rate.mjs +17 -0
- package/src/core/index-html.mjs +6 -2
- package/src/core/memory-defrag-model.mjs +112 -0
- package/src/core/memory-store.mjs +70 -18
- package/src/core/memory-sync.mjs +22 -11
- package/src/core/metrics/read.mjs +4 -1
- package/src/core/metrics/record.mjs +50 -2
- package/src/core/metrics/sync.mjs +6 -4
- package/src/core/model-env.mjs +149 -0
- package/src/core/model-test.mjs +14 -1
- package/src/core/notifications.mjs +128 -0
- package/src/core/onboarding.mjs +8 -2
- package/src/core/orchestrator.mjs +357 -26
- package/src/core/phases.mjs +95 -6
- package/src/core/plugin-api.mjs +24 -7
- package/src/core/plugin-manifest.mjs +184 -18
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-script-cases.mjs +118 -0
- package/src/core/plugin-store.mjs +163 -17
- package/src/core/plugin-workflows.mjs +71 -17
- package/src/core/policy/cache.mjs +116 -0
- package/src/core/policy/effective.mjs +175 -0
- package/src/core/policy/gate.mjs +91 -0
- package/src/core/policy/local.mjs +145 -0
- package/src/core/policy/registry.mjs +330 -0
- package/src/core/policy/scope.mjs +61 -0
- package/src/core/policy/state.mjs +79 -0
- package/src/core/policy/sync.mjs +513 -0
- package/src/core/protocol.mjs +43 -0
- package/src/core/run-harness.mjs +421 -72
- package/src/core/scheduler.mjs +980 -0
- package/src/core/script-bench.mjs +628 -0
- package/src/core/script-registry.mjs +116 -0
- package/src/core/script-store.mjs +563 -0
- package/src/core/settings.mjs +489 -14
- package/src/core/stats.mjs +33 -2
- package/src/core/workflow-export.mjs +94 -3
- package/src/core/workflow-share.mjs +67 -18
- package/src/core/workflows.mjs +47 -11
- package/src/core/workspaces.mjs +18 -12
- package/src/shared/forms/answer.mjs +164 -0
- package/src/shared/forms/catalog.mjs +91 -0
- package/src/shared/forms/form-def.mjs +290 -0
- package/src/shared/forms/layout.mjs +67 -0
- package/src/shared/forms/paths.mjs +47 -0
- package/src/shared/forms/project.mjs +309 -0
- package/src/shared/forms/schema.mjs +205 -0
- package/src/shared/graph/agent-meta.mjs +55 -5
- package/src/shared/graph/constants.mjs +14 -2
- package/src/shared/graph/flow-layout.mjs +2 -1
- package/src/shared/graph/isomorphic.mjs +5 -3
- package/src/shared/graph/manifest.mjs +22 -13
- package/src/shared/graph/ports.mjs +45 -19
- package/src/shared/graph/script-cases.mjs +257 -0
- package/src/shared/graph/script-icons.mjs +46 -0
- package/src/shared/graph/script-infer.mjs +259 -0
- package/src/shared/graph/script-meta.mjs +408 -0
- package/src/shared/graph/script-templates.mjs +201 -0
- package/src/shared/graph/template.mjs +4 -4
- package/src/shared/graph/validate.mjs +89 -16
- package/src/shared/human-estimate.mjs +100 -0
- package/src/shared/schedule/recurrence.mjs +353 -0
- package/src/shared/team-metrics/aggregate.mjs +51 -11
- package/{scripts → tools}/install.mjs +3 -3
- package/ui/public/app.js +4390 -683
- package/ui/public/artifact-picker.mjs +189 -0
- package/ui/public/ask/dom.mjs +121 -0
- package/ui/public/ask/form-preview.mjs +55 -0
- package/ui/public/ask/form-renderer.mjs +250 -0
- package/ui/public/ask/registry.mjs +53 -0
- package/ui/public/ask/widgets-display.mjs +370 -0
- package/ui/public/ask/widgets-input.mjs +624 -0
- package/ui/public/ask/widgets-layout.mjs +90 -0
- package/ui/public/ask-panel.mjs +401 -27
- package/ui/public/ask-run-card.mjs +1 -1
- package/ui/public/bridge-view.mjs +694 -0
- package/ui/public/chat-settings-view.mjs +24 -0
- package/ui/public/code-editor.mjs +181 -0
- package/ui/public/getting-started.mjs +34 -7
- package/ui/public/graph/composer.mjs +138 -10
- package/ui/public/graph/inspector.mjs +61 -58
- package/ui/public/graph/palette.mjs +27 -9
- package/ui/public/graph/run-decor.mjs +34 -17
- package/ui/public/graph/run-hosts.mjs +7 -1
- package/ui/public/graph/save-dialog.mjs +3 -0
- package/ui/public/graph/view.mjs +23 -7
- package/ui/public/guardrails-view.mjs +15 -3
- package/ui/public/guide-spot.mjs +87 -9
- package/ui/public/index.html +480 -141
- package/ui/public/memory-view.mjs +22 -4
- package/ui/public/models-view.mjs +162 -17
- package/ui/public/node-tunables.mjs +33 -4
- package/ui/public/plugins-view.mjs +23 -1
- package/ui/public/results-view.mjs +4 -2
- package/ui/public/schedule-sheet.mjs +430 -0
- package/ui/public/schedules-view.mjs +432 -0
- package/ui/public/script-bench-view.mjs +1154 -0
- package/ui/public/script-forms.mjs +282 -0
- package/ui/public/script-wizard.mjs +529 -0
- package/ui/public/scripts-view.mjs +868 -0
- package/ui/public/stats-view.mjs +159 -52
- package/ui/public/style.css +1461 -44
- package/ui/public/team-metrics-surfaces.mjs +77 -16
- package/ui/public/team-metrics-view.mjs +68 -5
- package/ui/public/team-policy-view.mjs +1402 -0
- package/ui/public/ui-level.mjs +237 -0
- package/ui/server.mjs +1827 -73
package/src/core/ask/turn.mjs
CHANGED
|
@@ -25,6 +25,12 @@ import { buildAskSpawnOptions, buildMcpConfig, ASK_MCP_SERVER_PATH } from './spa
|
|
|
25
25
|
import { refreshAskMemoryMount } from './memory-deps.mjs';
|
|
26
26
|
import { validateProposal } from './proposal.mjs';
|
|
27
27
|
import { validateMetricsChange } from './metrics-deps.mjs';
|
|
28
|
+
import { validatePolicyChange } from './policy-deps.mjs';
|
|
29
|
+
import { validateModelChange } from './model-deps.mjs';
|
|
30
|
+
import { validateScheduleChange } from './schedule-deps.mjs';
|
|
31
|
+
import { lookupTask } from './source-deps.mjs';
|
|
32
|
+
import { effectiveTimeZone } from './schedule-spec.mjs';
|
|
33
|
+
import { scheduleDefaults } from '../settings.mjs';
|
|
28
34
|
import { revalidateWorkflowProposal } from './workflow-deps.mjs';
|
|
29
35
|
import { askLimits, ASK_LIMITS } from './limits.mjs';
|
|
30
36
|
import {
|
|
@@ -46,6 +52,7 @@ class AskTurn extends EventEmitter {
|
|
|
46
52
|
mock = null, attachmentNames = {},
|
|
47
53
|
pinnedScope = null,
|
|
48
54
|
memoryProject = null,
|
|
55
|
+
timeZone = null,
|
|
49
56
|
deps = {},
|
|
50
57
|
} = {}) {
|
|
51
58
|
super();
|
|
@@ -65,6 +72,8 @@ class AskTurn extends EventEmitter {
|
|
|
65
72
|
this.attachmentNames = attachmentNames || {};
|
|
66
73
|
// #397: {projectKey}|{workspaceId}|null — the user-pinned scope at POST time.
|
|
67
74
|
this.pinnedScope = pinnedScope && typeof pinnedScope === 'object' ? pinnedScope : null;
|
|
75
|
+
// The user's timezone (the browser's, validated) — the zone a proposal's when / every is read in.
|
|
76
|
+
this.timeZone = effectiveTimeZone(timeZone);
|
|
68
77
|
// Native-rules revision (D16): {key, name}|null — the scope set this turn mounts through --add-dir.
|
|
69
78
|
this.memoryProject = memoryProject && typeof memoryProject === 'object' ? memoryProject : null;
|
|
70
79
|
this.memoryDir = null;
|
|
@@ -81,6 +90,12 @@ class AskTurn extends EventEmitter {
|
|
|
81
90
|
validateProposal: deps.validateProposal ?? validateProposal,
|
|
82
91
|
revalidateWorkflow: deps.revalidateWorkflow ?? revalidateWorkflowProposal,
|
|
83
92
|
validateMetricsChange: deps.validateMetricsChange ?? validateMetricsChange,
|
|
93
|
+
validatePolicyChange: deps.validatePolicyChange ?? validatePolicyChange,
|
|
94
|
+
validateScheduleChange: deps.validateScheduleChange ?? validateScheduleChange,
|
|
95
|
+
validateModelChange: deps.validateModelChange ?? validateModelChange,
|
|
96
|
+
scheduleDefaults: deps.scheduleDefaults ?? scheduleDefaults,
|
|
97
|
+
// A proposed plugin task is looked up here, once: it must exist, and the card shows its title.
|
|
98
|
+
lookupTask: deps.lookupTask === undefined ? lookupTask : deps.lookupTask,
|
|
84
99
|
trackRun: deps.trackRun ?? null,
|
|
85
100
|
generateTitle: deps.generateTitle ?? generateTitle,
|
|
86
101
|
askLimits: deps.askLimits ?? askLimits,
|
|
@@ -104,6 +119,8 @@ class AskTurn extends EventEmitter {
|
|
|
104
119
|
onCommentMutation: deps.onCommentMutation ?? (() => {}),
|
|
105
120
|
onWorktreeMutation: deps.onWorktreeMutation ?? (() => {}),
|
|
106
121
|
onMemoryMutation: deps.onMemoryMutation ?? (() => {}),
|
|
122
|
+
onScriptMutation: deps.onScriptMutation ?? (() => {}),
|
|
123
|
+
onScheduleMutation: deps.onScheduleMutation ?? (() => {}),
|
|
107
124
|
// DISPLAY-ONLY rates for the footer's live "≈" estimate (config.mjs
|
|
108
125
|
// liveCostRates: override → list price → null). Injectable so tests pin
|
|
109
126
|
// the frame arithmetic without the catalog.
|
|
@@ -164,7 +181,9 @@ class AskTurn extends EventEmitter {
|
|
|
164
181
|
// to real rows (spec §6.4). A ledger failure never blocks the card.
|
|
165
182
|
let attachments = [];
|
|
166
183
|
try { attachments = (typeof d.store.listAttachments === 'function' && d.store.listAttachments(this.threadId)) || []; } catch { attachments = []; }
|
|
167
|
-
|
|
184
|
+
let defaults = {};
|
|
185
|
+
try { defaults = d.scheduleDefaults() || {}; } catch { defaults = {}; }
|
|
186
|
+
const r = await d.validateProposal(inp, { cardId, attachments, timeZone: this.timeZone, nowMs: d.now(), scheduleDefaults: defaults, lookupTask: d.lookupTask });
|
|
168
187
|
if (r && r.ok) {
|
|
169
188
|
// #397 guardrail: a proposal targeting a DIFFERENT project/workspace than
|
|
170
189
|
// the pinned one is accepted but flagged — the card renders the mismatch
|
|
@@ -245,6 +264,86 @@ class AskTurn extends EventEmitter {
|
|
|
245
264
|
this._persistBlocks();
|
|
246
265
|
}
|
|
247
266
|
|
|
267
|
+
/**
|
|
268
|
+
* propose_policy_change RESULT: the metrics card's split — the child validated for the model, the parent re-validates
|
|
269
|
+
* the same INPUT over the real readers (policy-proposal.mjs) and mints the card.
|
|
270
|
+
*/
|
|
271
|
+
async _onPolicyProposal(input, text, isError) {
|
|
272
|
+
if (isError) return;
|
|
273
|
+
let out = null;
|
|
274
|
+
try { out = JSON.parse(text); } catch { out = null; }
|
|
275
|
+
if (!out || out.ok !== true) return;
|
|
276
|
+
const d = this.deps;
|
|
277
|
+
const raw = input && typeof input === 'object' ? input : {};
|
|
278
|
+
// The child's pinned-scope default, replayed (tools.mjs fillPolicyPin): the card matches what the model saw.
|
|
279
|
+
const pin = this.pinnedScope;
|
|
280
|
+
const kind = typeof raw.kind === 'string' ? raw.kind.trim() : '';
|
|
281
|
+
let inp = raw;
|
|
282
|
+
if (pin && !(typeof raw.projectKey === 'string' && raw.projectKey.trim()) && !(typeof raw.workspaceId === 'string' && raw.workspaceId.trim())) {
|
|
283
|
+
if (pin.projectKey && (kind === 'enable' || kind === 'edit')) inp = { ...raw, projectKey: pin.projectKey };
|
|
284
|
+
if (pin.workspaceId && (kind === 'edit' || kind === 'workspace_home' || kind === 'route_members')) inp = { ...raw, workspaceId: pin.workspaceId };
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
const r = await d.validatePolicyChange(inp);
|
|
288
|
+
if (r && r.ok) this.reducer.addBlock({ kind: 'card', id: d.newAskId('card'), state: 'proposed', card: r.card });
|
|
289
|
+
else {
|
|
290
|
+
const errors = (r && Array.isArray(r.errors) && r.errors.length) ? r.errors : ['invalid proposal'];
|
|
291
|
+
this.reducer.addBlock({ kind: 'notice', text: `Policy change rejected: ${errors.join('; ')}` });
|
|
292
|
+
}
|
|
293
|
+
} catch (err) {
|
|
294
|
+
this.reducer.addBlock({ kind: 'notice', text: `Policy change rejected: ${err?.message || err}` });
|
|
295
|
+
}
|
|
296
|
+
this._persistBlocks();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* propose_schedule_change RESULT: the metrics card's split — the child validated for the model, the parent
|
|
301
|
+
* re-validates the same INPUT against the live rows and mints the card. A child {ok:false} already reached
|
|
302
|
+
* the model as text: no card, no notice.
|
|
303
|
+
*/
|
|
304
|
+
async _onScheduleProposal(input, text, isError) {
|
|
305
|
+
if (isError) return;
|
|
306
|
+
let out = null;
|
|
307
|
+
try { out = JSON.parse(text); } catch { out = null; }
|
|
308
|
+
if (!out || out.ok !== true) return;
|
|
309
|
+
const d = this.deps;
|
|
310
|
+
try {
|
|
311
|
+
const r = await d.validateScheduleChange(input && typeof input === 'object' ? input : {}, { timeZone: this.timeZone });
|
|
312
|
+
if (r && r.ok) this.reducer.addBlock({ kind: 'card', id: d.newAskId('card'), state: 'proposed', card: r.card });
|
|
313
|
+
else {
|
|
314
|
+
const errors = (r && Array.isArray(r.errors) && r.errors.length) ? r.errors : ['invalid proposal'];
|
|
315
|
+
this.reducer.addBlock({ kind: 'notice', text: `Schedule change rejected: ${errors.join('; ')}` });
|
|
316
|
+
}
|
|
317
|
+
} catch (err) {
|
|
318
|
+
this.reducer.addBlock({ kind: 'notice', text: `Schedule change rejected: ${err?.message || err}` });
|
|
319
|
+
}
|
|
320
|
+
this._persistBlocks();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* propose_model_change RESULT: the metrics card's split — the child validated for the model, the parent
|
|
325
|
+
* re-validates the same INPUT over the real catalog and providers and mints the card. A child {ok:false}
|
|
326
|
+
* already reached the model as text: no card, no notice.
|
|
327
|
+
*/
|
|
328
|
+
async _onModelProposal(input, text, isError) {
|
|
329
|
+
if (isError) return;
|
|
330
|
+
let out = null;
|
|
331
|
+
try { out = JSON.parse(text); } catch { out = null; }
|
|
332
|
+
if (!out || out.ok !== true) return;
|
|
333
|
+
const d = this.deps;
|
|
334
|
+
try {
|
|
335
|
+
const r = await d.validateModelChange(input && typeof input === 'object' ? input : {});
|
|
336
|
+
if (r && r.ok) this.reducer.addBlock({ kind: 'card', id: d.newAskId('card'), state: 'proposed', card: r.card });
|
|
337
|
+
else {
|
|
338
|
+
const errors = (r && Array.isArray(r.errors) && r.errors.length) ? r.errors : ['invalid proposal'];
|
|
339
|
+
this.reducer.addBlock({ kind: 'notice', text: `Model change rejected: ${errors.join('; ')}` });
|
|
340
|
+
}
|
|
341
|
+
} catch (err) {
|
|
342
|
+
this.reducer.addBlock({ kind: 'notice', text: `Model change rejected: ${err?.message || err}` });
|
|
343
|
+
}
|
|
344
|
+
this._persistBlocks();
|
|
345
|
+
}
|
|
346
|
+
|
|
248
347
|
/** The card exists from the tool_use on (spec §8.2, PD7): a building block with the four-step trace, persisted. */
|
|
249
348
|
_onWorkflowStart(toolUseId, input) {
|
|
250
349
|
const d = this.deps;
|
|
@@ -329,6 +428,11 @@ class AskTurn extends EventEmitter {
|
|
|
329
428
|
onWorkflowResult: ({ toolUseId, text, isError }) => this._onWorkflowResult(toolUseId, text, isError), // the hook's `input` is not needed here: the card is rebuilt from `out`
|
|
330
429
|
onTrackRun: ({ input, isError }) => this._onTrackRun(input, isError),
|
|
331
430
|
onMetricsProposal: ({ input, text, isError }) => this._onMetricsProposal(input, text, isError),
|
|
431
|
+
onPolicyProposal: ({ input, text, isError }) => this._onPolicyProposal(input, text, isError),
|
|
432
|
+
onScheduleProposal: ({ input, text, isError }) => this._onScheduleProposal(input, text, isError),
|
|
433
|
+
onModelProposal: ({ input, text, isError }) => this._onModelProposal(input, text, isError),
|
|
434
|
+
// pause / resume / skip / mark-read in the child → the server's schedules-changed frames.
|
|
435
|
+
onScheduleMutation: (e) => { try { this.deps.onScheduleMutation(e); } catch { /* a broken sink never breaks the turn */ } },
|
|
332
436
|
// The MCP child cannot broadcast; the parent turns its comment writes into
|
|
333
437
|
// the same poke the REST routes emit.
|
|
334
438
|
onCommentMutation: (e) => { try { this.deps.onCommentMutation(e); } catch { /* a broken sink never breaks the turn */ } },
|
|
@@ -337,6 +441,8 @@ class AskTurn extends EventEmitter {
|
|
|
337
441
|
onWorktreeMutation: (e) => { try { this.deps.onWorktreeMutation(e); } catch { /* a broken sink never breaks the turn */ } },
|
|
338
442
|
// ...and for memory: a remember/forget in the child becomes the server's memory-changed frame.
|
|
339
443
|
onMemoryMutation: (e) => { try { this.deps.onMemoryMutation(e); } catch { /* a broken sink never breaks the turn */ } },
|
|
444
|
+
// ...and for scripts: a save_script in the child becomes the server's scripts-changed frame.
|
|
445
|
+
onScriptMutation: (e) => { try { this.deps.onScriptMutation(e); } catch { /* a broken sink never breaks the turn */ } },
|
|
340
446
|
// DISPLAY ONLY — never a sink input: prices the running usage sum (main +
|
|
341
447
|
// sub-agent tokens) at the TURN model's rates; the "≈" in the footer owns
|
|
342
448
|
// that approximation. _complete() reads summary.costUsd, not this.
|
|
@@ -485,7 +591,8 @@ class AskTurn extends EventEmitter {
|
|
|
485
591
|
// the flag set (the awaiting continuation resumes a microtask later);
|
|
486
592
|
// flag-first is kept as defensive style (plugin-shim.mjs:164 precedent).
|
|
487
593
|
timer = d.setTimeout(() => { this.timedOut = true; try { this.abort.abort(); } catch { /* ignore */ } }, d.limits.turnTimeoutMs);
|
|
488
|
-
|
|
594
|
+
// D12: read fresh every turn. The pinned project's team policy may start the limits off (team-policy §5).
|
|
595
|
+
const limitsNow = d.askLimits({ projectKey: this.pinnedScope?.projectKey || null });
|
|
489
596
|
out = await this._attempts(limitsNow, mcpConfigPath, scratchDir);
|
|
490
597
|
} catch (err) {
|
|
491
598
|
// Backstop for a deps failure (mkdir/write) — _attempts itself never throws.
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
// src/core/ask-files.mjs
|
|
2
|
+
// Preview files for an ask form (spec §7). Agents reference files by a
|
|
3
|
+
// RUN-RELATIVE path; the host resolves, sniffs, caps and SNAPSHOTS them at ask
|
|
4
|
+
// time, then serves them by (runId, askId, index). Nothing downstream ever takes
|
|
5
|
+
// a path from the agent or from HTTP.
|
|
6
|
+
//
|
|
7
|
+
// Three refusals, in order, and each is a gate-2 error the agent is resumed with:
|
|
8
|
+
// 1. SHAPE — refusePathShape, pure, platform-independent (a run authored on
|
|
9
|
+
// Linux is read back on Windows, so every Windows rule applies
|
|
10
|
+
// everywhere). Only the case-FOLD of the containment comparison
|
|
11
|
+
// is platform-conditional, and `platform` is injectable.
|
|
12
|
+
// 2. CONTAINMENT — realpath(candidate) must sit inside realpath(one root); a
|
|
13
|
+
// symlink out of the tree therefore refuses itself.
|
|
14
|
+
// 3. KIND — magic bytes decide. The extension and the agent's claim are
|
|
15
|
+
// IGNORED (§7). Text has no magic, so a UTF-8-clean body is
|
|
16
|
+
// probed by content: xml/svg, JSON, a diff header, any other
|
|
17
|
+
// leading '<' is markup and REFUSED, else text/plain.
|
|
18
|
+
//
|
|
19
|
+
// The allowlist below IS the §7 trust table. It is deliberately NOT
|
|
20
|
+
// src/core/ask/attachment-kind.mjs: that is the chat-attachment policy (no SVG,
|
|
21
|
+
// no media, no diff) and the two must be able to diverge.
|
|
22
|
+
//
|
|
23
|
+
// CROSS-PLATFORM CONTRACT
|
|
24
|
+
// · Every path SHAPE rule is enforced on every platform: a run authored on
|
|
25
|
+
// Linux is read back on Windows, and the snapshot dir must be portable.
|
|
26
|
+
// `test/ask-files.test.mjs` therefore covers CON / COM1 / `C:x` / UNC /
|
|
27
|
+
// `<>:"|?*` / trailing dot / trailing space from a macOS or Linux host.
|
|
28
|
+
// · Only the case-FOLD of the containment comparison is platform-conditional,
|
|
29
|
+
// through the injectable `platform` option (win32 + darwin fold, linux does
|
|
30
|
+
// not). Both arms are exercised on one host.
|
|
31
|
+
// · Paths are built with path.join / path.resolve only — never a string '/'.
|
|
32
|
+
// `stored` is `<index><ext>`, which is a legal basename on every platform.
|
|
33
|
+
// · Snapshot dirs are removed with the pipeline dir; nothing here unlinks.
|
|
34
|
+
import { createHash } from 'node:crypto';
|
|
35
|
+
import { createReadStream } from 'node:fs';
|
|
36
|
+
import { copyFile, lstat, mkdir, open, readFile, realpath, writeFile } from 'node:fs/promises';
|
|
37
|
+
import { join, resolve, sep } from 'node:path';
|
|
38
|
+
import process from 'node:process';
|
|
39
|
+
|
|
40
|
+
import { ASK_LIMITS } from '../shared/forms/catalog.mjs';
|
|
41
|
+
|
|
42
|
+
/** §7's trust table: sniffed mime -> { ext, trust }. `trust` is what the P3
|
|
43
|
+
* renderer switches on; the ext is the on-disk suffix of the snapshot. */
|
|
44
|
+
export const ASK_FILE_MIMES = Object.freeze({
|
|
45
|
+
'image/png': { ext: '.png', trust: 'inline' },
|
|
46
|
+
'image/jpeg': { ext: '.jpg', trust: 'inline' },
|
|
47
|
+
'image/gif': { ext: '.gif', trust: 'inline' },
|
|
48
|
+
'image/webp': { ext: '.webp', trust: 'inline' },
|
|
49
|
+
'image/avif': { ext: '.avif', trust: 'inline' },
|
|
50
|
+
'image/svg+xml': { ext: '.svg', trust: 'inert' },
|
|
51
|
+
'text/plain': { ext: '.txt', trust: 'text' },
|
|
52
|
+
'application/json':{ ext: '.json', trust: 'text' },
|
|
53
|
+
'text/x-diff': { ext: '.diff', trust: 'text' },
|
|
54
|
+
'application/pdf': { ext: '.pdf', trust: 'viewer' },
|
|
55
|
+
'video/mp4': { ext: '.mp4', trust: 'media' },
|
|
56
|
+
'video/webm': { ext: '.webm', trust: 'media' },
|
|
57
|
+
'audio/mpeg': { ext: '.mp3', trust: 'media' },
|
|
58
|
+
'audio/wav': { ext: '.wav', trust: 'media' },
|
|
59
|
+
'audio/ogg': { ext: '.ogg', trust: 'media' },
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/** The basename a snapshot may have — the ONLY string the file route ever joins
|
|
63
|
+
* onto a directory. `<index><ext>`, nothing else, ever. */
|
|
64
|
+
const STORED_RE = /^\d{1,2}\.[a-z0-9]{1,5}$/;
|
|
65
|
+
|
|
66
|
+
/** ISO 32000-1 note 13: `%PDF-` may sit behind up to 1024 bytes of preamble. */
|
|
67
|
+
const PDF_HEADER_WINDOW = 1024;
|
|
68
|
+
/** Magic-byte window; also the head we decode for the text probes' cheap path. */
|
|
69
|
+
const SNIFF_HEAD = 4096;
|
|
70
|
+
/** A body with no binary magic bigger than this is refused by SIZE (`too-big`,
|
|
71
|
+
* naming this cap) rather than read whole into memory to be proved text. */
|
|
72
|
+
const TEXT_SNIFF_MAX = 1024 * 1024;
|
|
73
|
+
/** sniffFile's answer for such a body: the caller turns it into a size refusal
|
|
74
|
+
* that names the cap, so the agent learns to pick a smaller file — not "not a
|
|
75
|
+
* file type worca can display", which sent it back with the same file. */
|
|
76
|
+
const TEXT_TOO_BIG = Symbol('text-too-big');
|
|
77
|
+
|
|
78
|
+
/** MS-DOS device names: reserved in EVERY directory on Windows, with or without
|
|
79
|
+
* an extension, case-insensitively. */
|
|
80
|
+
const WIN_DEVICE_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
|
|
81
|
+
/** Characters Windows forbids in a path component (`:` included — it is also how
|
|
82
|
+
* a drive-relative path smuggles itself in). */
|
|
83
|
+
const WIN_RESERVED_CHARS_RE = /[<>:"|?*]/;
|
|
84
|
+
/** ISO-BMFF major brands that are MP4-family video. Everything else sharing the
|
|
85
|
+
* `ftyp` container (HEIC, QuickTime, M4A, 3GP) is refused as unrecognized. */
|
|
86
|
+
const MP4_BRANDS = new Set(['isom', 'iso2', 'iso3', 'iso4', 'iso5', 'iso6', 'mp41', 'mp42', 'mp71', 'avc1', 'dash', 'M4V ']);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Why this run-relative path is refused, or null when its SHAPE is acceptable.
|
|
90
|
+
* Pure, and platform-independent on purpose (E10): the snapshot must be
|
|
91
|
+
* reproducible on every host that later reads the run.
|
|
92
|
+
* @param {unknown} rel
|
|
93
|
+
* @returns {string|null}
|
|
94
|
+
*/
|
|
95
|
+
export function refusePathShape(rel) {
|
|
96
|
+
if (typeof rel !== 'string' || !rel.trim()) return 'a file value must be a non-empty path';
|
|
97
|
+
// Windows forbids every C0 control and DEL in a name, and a newline in `rel` would
|
|
98
|
+
// split the one-line audit entry the refusal lands on — so the reason is JSON-quoted.
|
|
99
|
+
for (let i = 0; i < rel.length; i++) {
|
|
100
|
+
const k = rel.charCodeAt(i);
|
|
101
|
+
if (k < 32 || k === 127) return `${JSON.stringify(rel)} contains a control character`;
|
|
102
|
+
}
|
|
103
|
+
if (rel.length > 1024) return 'a file path may not exceed 1024 characters';
|
|
104
|
+
if (rel.startsWith('/') || rel.startsWith('\\')) return `"${rel}" is absolute; file values are run-relative`;
|
|
105
|
+
if (/^[A-Za-z]:/.test(rel)) return `"${rel}" names a drive; file values are run-relative`;
|
|
106
|
+
const parts = rel.split(/[\\/]+/);
|
|
107
|
+
for (const part of parts) {
|
|
108
|
+
if (!part || part === '.') continue;
|
|
109
|
+
if (part === '..') return `"${rel}" escapes the run with ".."`;
|
|
110
|
+
if (WIN_DEVICE_RE.test(part)) return `"${rel}" uses the reserved Windows device name "${part}"`;
|
|
111
|
+
if (WIN_RESERVED_CHARS_RE.test(part)) return `"${rel}" uses a character Windows forbids in a path`;
|
|
112
|
+
if (part.endsWith('.') || part.endsWith(' ')) return `"${rel}" has a segment ending in a dot or a space`;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Does the sniffed mime satisfy the field's `accept` patterns? An empty/absent
|
|
118
|
+
* accept means "anything on the allowlist". `text/plain` stands in for any
|
|
119
|
+
* `text/*` pattern, because markdown and csv are not separately sniffable (E9). */
|
|
120
|
+
export function mimeMatchesAccept(mime, accept) {
|
|
121
|
+
const list = Array.isArray(accept) ? accept.filter((a) => typeof a === 'string' && a.trim()) : [];
|
|
122
|
+
if (!list.length) return true;
|
|
123
|
+
const [type] = String(mime).split('/');
|
|
124
|
+
return list.some((raw) => {
|
|
125
|
+
const pattern = raw.trim().toLowerCase();
|
|
126
|
+
if (pattern === '*/*' || pattern === '*') return true;
|
|
127
|
+
if (pattern.endsWith('/*')) return type === pattern.slice(0, -2);
|
|
128
|
+
if (pattern === mime) return true;
|
|
129
|
+
return mime === 'text/plain' && pattern.startsWith('text/');
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The real mime of a body, from its leading bytes, or null when it is not a type
|
|
135
|
+
* worca will display. The extension and whatever the agent claimed are ignored.
|
|
136
|
+
* @param {Buffer} buf
|
|
137
|
+
* @returns {string|null}
|
|
138
|
+
*/
|
|
139
|
+
export function sniffMime(buf) {
|
|
140
|
+
if (!Buffer.isBuffer(buf) || !buf.length) return null;
|
|
141
|
+
if (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47
|
|
142
|
+
&& buf[4] === 0x0d && buf[5] === 0x0a && buf[6] === 0x1a && buf[7] === 0x0a) return 'image/png';
|
|
143
|
+
// SOI (FF D8) plus the first marker: a bare FF D8 FF stub is not a JPEG.
|
|
144
|
+
if (buf.length >= 4 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff && buf[3] >= 0xc0) return 'image/jpeg';
|
|
145
|
+
if (buf.length >= 6) {
|
|
146
|
+
const head6 = buf.toString('latin1', 0, 6);
|
|
147
|
+
if (head6 === 'GIF87a' || head6 === 'GIF89a') return 'image/gif';
|
|
148
|
+
}
|
|
149
|
+
if (buf.length >= 12 && buf.toString('latin1', 0, 4) === 'RIFF' && buf.toString('latin1', 8, 12) === 'WEBP') return 'image/webp';
|
|
150
|
+
// ISO-BMFF: `....ftyp<brand>`. avif/mp4 share the container; the brand decides.
|
|
151
|
+
if (buf.length >= 12 && buf.toString('latin1', 4, 8) === 'ftyp') {
|
|
152
|
+
const brand = buf.toString('latin1', 8, 12);
|
|
153
|
+
if (brand === 'avif' || brand === 'avis') return 'image/avif';
|
|
154
|
+
// Only the MP4 family is video/mp4. HEIC/HEIF (heic, mif1 …), QuickTime (qt ) and
|
|
155
|
+
// M4A share the container and are not types worca displays: refused, not mislabelled.
|
|
156
|
+
return MP4_BRANDS.has(brand) ? 'video/mp4' : null;
|
|
157
|
+
}
|
|
158
|
+
if (buf.length >= 4 && buf[0] === 0x1a && buf[1] === 0x45 && buf[2] === 0xdf && buf[3] === 0xa3) return 'video/webm'; // EBML
|
|
159
|
+
if (buf.length >= 4 && buf.toString('latin1', 0, 4) === 'OggS') return 'audio/ogg';
|
|
160
|
+
if (buf.length >= 12 && buf.toString('latin1', 0, 4) === 'RIFF' && buf.toString('latin1', 8, 12) === 'WAVE') return 'audio/wav';
|
|
161
|
+
// MPEG audio: an ID3v2 tag, or a Layer III frame sync — the 11 sync bits, layer bits `01`,
|
|
162
|
+
// a legal bitrate index (not 1111) and sampling-rate index (not 11). The bare 11-bit sync
|
|
163
|
+
// alone matched a UTF-16LE BOM (FF FE) and any FF-FF run; Layer I/II are not types worca
|
|
164
|
+
// plays, so they fall through and are refused as unrecognized.
|
|
165
|
+
if (buf.length >= 3 && (buf.toString('latin1', 0, 3) === 'ID3'
|
|
166
|
+
|| (buf[0] === 0xff && (buf[1] & 0xe6) === 0xe2 && (buf[2] & 0xf0) !== 0xf0 && (buf[2] & 0x0c) !== 0x0c))) return 'audio/mpeg';
|
|
167
|
+
if (buf.length >= 5) {
|
|
168
|
+
const at = buf.toString('latin1', 0, Math.min(buf.length, PDF_HEADER_WINDOW + 5)).indexOf('%PDF-');
|
|
169
|
+
if (at !== -1 && at <= PDF_HEADER_WINDOW) return 'application/pdf';
|
|
170
|
+
}
|
|
171
|
+
return sniffText(buf);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The body after the prelude an XML file may carry: an `<?xml … ?>` prolog, comments, one
|
|
175
|
+
* DOCTYPE (with or without an internal subset). A DOCTYPE names the document type outright,
|
|
176
|
+
* so only `svg` may pass it. Null when the prelude is unterminated or names another type. */
|
|
177
|
+
function afterXmlPrelude(head) {
|
|
178
|
+
let s = head;
|
|
179
|
+
if (s.startsWith('<?xml')) {
|
|
180
|
+
const end = s.indexOf('?>');
|
|
181
|
+
if (end === -1) return null;
|
|
182
|
+
s = s.slice(end + 2);
|
|
183
|
+
}
|
|
184
|
+
for (;;) {
|
|
185
|
+
s = s.trimStart();
|
|
186
|
+
if (s.startsWith('<!--')) {
|
|
187
|
+
const end = s.indexOf('-->', 4);
|
|
188
|
+
if (end === -1) return null;
|
|
189
|
+
s = s.slice(end + 3);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const doctype = /^<!doctype\s+([^\s>\[]+)[^>\[]*(?:\[[\s\S]*?\]\s*)?>/i.exec(s);
|
|
193
|
+
if (doctype) {
|
|
194
|
+
if (doctype[1].toLowerCase() !== 'svg') return null;
|
|
195
|
+
s = s.slice(doctype[0].length);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
return s;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** A body that opens with '<': its ROOT element decides. Only a root `svg` (prefixed or not)
|
|
203
|
+
* is an inert image; any other root element is markup and refused, whatever prolog it wears
|
|
204
|
+
* (an XHTML document that merely CONTAINS an svg is markup). A prelude followed by PROSE —
|
|
205
|
+
* a markdown file that opens with an HTML comment — is text, exactly like a body that never
|
|
206
|
+
* started with '<'; a prelude followed by nothing, or by a non-element such as `<?php`, is
|
|
207
|
+
* unrecognized. */
|
|
208
|
+
function sniffMarkup(head) {
|
|
209
|
+
const rest = afterXmlPrelude(head);
|
|
210
|
+
if (!rest) return null;
|
|
211
|
+
const root = /^<(?:[A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*)/.exec(rest);
|
|
212
|
+
if (root) return root[1].toLowerCase() === 'svg' ? 'image/svg+xml' : null;
|
|
213
|
+
return rest.startsWith('<') ? null : 'text/plain';
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** E9: text has no magic number, so it is probed by CONTENT. A body that is not
|
|
217
|
+
* clean UTF-8, or whose first non-space byte is '<' without being xml/svg, is
|
|
218
|
+
* refused — that is §7's "html, xhtml, anything scriptable or unrecognized". */
|
|
219
|
+
function sniffText(buf) {
|
|
220
|
+
let text;
|
|
221
|
+
try {
|
|
222
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(buf);
|
|
223
|
+
} catch {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
// A C0 control other than tab / LF / CR is not text (and no regex here: a `\\u` escape
|
|
227
|
+
// in source is a trap for the tools that copy this file).
|
|
228
|
+
for (let i = 0; i < text.length; i++) {
|
|
229
|
+
const k = text.charCodeAt(i);
|
|
230
|
+
if (k < 32 && k !== 9 && k !== 10 && k !== 13) return null;
|
|
231
|
+
}
|
|
232
|
+
const head = (text.charCodeAt(0) === 0xfeff ? text.slice(1) : text).trimStart(); // strip a BOM
|
|
233
|
+
if (!head) return null; // an empty or blank body is nothing worca can display
|
|
234
|
+
// Markup is decided by its ROOT element, read through the prelude an SVG file may open
|
|
235
|
+
// with (a prolog, comments, an svg DOCTYPE) — see sniffMarkup.
|
|
236
|
+
if (head.startsWith('<')) return sniffMarkup(head);
|
|
237
|
+
if (head.startsWith('{') || head.startsWith('[')) {
|
|
238
|
+
try { JSON.parse(text); return 'application/json'; } catch { /* not json after all */ }
|
|
239
|
+
}
|
|
240
|
+
if (/^diff --git /m.test(head) || (/^--- /m.test(head) && /^\+\+\+ /m.test(head))) return 'text/x-diff';
|
|
241
|
+
return 'text/plain';
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Sniff a file on disk: the magic window first, and only a small body is read
|
|
245
|
+
* whole for the text probes; a larger body with no binary magic is TEXT_TOO_BIG. */
|
|
246
|
+
async function sniffFile(abs, size) {
|
|
247
|
+
const fh = await open(abs, 'r');
|
|
248
|
+
try {
|
|
249
|
+
const head = Buffer.alloc(Math.min(SNIFF_HEAD, Math.max(size, 1)));
|
|
250
|
+
const { bytesRead } = await fh.read(head, 0, head.length, 0);
|
|
251
|
+
const binary = sniffBinaryOnly(head.subarray(0, bytesRead));
|
|
252
|
+
if (binary) return binary;
|
|
253
|
+
} finally {
|
|
254
|
+
await fh.close();
|
|
255
|
+
}
|
|
256
|
+
if (size > TEXT_SNIFF_MAX) return TEXT_TOO_BIG;
|
|
257
|
+
return sniffText(await readFile(abs));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** sniffMime minus the text fallback — used when the head alone is in hand. */
|
|
261
|
+
function sniffBinaryOnly(head) {
|
|
262
|
+
const mime = sniffMime(head);
|
|
263
|
+
return mime && ASK_FILE_MIMES[mime].trust !== 'text' && mime !== 'image/svg+xml' ? mime : null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Is `candidate` (already realpath'd) inside `root` (already realpath'd)? Exported so the
|
|
267
|
+
* case-fold arm is testable directly: macOS realpath() preserves whatever case it was
|
|
268
|
+
* handed, so no on-disk fixture can prove the fold on one host. */
|
|
269
|
+
export function isInside(root, candidate, { fold = false } = {}) {
|
|
270
|
+
const a = fold ? root.toLowerCase() : root;
|
|
271
|
+
const b = fold ? candidate.toLowerCase() : candidate;
|
|
272
|
+
const prefix = a.endsWith(sep) ? a : a + sep;
|
|
273
|
+
return b === a || b.startsWith(prefix);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** sha256 of a file, streamed (a 25 MB body must never be buffered to be hashed). */
|
|
277
|
+
function hashFile(abs) {
|
|
278
|
+
return new Promise((res, rej) => {
|
|
279
|
+
const h = createHash('sha256');
|
|
280
|
+
const s = createReadStream(abs);
|
|
281
|
+
s.on('error', rej);
|
|
282
|
+
s.on('data', (c) => h.update(c));
|
|
283
|
+
s.on('end', () => res(h.digest('hex')));
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Snapshot every file an ask references (spec §7). Resolves each ref against the
|
|
289
|
+
* roots IN ORDER (the node working tree, then the pipeline dir), refuses
|
|
290
|
+
* anything that fails the shape / containment / kind / cap rules, and copies the
|
|
291
|
+
* survivors to `<destDir>/<index><ext>` beside a `manifest.json` that is the ONE
|
|
292
|
+
* source both file routes read.
|
|
293
|
+
*
|
|
294
|
+
* ANY error means the whole ask is refused by gate 2, so the caller must check
|
|
295
|
+
* `errors` before using `files`.
|
|
296
|
+
*
|
|
297
|
+
* @param {{refs: Array<{path:string, rel:string, accept?:string[]}>, roots: string[],
|
|
298
|
+
* destDir: string, platform?: string}} args
|
|
299
|
+
* @returns {Promise<{files: Array<{index:number, rel:string, name:string, mime:string,
|
|
300
|
+
* bytes:number, sha256:string, stored:string}>, errors: Array<{path:string, code:string, message:string}>}>}
|
|
301
|
+
*/
|
|
302
|
+
export async function snapshotAskFiles({ refs, roots, destDir, platform = process.platform }) {
|
|
303
|
+
const files = [];
|
|
304
|
+
const errors = [];
|
|
305
|
+
const list = Array.isArray(refs) ? refs : [];
|
|
306
|
+
if (list.length > ASK_LIMITS.filesPerAsk) {
|
|
307
|
+
errors.push({ path: 'data', code: 'too-many',
|
|
308
|
+
message: `an ask may reference at most ${ASK_LIMITS.filesPerAsk} files (got ${list.length})` });
|
|
309
|
+
return { files, errors };
|
|
310
|
+
}
|
|
311
|
+
const realRoots = [];
|
|
312
|
+
for (const r of roots || []) {
|
|
313
|
+
if (!r) continue;
|
|
314
|
+
try { realRoots.push(await realpath(r)); } catch { /* a root that does not exist contains nothing */ }
|
|
315
|
+
}
|
|
316
|
+
// macOS and Windows are case-insensitive by default; Linux is not. Injected so
|
|
317
|
+
// both arms are testable from one host.
|
|
318
|
+
const fold = platform === 'win32' || platform === 'darwin';
|
|
319
|
+
let total = 0;
|
|
320
|
+
for (let index = 0; index < list.length; index++) {
|
|
321
|
+
const ref = list[index] || {};
|
|
322
|
+
const at = typeof ref.path === 'string' && ref.path ? ref.path : 'data';
|
|
323
|
+
const rel = typeof ref.rel === 'string' ? ref.rel : '';
|
|
324
|
+
const fail = (code, message) => errors.push({ path: at, code, message });
|
|
325
|
+
|
|
326
|
+
const shape = refusePathShape(rel);
|
|
327
|
+
if (shape) { fail('file-path', shape); continue; }
|
|
328
|
+
// Either separator is accepted by the shape rule, so the segments are joined
|
|
329
|
+
// NATIVELY here: a `mockups\a.png` an agent wrote on Windows resolves on every host.
|
|
330
|
+
const segments = rel.split(/[\\/]+/).filter(Boolean);
|
|
331
|
+
|
|
332
|
+
let abs = null;
|
|
333
|
+
for (const root of realRoots) {
|
|
334
|
+
let candidate;
|
|
335
|
+
try { candidate = await realpath(resolve(root, ...segments)); } catch { continue; }
|
|
336
|
+
if (!isInside(root, candidate, { fold })) continue; // a symlink out of the tree refuses itself
|
|
337
|
+
abs = candidate;
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
if (!abs) { fail('file-path', `"${rel}" is not inside the node working tree or the pipeline dir`); continue; }
|
|
341
|
+
|
|
342
|
+
const st = await lstat(abs).catch(() => null);
|
|
343
|
+
if (!st || !st.isFile()) { fail('file-path', `"${rel}" is not a regular file`); continue; }
|
|
344
|
+
if (st.size > ASK_LIMITS.fileBytes) {
|
|
345
|
+
fail('too-big', `"${rel}" is ${st.size} bytes; the limit is ${ASK_LIMITS.fileBytes}`);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (total + st.size > ASK_LIMITS.askBytes) {
|
|
349
|
+
fail('too-big', `one ask may carry at most ${ASK_LIMITS.askBytes} bytes of files`);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const mime = await sniffFile(abs, st.size);
|
|
354
|
+
if (mime === TEXT_TOO_BIG) {
|
|
355
|
+
fail('too-big', `"${rel}" is ${st.size} bytes with no recognized file signature; a text preview may not exceed ${TEXT_SNIFF_MAX} bytes`);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (!mime || !ASK_FILE_MIMES[mime]) {
|
|
359
|
+
fail('type', `"${rel}" is not a file type worca can display`);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (!mimeMatchesAccept(mime, ref.accept)) {
|
|
363
|
+
fail('type', `"${rel}" is ${mime}; this field accepts ${(ref.accept || []).join(', ')}`);
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const stored = `${index}${ASK_FILE_MIMES[mime].ext}`;
|
|
368
|
+
await mkdir(destDir, { recursive: true });
|
|
369
|
+
const target = join(destDir, stored);
|
|
370
|
+
await copyFile(abs, target);
|
|
371
|
+
files.push({
|
|
372
|
+
index, rel, name: segments[segments.length - 1] || rel, mime,
|
|
373
|
+
bytes: st.size, sha256: await hashFile(target), stored,
|
|
374
|
+
});
|
|
375
|
+
total += st.size;
|
|
376
|
+
}
|
|
377
|
+
if (files.length && !errors.length) {
|
|
378
|
+
await writeFile(join(destDir, 'manifest.json'), `${JSON.stringify({ version: 1, files }, null, 2)}\n`, 'utf8');
|
|
379
|
+
}
|
|
380
|
+
return { files, errors };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* The file routes' reader: ONE manifest entry by its index. The returned
|
|
385
|
+
* `stored` is re-validated against STORED_RE, so even a hand-edited manifest can
|
|
386
|
+
* never name a path — it is the only string the route joins onto `dir`.
|
|
387
|
+
* @param {string} dir `<pipelineDir>/ask-files/<askId>`
|
|
388
|
+
* @param {number} index
|
|
389
|
+
* @returns {Promise<{index:number, stored:string, mime:string, name:string, bytes:number, sha256:string}|null>}
|
|
390
|
+
*/
|
|
391
|
+
export async function readAskFileEntry(dir, index) {
|
|
392
|
+
let manifest;
|
|
393
|
+
try {
|
|
394
|
+
manifest = JSON.parse(await readFile(join(dir, 'manifest.json'), 'utf8'));
|
|
395
|
+
} catch {
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
const hit = (Array.isArray(manifest?.files) ? manifest.files : []).find((f) => f && f.index === index);
|
|
399
|
+
if (!hit || typeof hit.stored !== 'string' || !STORED_RE.test(hit.stored)) return null;
|
|
400
|
+
if (!hit.mime || !ASK_FILE_MIMES[hit.mime]) return null;
|
|
401
|
+
return {
|
|
402
|
+
index, stored: hit.stored, mime: hit.mime,
|
|
403
|
+
name: typeof hit.name === 'string' ? hit.name : hit.stored,
|
|
404
|
+
bytes: Number(hit.bytes) || 0, sha256: typeof hit.sha256 === 'string' ? hit.sha256 : '',
|
|
405
|
+
};
|
|
406
|
+
}
|