@tea-agent/loop-agent 0.43.0-next.5 → 0.43.0-next.7
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/CHANGELOG.md +7 -0
- package/dist/build-stamp.json +3 -3
- package/dist/executors/dag-pi-executor.js +15 -195
- package/dist/executors/pi-sdk-executor.js +3 -1
- package/dist/worker/console/chat/browser-policy.js +143 -0
- package/dist/worker/console/chat/browser-routes.js +148 -0
- package/dist/worker/console/chat/routes.js +5 -0
- package/dist/worker/console/chat/scm-routes.js +25 -1
- package/dist/worker/console/chat/scm-service.js +30 -0
- package/dist/worker/console/chat/session-catalog.js +31 -0
- package/dist/worker/console/chat/session-mode-view.js +3 -3
- package/dist/worker/console/server.js +2 -28
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-BuyrlONK.js → abnfDiagram-N423BO3Z-0z1FF_Mr.js} +1 -1
- package/dist/worker/console/static/assets/{arc-BvCl_wpi.js → arc-5Rtc6XV_.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-BkBpBaRq.js → architectureDiagram-T3A2C74G-BkywIxBo.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-yve4Mcb_.js → blockDiagram-VBNYF7ZC-g1wALcrC.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-9yLGboBx.js → c4Diagram-5PPSVZJV-CtFfJvWJ.js} +1 -1
- package/dist/worker/console/static/assets/channel-Ln3PalCI.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-BKP69b8r.js → chunk-2GRJ4B5K-D5PQz8yM.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-NWFRnXju.js → chunk-2Q5K7J3B-DgV_JJPi.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BiweqLEc.js → chunk-5RXB4S5H-xwfF7A20.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-CP9GHmRY.js → chunk-5VM5RSS4-CGqPeDdw.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-BiGDkBtj.js → chunk-6Q2QTUOP-0wVqzhBr.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-DkJ4ZjQS.js → chunk-GF5L2VYU-CMbMHcFc.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-B5jjn7zq.js → chunk-JWPE2WC7-BHizoNTm.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-C6KoK9O0.js → chunk-KBJHAD2P-PS3GrTTl.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-BvNIfyn-.js → chunk-RYQCIY6F-igvsougt.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-BZ33iX4W.js → chunk-XXDRQBXY-DtyvbRKY.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BwEIBfqD.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BwEIBfqD.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CP8eIuZd.js → cose-bilkent-JH36ORCC-Y3cTrz6x.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-DZECjpOe.js → cynefin-VYW2F7L2-CNz317P7.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-CvVJoFqf.js → cynefinDiagram-MW4NZA55-CqJD0Eda.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-D_JTqdAn.js → dagre-VZM6K2ZE-DB9xkqs_.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-Db7AQ0z0.js → diagram-7IWD3JNH-BvMd4WO1.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-AZPVo5CZ.js → diagram-B4RE2ZJO-kzIbfP-r.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-BgXqI3E6.js → diagram-LBJQPF4R-D7DuDLA1.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BYjFn3Ba.js → diagram-Q27KOJAE-DWYiiEqR.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-B4rJgGGO.js → diagram-UB23O5K3-BRnBFo_0.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-C6XWZhpw.js → ebnfDiagram-BXEA7PRR-DeTE8_JP.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-1YdQRIQW.js → erDiagram-JOGREHBK-DJIc6Ji-.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-l3a0AwN0.js → flowDiagram-UKHOOZJN-DHYWItNm.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-CQL96F2Z.js → ganttDiagram-PKOTCBZU-BtwhbiNv.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-BxCvafRb.js → gitGraphDiagram-DS77QQ5N-R21xagUM.js} +1 -1
- package/dist/worker/console/static/assets/index-Bf7dxfm-.css +1 -0
- package/dist/worker/console/static/assets/index-DHrt_6jk.js +468 -0
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-BUipIeDO.js → infoDiagram-6WML65LV-xn9vhN0g.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-CRfomVMX.js → ishikawaDiagram-WSZJBQD7-B1MMrk5S.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-_UFvyviX.js → journeyDiagram-NVQOT4AX-Bt_eha4U.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-CKIRoqKv.js → kanban-definition-27J2QSJJ-CPIEKJzB.js} +1 -1
- package/dist/worker/console/static/assets/{linear-CjttJDCM.js → linear-CT6doEH4.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-DPvHX155.js → mermaid.core-_8ilrIeK.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-Qmj-qwBe.js → mindmap-definition-FAOFIHXS-xzLuFnQq.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-Auw6DRgq.js → pegDiagram-VL7TDLO6-BUK6JRUg.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-CqMrfD4c.js → pieDiagram-7S7Q4E2Y-ChFOxDhD.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-BAddeggH.js → quadrantDiagram-CIZ2JOQS-BeoCJ_wm.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-DG1JuOu2.js → railroadDiagram-AXF67PYL-CaSV6rVf.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-DLYGMKlC.js → requirementDiagram-LRYGKXZP-CogY5162.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-g68SbLM9.js → sankeyDiagram-W5VNT64P-CmTAqVmS.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-CmszLKv8.js → sequenceDiagram-SI44F4Z6-DtmgJPSM.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-D2tEKUCj.js → sizeCapture-X5ZJPWSS-DY-e-ucu.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-Syc5E_Jf.js → stateDiagram-OKZ733FA-C0GKqasS.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BmOaJuhY.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-Ibn0IRCa.js → swimlanes-SLNWSIFB-CHwJlgUA.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DOCjPMNz.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-2ZW9W3iS.js → timeline-definition-Z64GVDOM-leVMWmSe.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-v50HsKUh.js → vennDiagram-T6HMQDX7-Dh-LA6pq.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DqCXEoDH.js → wardleyDiagram-T6FBY63Y-jBwm-krQ.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-vIP8Vo9d.js → xychartDiagram-ELKLHX3M-CFkAWroq.js} +1 -1
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +12 -15
- package/dist/workflows/dag/frontend-implementation-contract.js +4 -0
- package/dist/workflows/dag/frontend-plan-completeness.js +186 -0
- package/dist/workflows/dag/frontend-plan-recovery-policy.js +18 -0
- package/dist/workflows/dag/frontend-review-scopes.js +32 -10
- package/package.json +1 -1
- package/dist/worker/console/static/assets/channel-BRahIhjw.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-GOC80mhq.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-GOC80mhq.js +0 -1
- package/dist/worker/console/static/assets/index-4wCGsXuU.js +0 -465
- package/dist/worker/console/static/assets/index-CiF9WBoR.css +0 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-CwWwuPI-.js +0 -1
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-A7fqemr6.js +0 -8
package/CHANGELOG.md
CHANGED
|
@@ -4,12 +4,18 @@
|
|
|
4
4
|
|
|
5
5
|
### 新增
|
|
6
6
|
|
|
7
|
+
- Operator Chat 浏览器面板(DSH 风格):Console 右侧辅助栏新增「浏览器」tab——地址栏(自动补 https、仅 http/https、禁止 javascript/data/file 等危险协议与本机回环地址)、后退/前进/刷新导航栈、系统浏览器跳转;页面运行在无 same-origin、无 top-navigation 的沙箱 iframe 中(访问的页面拿不到 Console 的存储与 API),沙箱状态条支持单页面临时解锁(红色警示 + 一键恢复);对拒绝被嵌入的站点(X-Frame-Options / frame-ancestors)给出明确解释面板与「在系统浏览器打开 / 仍要加载」出口,探测端点仅返回响应头、仅限 http(s) 非回环、8 秒硬超时。
|
|
7
8
|
- Operator Chat 源代码管理(DSH 风格):模型获得 `scm` 工具(status/diff/stage/unstage/discard/commit/log/branch/push/pull,固定参数 git 无 shell、限时限量输出);Console 右侧辅助栏新增「源码」面板(DSH GitView 布局——分支下拉切换、已暂存/更改分组与全部暂存/取消暂存、单行提交框 Ctrl+Enter、内联分页历史带 refs 徽标与相对时间、点击文件看 diff、右键菜单放弃更改/复制路径);模型与面板共用同一工作区级 ScmService。
|
|
8
9
|
- Operator Chat 在具备 Aimax 凭据资格时,向对话进程注入 `$AIMAX_AUTH_TOKEN`(明文或 `sm4:v1:` 解密后的 ticket),供 skills 读取;文件缺失、无法解析或解密失败时不注入该变量。
|
|
9
10
|
- Operator Chat 终端管理(DSH 风格):模型获得 `terminal` 持久 PTY 工具(跨平台 shell 目录——Windows 提供 powershell/bash,macOS/Linux 提供 bash/zsh,pwsh 7 装了即用;cwd/环境变量/后台任务跨调用保留,完成标记协议 + 超时与截断保护,枚举与默认值按平台动态生成);Console 右侧辅助栏新增「终端」面板(xterm.js 渲染,实时查看/接管模型的 shell,按平台动态展示可创建的 shell,支持发送输入、Ctrl+C、终止并关闭);模型 shell 与用户面板共用同一会话注册表并开在工作区仓库根目录,命名区分来源。
|
|
10
11
|
|
|
11
12
|
### 改进
|
|
12
13
|
|
|
14
|
+
- 修复前端契约可选字段未填写时,评审分片索引引用不存在字段、导致设计评审启动前失败的问题。分片生成跳过 undefined;读取端明确拒绝缺失字段、无效引用、重复或非法范围及未索引内容,保留合法空数组和 JSON 空值。
|
|
15
|
+
|
|
16
|
+
- Operator Chat 侧栏「添加工作区」图标改为与 DSH 一致的文件夹加号,不再用纯加号。
|
|
17
|
+
- Operator Chat 侧栏改为单一「新会话」;空白会话在输入框上方用芯片选择工作区和模式(工作流 / Pi),有历史后芯片收起。不再用落地页双卡或「新 Pi」按钮;换模式会复用或新建对应空白会话,不会改已有会话的模式。
|
|
18
|
+
- 修复模型续跑取消后带部分文字仍被标记成功的问题;前端 Plan 的取消、认证、限流及策略失败优先于容量恢复,精确格式错误保留原修复路径。完整性检查按需求建立单次索引,减少大计划重复扫描,同时拆分纯检查与恢复策略模块。
|
|
13
19
|
- 优化 Operator Chat 长会话的流式渲染:完成回合、消息行、工具卡和用户输入锚点建立稳定的 memo 边界,流式文本更新不再重复渲染整段历史或重装锚点测量监听;缓存会在历史消息或工具实际更新时正确失效;无新增折叠回合时不再提交空状态及重复写入本地存储,工具参数与结果格式化按真实输入变化缓存。
|
|
14
20
|
- 对话页「过程折叠」对齐 DSH 细节:折叠后的内容仍保留在页面中,浏览器 Ctrl+F 可直接命中并自动展开;折叠行展开时不再多出底部空隙;箭头改为 16px、颜色更淡的固定图标(靠旋转而非换图标),并补齐系统「减少动态效果」偏好;汇总文案新增「N 个 subagent」,内置 Explore 子代理不再计入普通工具调用。
|
|
15
21
|
- 恢复前端 Plan 实时事实分页读取、逐需求 UX 遗漏检查与已完成范围复用;超限时补齐剩余验证目标,并接通 DAG 到 SDK 的原会话续跑。局部状态修改保留其他需求的关联,沿用耐久账本、显式替换和冻结分片边界。
|
|
@@ -31,6 +37,7 @@
|
|
|
31
37
|
|
|
32
38
|
### 修复
|
|
33
39
|
|
|
40
|
+
- 修复点「新 Pi」后一发消息侧栏就变成「工作流」:全局会话列表和点进会话时漏了 `sessionMode`,缺省被当成工作流。
|
|
34
41
|
- 修复对话页存在运行活动时,收起状态的右侧辅助栏展开按钮覆盖「运行活动 N」及数字徽标的问题;中大尺寸布局现在保留 8px 操作间距,窄屏布局不变。
|
|
35
42
|
- 修复 Operator Chat 消息流的六处实际使用问题:
|
|
36
43
|
- **回复被吞**:断线/重连(含每 25 秒的 SSE 事件窗口轮转)后,保留事件里已经生成的正文会在事件流恢复时重新应用到对应回合,不再被静默丢弃;仅当该回合已有更新内容时才跳过,避免重复气泡。
|
package/dist/build-stamp.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"version": "0.43.0-next.
|
|
4
|
-
"gitSha": "
|
|
5
|
-
"builtAt": "2026-09-
|
|
3
|
+
"version": "0.43.0-next.7",
|
|
4
|
+
"gitSha": "55437ad2cc676289ad970390a991cf28f47584d2",
|
|
5
|
+
"builtAt": "2026-09-09T08:12:25.724Z"
|
|
6
6
|
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { collectFrontendPlanMissingFacts, collectFrontendPlanPhaseMissingFacts, committedFactFromPlanRecord, planFactStringList, planFactScopeIntersects } from "../workflows/dag/frontend-plan-completeness.js";
|
|
2
|
+
export { collectFrontendPlanMissingFacts, collectFrontendPlanPhaseMissingFacts } from "../workflows/dag/frontend-plan-completeness.js";
|
|
3
|
+
import { classifyFrontendPlanRecovery } from "../workflows/dag/frontend-plan-recovery-policy.js";
|
|
1
4
|
import { collectFrontendExecutionGroups, frontendExecutionSchema } from "../workflows/dag/frontend-execution-groups.js";
|
|
2
5
|
import { FRONTEND_SCOPE_TARGET_BYTES, packFrontendInputUnits, parseFrontendInputBlock, projectFrontendContractPrompt, projectFrontendInputScope } from "../workflows/dag/frontend-input-projection.js";
|
|
3
6
|
import { createDurableFrontendTools } from "../workflows/dag/frontend-durable-tools.js";
|
|
@@ -268,16 +271,6 @@ export function isPlannerThinkingExhausted(result, committedAnyFacts) {
|
|
|
268
271
|
return false;
|
|
269
272
|
return true;
|
|
270
273
|
}
|
|
271
|
-
/** Capacity recovery must not depend on whether a gateway emitted prose or
|
|
272
|
-
* diagnostics. Precise provider/governance failures retain their ownership. */
|
|
273
|
-
function isFrontendPlanCapacityStop(result) {
|
|
274
|
-
if (result.ok || result.timedOut)
|
|
275
|
-
return false;
|
|
276
|
-
if (result.failureCategory === OUTPUT_LIMIT_RETRY_CATEGORY)
|
|
277
|
-
return true;
|
|
278
|
-
return readWriterThinkingExhaustionEvidence(result).stopReason === "length" &&
|
|
279
|
-
(!result.failureCategory || ["empty-output", "network", "nonzero-exit", "unknown"].includes(result.failureCategory));
|
|
280
|
-
}
|
|
281
274
|
/**
|
|
282
275
|
* The writer session burned an excessive token budget (a read-edit-test loop
|
|
283
276
|
* that never converged) and still failed. Distinct from empty-output so the
|
|
@@ -3811,180 +3804,6 @@ const FRONTEND_PLAN_SEGMENTS = [
|
|
|
3811
3804
|
].join(" "),
|
|
3812
3805
|
},
|
|
3813
3806
|
];
|
|
3814
|
-
function committedFactFromPlanRecord(value) {
|
|
3815
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
3816
|
-
return undefined;
|
|
3817
|
-
const record = value;
|
|
3818
|
-
if (record.phase !== undefined && record.phase !== "committed")
|
|
3819
|
-
return undefined;
|
|
3820
|
-
const fact = record.fact;
|
|
3821
|
-
return fact && typeof fact === "object" && !Array.isArray(fact)
|
|
3822
|
-
? fact
|
|
3823
|
-
: typeof record.kind === "string"
|
|
3824
|
-
? record
|
|
3825
|
-
: undefined;
|
|
3826
|
-
}
|
|
3827
|
-
function planFactStringList(value) {
|
|
3828
|
-
if (!Array.isArray(value))
|
|
3829
|
-
return [];
|
|
3830
|
-
return value.filter((item) => typeof item === "string" && item.trim().length > 0);
|
|
3831
|
-
}
|
|
3832
|
-
function planFactScopeIntersects(fact, requirementIds) {
|
|
3833
|
-
return planFactStringList(fact.scopeRequirementIds).some((id) => requirementIds.has(id));
|
|
3834
|
-
}
|
|
3835
|
-
/** Compute the authoritative coverage queue from the committed plan ledger. */
|
|
3836
|
-
export function collectFrontendPlanMissingFacts(input) {
|
|
3837
|
-
const requirements = new Map();
|
|
3838
|
-
const standaloneEvidenceGaps = new Set();
|
|
3839
|
-
const verificationTargetIds = new Set();
|
|
3840
|
-
const verificationTargetRequirements = new Map();
|
|
3841
|
-
for (const value of input.committedFacts) {
|
|
3842
|
-
const fact = committedFactFromPlanRecord(value);
|
|
3843
|
-
if (!fact || fact.origin !== "plan")
|
|
3844
|
-
continue;
|
|
3845
|
-
if (fact.kind === "plan-requirement" && fact.entry && typeof fact.entry === "object") {
|
|
3846
|
-
const entry = fact.entry;
|
|
3847
|
-
if (typeof entry.id === "string" && entry.id.trim())
|
|
3848
|
-
requirements.set(entry.id, entry);
|
|
3849
|
-
}
|
|
3850
|
-
if (fact.kind === "plan-verification-target" && fact.entry && typeof fact.entry === "object") {
|
|
3851
|
-
const entry = fact.entry;
|
|
3852
|
-
const id = entry.id;
|
|
3853
|
-
if (typeof id === "string" && id.trim()) {
|
|
3854
|
-
verificationTargetIds.add(id);
|
|
3855
|
-
verificationTargetRequirements.set(id, new Set(Array.isArray(entry.requirementIds)
|
|
3856
|
-
? entry.requirementIds.filter((value) => typeof value === "string")
|
|
3857
|
-
: []));
|
|
3858
|
-
}
|
|
3859
|
-
}
|
|
3860
|
-
if (fact.kind === "plan-evidence-gap" && fact.entry && typeof fact.entry === "object") {
|
|
3861
|
-
const entry = fact.entry;
|
|
3862
|
-
const requirementId = entry.requirementId;
|
|
3863
|
-
const description = entry.description;
|
|
3864
|
-
if (typeof requirementId === "string" && requirementId.trim() && typeof description === "string" && description.trim()) {
|
|
3865
|
-
standaloneEvidenceGaps.add(requirementId);
|
|
3866
|
-
}
|
|
3867
|
-
}
|
|
3868
|
-
}
|
|
3869
|
-
const missing = [];
|
|
3870
|
-
for (const id of input.requirementIds) {
|
|
3871
|
-
const entry = requirements.get(id);
|
|
3872
|
-
if (!entry) {
|
|
3873
|
-
missing.push({
|
|
3874
|
-
kind: "plan-requirement",
|
|
3875
|
-
id,
|
|
3876
|
-
requirementIds: [id],
|
|
3877
|
-
reason: `requirement ${id} has no committed plan-requirement fact`,
|
|
3878
|
-
});
|
|
3879
|
-
continue;
|
|
3880
|
-
}
|
|
3881
|
-
const targetIds = Array.isArray(entry.verificationTargetIds)
|
|
3882
|
-
? entry.verificationTargetIds.filter((value) => typeof value === "string" && value.trim().length > 0)
|
|
3883
|
-
: [];
|
|
3884
|
-
const gap = entry.evidenceGap && typeof entry.evidenceGap === "object"
|
|
3885
|
-
? entry.evidenceGap
|
|
3886
|
-
: undefined;
|
|
3887
|
-
const hasEvidenceGap = (typeof gap?.description === "string" && gap.description.trim().length > 0) ||
|
|
3888
|
-
standaloneEvidenceGaps.has(id);
|
|
3889
|
-
if (targetIds.length === 0 && !hasEvidenceGap) {
|
|
3890
|
-
missing.push({
|
|
3891
|
-
kind: "plan-verification-target",
|
|
3892
|
-
requirementIds: [id],
|
|
3893
|
-
reason: `requirement ${id} declares neither a verification target nor a non-empty evidenceGap`,
|
|
3894
|
-
});
|
|
3895
|
-
continue;
|
|
3896
|
-
}
|
|
3897
|
-
for (const targetId of targetIds) {
|
|
3898
|
-
if (!verificationTargetIds.has(targetId) ||
|
|
3899
|
-
!verificationTargetRequirements.get(targetId)?.has(id)) {
|
|
3900
|
-
missing.push({
|
|
3901
|
-
kind: "plan-verification-target",
|
|
3902
|
-
id: targetId,
|
|
3903
|
-
requirementIds: [id],
|
|
3904
|
-
reason: `requirement ${id} references verification target ${targetId}, but that target is not committed`,
|
|
3905
|
-
});
|
|
3906
|
-
}
|
|
3907
|
-
}
|
|
3908
|
-
}
|
|
3909
|
-
return missing;
|
|
3910
|
-
}
|
|
3911
|
-
/** Completeness checks for phases whose facts are committed incrementally. */
|
|
3912
|
-
export function collectFrontendPlanPhaseMissingFacts(input) {
|
|
3913
|
-
const facts = input.committedFacts
|
|
3914
|
-
.map(committedFactFromPlanRecord)
|
|
3915
|
-
.filter((fact) => Boolean(fact && fact.origin === "plan"));
|
|
3916
|
-
if (input.phase === "ux-registry") {
|
|
3917
|
-
return facts.some((fact) => fact.kind === "state-registry")
|
|
3918
|
-
? []
|
|
3919
|
-
: [
|
|
3920
|
-
{
|
|
3921
|
-
kind: "state-registry",
|
|
3922
|
-
requirementIds: [...input.requirementIds],
|
|
3923
|
-
reason: "global UX vocabulary phase has no committed state-registry fact",
|
|
3924
|
-
},
|
|
3925
|
-
];
|
|
3926
|
-
}
|
|
3927
|
-
if (input.phase === "ux-local") {
|
|
3928
|
-
if (input.requirementIds.length > 1) {
|
|
3929
|
-
return input.requirementIds.flatMap(id => collectFrontendPlanPhaseMissingFacts({
|
|
3930
|
-
...input, requirementIds: [id],
|
|
3931
|
-
}));
|
|
3932
|
-
}
|
|
3933
|
-
const needsUx = input.requirementIds.some((id) => input.behaviorRequiredRequirementIds?.includes(id));
|
|
3934
|
-
if (!needsUx)
|
|
3935
|
-
return [];
|
|
3936
|
-
const requirementSlice = new Set(input.requirementIds);
|
|
3937
|
-
const scopedFacts = facts.filter((fact) => planFactScopeIntersects(fact, requirementSlice));
|
|
3938
|
-
const hasChoice = scopedFacts.some((fact) => fact.kind === "component-choice" &&
|
|
3939
|
-
Array.isArray(fact.uiComponentChoices) &&
|
|
3940
|
-
fact.uiComponentChoices.length > 0);
|
|
3941
|
-
const canonicalStateFlow = collectCanonicalStateFlowNames(scopedFacts);
|
|
3942
|
-
const liveStateFlow = collectCanonicalStateFlowNames(facts);
|
|
3943
|
-
const hasStateFlow = [...canonicalStateFlow.uiStateNames].some(name => liveStateFlow.uiStateNames.has(name)) ||
|
|
3944
|
-
[...canonicalStateFlow.interactionNames].some(name => liveStateFlow.interactionNames.has(name));
|
|
3945
|
-
const missing = [];
|
|
3946
|
-
if (!hasChoice) {
|
|
3947
|
-
missing.push({
|
|
3948
|
-
kind: "component-choice",
|
|
3949
|
-
requirementIds: [...input.requirementIds],
|
|
3950
|
-
reason: "behaviour-required UX slice has no committed component-choice fact",
|
|
3951
|
-
});
|
|
3952
|
-
}
|
|
3953
|
-
if (!hasStateFlow) {
|
|
3954
|
-
missing.push({
|
|
3955
|
-
kind: "state-flow",
|
|
3956
|
-
requirementIds: [...input.requirementIds],
|
|
3957
|
-
reason: "behaviour-required UX slice has no committed state-flow fact",
|
|
3958
|
-
});
|
|
3959
|
-
}
|
|
3960
|
-
return missing;
|
|
3961
|
-
}
|
|
3962
|
-
const hasMockApi = facts.some((fact) => fact.kind === "mock-api");
|
|
3963
|
-
const allInteractions = collectCanonicalStateFlowNames(input.committedFacts).interactionNames;
|
|
3964
|
-
const liveInteractions = new Set([...collectCanonicalStateFlowNames(facts.filter(f => !planFactStringList(f.scopeRequirementIds).length || planFactScopeIntersects(f, new Set(input.requirementIds)))).interactionNames].filter(name => allInteractions.has(name)));
|
|
3965
|
-
const coveredInteractions = new Set(facts
|
|
3966
|
-
.filter((fact) => fact.kind === "data-flow")
|
|
3967
|
-
.flatMap((fact) => planFactStringList(fact.interactions)));
|
|
3968
|
-
const missing = [];
|
|
3969
|
-
if (!hasMockApi) {
|
|
3970
|
-
missing.push({
|
|
3971
|
-
kind: "mock-api",
|
|
3972
|
-
requirementIds: [...input.requirementIds],
|
|
3973
|
-
reason: "global Mock/data phase has no committed mock-api fact",
|
|
3974
|
-
});
|
|
3975
|
-
}
|
|
3976
|
-
for (const interaction of liveInteractions) {
|
|
3977
|
-
if (coveredInteractions.has(interaction))
|
|
3978
|
-
continue;
|
|
3979
|
-
missing.push({
|
|
3980
|
-
kind: "data-flow",
|
|
3981
|
-
id: interaction,
|
|
3982
|
-
requirementIds: [...input.requirementIds],
|
|
3983
|
-
reason: `interaction ${interaction} has no committed data-flow fact`,
|
|
3984
|
-
});
|
|
3985
|
-
}
|
|
3986
|
-
return missing;
|
|
3987
|
-
}
|
|
3988
3807
|
/** Estimate calls conservatively: requirement + one VT, with a second VT
|
|
3989
3808
|
* reserved for behaviour-required requirements. Explicit declarations win. */
|
|
3990
3809
|
export function estimateFrontendPlanRequirementRecordCalls(fact) {
|
|
@@ -5069,13 +4888,13 @@ export async function runFrontendPlanSegmentedSessions(input) {
|
|
|
5069
4888
|
// ownership; an unrelated/global fact must not prove a slice complete.
|
|
5070
4889
|
if (input.attempt > 1 && input.committedFacts && session.id.startsWith("ux-local-") && session.requirementSlice?.length) {
|
|
5071
4890
|
const facts = input.committedFacts();
|
|
5072
|
-
const
|
|
5073
|
-
const
|
|
5074
|
-
|
|
5075
|
-
|
|
5076
|
-
|
|
5077
|
-
|
|
5078
|
-
|
|
4891
|
+
const ownedIds = new Set(facts.flatMap(value => {
|
|
4892
|
+
const fact = committedFactFromPlanRecord(value);
|
|
4893
|
+
return fact ? planFactStringList(fact.scopeRequirementIds) : [];
|
|
4894
|
+
}));
|
|
4895
|
+
const complete = session.requirementSlice.every(id => ownedIds.has(id)) &&
|
|
4896
|
+
collectFrontendPlanPhaseMissingFacts({ phase: "ux-local", requirementIds: session.requirementSlice,
|
|
4897
|
+
committedFacts: facts, behaviorRequiredRequirementIds: input.behaviorRequiredRequirementIds }).length === 0;
|
|
5079
4898
|
if (complete) {
|
|
5080
4899
|
index += 1;
|
|
5081
4900
|
continue;
|
|
@@ -5278,9 +5097,10 @@ export async function runFrontendPlanSegmentedSessions(input) {
|
|
|
5278
5097
|
: isUxLocalSession
|
|
5279
5098
|
? buildUxPrompt(session.requirementSlice ?? [], missing)
|
|
5280
5099
|
: buildPhasePrompt(globalMockDataSegment, missing, session.requirementSlice ?? allRequirementIds);
|
|
5281
|
-
|
|
5100
|
+
const recovery = classifyFrontendPlanRecovery({ ...result, stopReason: readWriterThinkingExhaustionEvidence(result).stopReason });
|
|
5101
|
+
if (recovery === "stop")
|
|
5282
5102
|
return { ...result, ok: false };
|
|
5283
|
-
if ((
|
|
5103
|
+
if ((recovery === "output" || (session.atomicRecovery && result.ok)) && input.committedFacts &&
|
|
5284
5104
|
!(missingPhaseFacts.length === 1 && missingPhaseFacts[0].kind === "plan-requirement") &&
|
|
5285
5105
|
(session.coverageOnly || isUxRegistrySession || isUxLocalSession || session.id.startsWith("global-mock-data"))) {
|
|
5286
5106
|
if (missingPhaseFacts.length === 0) {
|
|
@@ -5317,7 +5137,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
|
|
|
5317
5137
|
return { ...accumulated, ok: false, failureCategory: OUTPUT_LIMIT_RETRY_CATEGORY,
|
|
5318
5138
|
stderr: `${accumulated.stderr}\nfrontend plan capacity recovery exhausted: preserve committed facts; phase=${session.id}; scope=${missingIds.join(",")}; strategy=atomic-fact; missing=${JSON.stringify(missingPhaseFacts)}`.trim() };
|
|
5319
5139
|
}
|
|
5320
|
-
const capacityExhausted =
|
|
5140
|
+
const capacityExhausted = recovery === "output" || recovery === "context";
|
|
5321
5141
|
if (capacityExhausted && !result.timedOut) {
|
|
5322
5142
|
const failure = () => mapPlannerExhaustion({ ...result, ok: false, stderr: `${result.stderr}\nFRONTEND_INPUT_UNIT_TOO_LARGE: ${session.id}; no smaller complete scope can finish; unchanged retries are disabled` }, committedAfter > committedBefore);
|
|
5323
5143
|
const missingCoverageIds = input.committedFacts ? [...new Set(collectFrontendPlanMissingFacts({ requirementIds: session.coverageSlice ?? session.requirementSlice ?? allRequirementIds, committedFacts: input.committedFacts() }).flatMap(f => f.requirementIds))] : [...(session.coverageSlice ?? session.requirementSlice ?? allRequirementIds)];
|
|
@@ -5423,7 +5243,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
|
|
|
5423
5243
|
: undefined;
|
|
5424
5244
|
const zeroProgressBurn = coverageSlice !== undefined &&
|
|
5425
5245
|
coverageSlice.length > 1 &&
|
|
5426
|
-
(
|
|
5246
|
+
(capacityExhausted || (["empty-output", "unknown"].includes(result.failureCategory) && committedAfter === committedBefore && !(result.assistantText ?? "").trim() && !result.stderr.trim())) &&
|
|
5427
5247
|
!result.timedOut;
|
|
5428
5248
|
if (zeroProgressBurn && coverageSlice) {
|
|
5429
5249
|
const missingIds = input.committedFacts ? new Set(collectFrontendPlanMissingFacts({ requirementIds: coverageSlice, committedFacts: input.committedFacts() }).flatMap(f => f.requirementIds)) : undefined;
|
|
@@ -978,6 +978,7 @@ async function executeSingleSdkAttemptInternal(options) {
|
|
|
978
978
|
? collected.parsedEvents
|
|
979
979
|
: (fallbackParsed?.parsedEvents ?? 0);
|
|
980
980
|
const tokensUsed = aggregateSdkTokenUsage(usageSamples);
|
|
981
|
+
const cancelled = stopReason === "aborted";
|
|
981
982
|
const outputLimitExhausted = Boolean(options.outputLimitRecovery && stopReason === "length" && !committedTerminalObserved && !timedOut && !stderr);
|
|
982
983
|
const failureCategory = !terminationConfirmed ? "termination-unconfirmed" : stopReason === "aborted" ? "cancelled" : outputLimitExhausted ? "output-limit" : providerBudgetFailure ? (providerBudgetFailure.includes("REQUEST_ABORTED") ? "interrupted" : providerBudgetFailure.startsWith("FRONTEND_CONTEXT_ESTIMATE_EXCEEDED") ? "context-budget-exhausted" : "budget_breach") : terminationConfirmed
|
|
983
984
|
? classifyPiFailure({
|
|
@@ -994,13 +995,14 @@ async function executeSingleSdkAttemptInternal(options) {
|
|
|
994
995
|
backend: "sdk",
|
|
995
996
|
command: ["pi-sdk", ...piSdkArgs],
|
|
996
997
|
durationMs,
|
|
997
|
-
exitCode: timedOut || stderr || outputLimitExhausted ? 1 : 0,
|
|
998
|
+
exitCode: timedOut || stderr || outputLimitExhausted || cancelled ? 1 : 0,
|
|
998
999
|
failureCategory,
|
|
999
1000
|
modelDisplay,
|
|
1000
1001
|
ok: terminationConfirmed &&
|
|
1001
1002
|
!timedOut &&
|
|
1002
1003
|
!stderr &&
|
|
1003
1004
|
!outputLimitExhausted &&
|
|
1005
|
+
!cancelled &&
|
|
1004
1006
|
assistantText.length > 0 &&
|
|
1005
1007
|
!collected.outputTooLarge,
|
|
1006
1008
|
parsedEvents,
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure URL policy for the Operator Chat browser panel: normalize user input
|
|
3
|
+
* into an http(s) URL and refuse destinations that would be dangerous to
|
|
4
|
+
* embed in the panel iframe. Dependency-free so it is unit-testable.
|
|
5
|
+
*
|
|
6
|
+
* The iframe sandbox (opaque origin — no allow-same-origin, no top
|
|
7
|
+
* navigation) is the primary security boundary; this module is the
|
|
8
|
+
* address-bar gate on top of it: only http/https may be navigated, and
|
|
9
|
+
* loopback addresses are refused so a browsed page cannot probe local
|
|
10
|
+
* services by user action. The Console's OWN origin is explicitly allowed —
|
|
11
|
+
* the sandbox still renders it in an opaque origin with no same-origin
|
|
12
|
+
* privileges, exactly like any other site.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Extract the `frame-ancestors` source list of a Content-Security-Policy
|
|
16
|
+
* header, or undefined when the directive is absent (or empty).
|
|
17
|
+
*/
|
|
18
|
+
export function extractFrameAncestors(csp) {
|
|
19
|
+
if (csp === null)
|
|
20
|
+
return undefined;
|
|
21
|
+
for (const directive of csp.split(";")) {
|
|
22
|
+
const parts = directive.trim().split(/\s+/);
|
|
23
|
+
if (parts[0] === "frame-ancestors") {
|
|
24
|
+
const sources = parts.slice(1).filter((source) => source !== "");
|
|
25
|
+
return sources.length === 0 ? undefined : sources;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Decide whether a site can render inside the panel iframe. The signals are
|
|
32
|
+
* exactly the ones the browser enforces when it refuses an iframe load:
|
|
33
|
+
* X-Frame-Options DENY/SAMEORIGIN, or a frame-ancestors directive that does
|
|
34
|
+
* not allow `*` ('self' means the SITE's own origin — never ours, so it also
|
|
35
|
+
* blocks the panel). A site we could not reach yields 'unknown' and the
|
|
36
|
+
* plain iframe stays.
|
|
37
|
+
*/
|
|
38
|
+
export function embeddabilityOf(probe) {
|
|
39
|
+
if (probe.reachable !== true)
|
|
40
|
+
return "unknown";
|
|
41
|
+
const xfo = probe.xFrameOptions?.trim().toUpperCase();
|
|
42
|
+
if (xfo === "DENY" || xfo === "SAMEORIGIN")
|
|
43
|
+
return "blocked";
|
|
44
|
+
if (probe.frameAncestors !== undefined &&
|
|
45
|
+
!probe.frameAncestors.some((source) => source === "*")) {
|
|
46
|
+
return "blocked";
|
|
47
|
+
}
|
|
48
|
+
return "embeddable";
|
|
49
|
+
}
|
|
50
|
+
/** A loopback hostname (localhost, IPv6 ::1, 127.0.0.0/8, 0.0.0.0). */
|
|
51
|
+
export function isLoopbackHostname(hostname) {
|
|
52
|
+
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
53
|
+
if (host === "localhost" || host === "::1" || host === "0.0.0.0")
|
|
54
|
+
return true;
|
|
55
|
+
const parts = host.split(".");
|
|
56
|
+
return (parts.length === 4 &&
|
|
57
|
+
parts[0] === "127" &&
|
|
58
|
+
parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255));
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Schemes that must never reach the iframe even without `//` (javascript:,
|
|
62
|
+
* data:, file:, ...). Host:port lookalikes (example.com:8080) are NOT here —
|
|
63
|
+
* they parse as hosts below.
|
|
64
|
+
*/
|
|
65
|
+
const FORBIDDEN_SCHEMES = new Set([
|
|
66
|
+
"javascript",
|
|
67
|
+
"data",
|
|
68
|
+
"file",
|
|
69
|
+
"about",
|
|
70
|
+
"vbscript",
|
|
71
|
+
"blob",
|
|
72
|
+
"mailto",
|
|
73
|
+
"tel",
|
|
74
|
+
"ftp",
|
|
75
|
+
"ftps",
|
|
76
|
+
"ws",
|
|
77
|
+
"wss",
|
|
78
|
+
"sftp",
|
|
79
|
+
"ssh",
|
|
80
|
+
"chrome",
|
|
81
|
+
"chrome-extension",
|
|
82
|
+
"moz-extension",
|
|
83
|
+
"edge",
|
|
84
|
+
"opera",
|
|
85
|
+
"resource",
|
|
86
|
+
"view-source",
|
|
87
|
+
]);
|
|
88
|
+
/**
|
|
89
|
+
* Normalize one address-bar input against the navigation policy.
|
|
90
|
+
* @param input raw user text.
|
|
91
|
+
* @param selfOrigin the Console's own origin (window.location.origin). The
|
|
92
|
+
* Console itself may be browsed in the panel (the sandbox keeps it opaque),
|
|
93
|
+
* so it is let through BEFORE the loopback check — its host is normally
|
|
94
|
+
* loopback.
|
|
95
|
+
*/
|
|
96
|
+
export function normalizeBrowserUrl(input, selfOrigin) {
|
|
97
|
+
const trimmed = input.trim();
|
|
98
|
+
if (trimmed === "")
|
|
99
|
+
return { kind: "invalid" };
|
|
100
|
+
// Distinguish an explicit scheme from a bare host:port. "example.com:8080"
|
|
101
|
+
// would match a naive scheme regex (dots are legal in schemes), so a
|
|
102
|
+
// scheme prefix is only honored when it is http(s) or a known-forbidden
|
|
103
|
+
// scheme; anything else is treated as a host and gets https://.
|
|
104
|
+
const schemeMatch = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(trimmed);
|
|
105
|
+
let withScheme;
|
|
106
|
+
if (schemeMatch === null) {
|
|
107
|
+
withScheme = `https://${trimmed}`;
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
const scheme = schemeMatch[1]?.toLowerCase() ?? "";
|
|
111
|
+
if (scheme === "http" || scheme === "https")
|
|
112
|
+
withScheme = trimmed;
|
|
113
|
+
else if (FORBIDDEN_SCHEMES.has(scheme))
|
|
114
|
+
return { kind: "blocked", reason: "scheme" };
|
|
115
|
+
else
|
|
116
|
+
withScheme = `https://${trimmed}`;
|
|
117
|
+
}
|
|
118
|
+
let url;
|
|
119
|
+
try {
|
|
120
|
+
url = new URL(withScheme);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return { kind: "invalid" };
|
|
124
|
+
}
|
|
125
|
+
// Protocol backstop: any URL that still parses to a non-http(s) scheme
|
|
126
|
+
// (ftp://, ws:// — which carry `//` and skip the list) is refused here.
|
|
127
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
128
|
+
return { kind: "blocked", reason: "scheme" };
|
|
129
|
+
}
|
|
130
|
+
// The Console's own origin is allowed (browsed in an opaque origin like
|
|
131
|
+
// any other site); checked before the loopback gate because its host is
|
|
132
|
+
// normally loopback.
|
|
133
|
+
try {
|
|
134
|
+
if (url.origin === new URL(selfOrigin).origin)
|
|
135
|
+
return { kind: "ok", url: url.href };
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// Unparsable selfOrigin (never in practice): fall through to the gate.
|
|
139
|
+
}
|
|
140
|
+
if (isLoopbackHostname(url.hostname))
|
|
141
|
+
return { kind: "blocked", reason: "loopback" };
|
|
142
|
+
return { kind: "ok", url: url.href };
|
|
143
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP routes for the Operator Chat browser panel: one read-only probe
|
|
3
|
+
* endpoint the panel uses to explain an iframe refusal (X-Frame-Options /
|
|
4
|
+
* CSP frame-ancestors are exactly the signals the browser enforces when it
|
|
5
|
+
* refuses to embed a site). The probe is display-only (headers back to the
|
|
6
|
+
* caller), restricted to http(s) non-loopback URLs, with a hard timeout.
|
|
7
|
+
*/
|
|
8
|
+
import { embeddabilityOf, extractFrameAncestors, isLoopbackHostname, } from "./browser-policy.js";
|
|
9
|
+
function workspaceIdOf(req) {
|
|
10
|
+
try {
|
|
11
|
+
return new URL(req.url ?? "/", "http://localhost").searchParams.get("workspaceId") ?? undefined;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function sendJson(res, status, body) {
|
|
18
|
+
res.writeHead(status, {
|
|
19
|
+
"content-type": "application/json; charset=utf-8",
|
|
20
|
+
"cache-control": "no-store",
|
|
21
|
+
});
|
|
22
|
+
res.end(JSON.stringify(body));
|
|
23
|
+
}
|
|
24
|
+
/** Route dispatcher; returns true when the request was handled. */
|
|
25
|
+
export async function handleBrowserRequest(req, res, deps, pathname) {
|
|
26
|
+
if (!pathname.startsWith("/api/operator/v1/chat/browser"))
|
|
27
|
+
return false;
|
|
28
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
29
|
+
const rest = pathname.slice("/api/operator/v1/chat/browser".length);
|
|
30
|
+
const parts = rest.split("/").filter(Boolean);
|
|
31
|
+
const sub = parts[0] ?? "";
|
|
32
|
+
try {
|
|
33
|
+
if (method === "GET" && sub === "probe" && parts.length === 1) {
|
|
34
|
+
// Fail-closed: the probe needs a workspaceId like every chat API.
|
|
35
|
+
if (deps.requireWorkspace) {
|
|
36
|
+
const ws = deps.requireWorkspace(req);
|
|
37
|
+
if (!ws.ok) {
|
|
38
|
+
sendJson(res, 400, {
|
|
39
|
+
ok: false,
|
|
40
|
+
error: { code: "WORKSPACE_REQUIRED", message: "workspaceId is required" },
|
|
41
|
+
});
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
else if (!workspaceIdOf(req)) {
|
|
46
|
+
sendJson(res, 400, {
|
|
47
|
+
ok: false,
|
|
48
|
+
error: { code: "WORKSPACE_REQUIRED", message: "workspaceId is required" },
|
|
49
|
+
});
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
53
|
+
const raw = url.searchParams.get("url") ?? "";
|
|
54
|
+
let parsed;
|
|
55
|
+
try {
|
|
56
|
+
parsed = new URL(raw);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
sendJson(res, 400, {
|
|
60
|
+
ok: false,
|
|
61
|
+
error: { code: "INVALID_URL", message: "invalid url" },
|
|
62
|
+
});
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
66
|
+
sendJson(res, 400, {
|
|
67
|
+
ok: false,
|
|
68
|
+
error: { code: "BAD_SCHEME", message: "only http/https urls can be probed" },
|
|
69
|
+
});
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
// Mirror the address-bar policy: loopback stays unreachable from
|
|
73
|
+
// the panel, so probing it would leak nothing the panel could use.
|
|
74
|
+
if (isLoopbackHostname(parsed.hostname)) {
|
|
75
|
+
sendJson(res, 400, {
|
|
76
|
+
ok: false,
|
|
77
|
+
error: { code: "LOOPBACK_REFUSED", message: "local addresses are not probed" },
|
|
78
|
+
});
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
const controller = new AbortController();
|
|
82
|
+
const timer = setTimeout(() => controller.abort(), 8000);
|
|
83
|
+
try {
|
|
84
|
+
let response = await fetch(parsed, {
|
|
85
|
+
method: "HEAD",
|
|
86
|
+
redirect: "follow",
|
|
87
|
+
signal: controller.signal,
|
|
88
|
+
});
|
|
89
|
+
// Some servers answer HEAD with 405/501; retry once as GET
|
|
90
|
+
// (the body is discarded — only the headers matter).
|
|
91
|
+
if (response.status === 405 || response.status === 501) {
|
|
92
|
+
response = await fetch(parsed, {
|
|
93
|
+
method: "GET",
|
|
94
|
+
redirect: "follow",
|
|
95
|
+
signal: controller.signal,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const csp = response.headers.get("content-security-policy");
|
|
99
|
+
const frameAncestors = extractFrameAncestors(csp);
|
|
100
|
+
const xFrameOptions = response.headers.get("x-frame-options");
|
|
101
|
+
const probe = {
|
|
102
|
+
reachable: true,
|
|
103
|
+
url: response.url,
|
|
104
|
+
status: response.status,
|
|
105
|
+
...(xFrameOptions !== null ? { xFrameOptions } : {}),
|
|
106
|
+
...(frameAncestors !== undefined ? { frameAncestors } : {}),
|
|
107
|
+
};
|
|
108
|
+
sendJson(res, 200, {
|
|
109
|
+
ok: true,
|
|
110
|
+
data: { probe, verdict: embeddabilityOf(probe) },
|
|
111
|
+
});
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// DNS / TLS / connection / timeout: nothing to judge — the
|
|
116
|
+
// client keeps the plain iframe.
|
|
117
|
+
sendJson(res, 200, {
|
|
118
|
+
ok: true,
|
|
119
|
+
data: { probe: { reachable: false }, verdict: "unknown" },
|
|
120
|
+
});
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
sendJson(res, 404, {
|
|
128
|
+
ok: false,
|
|
129
|
+
error: { code: "NOT_FOUND", message: `unknown browser route: ${method} ${pathname}` },
|
|
130
|
+
});
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (!res.headersSent) {
|
|
135
|
+
sendJson(res, 500, {
|
|
136
|
+
ok: false,
|
|
137
|
+
error: {
|
|
138
|
+
code: "BROWSER_INTERNAL",
|
|
139
|
+
message: error instanceof Error ? error.message : String(error),
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
res.end();
|
|
145
|
+
}
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -48,6 +48,7 @@ import { SddDataAlignmentError, } from "./sdd-data-alignment.js";
|
|
|
48
48
|
import { TerminalSessionRegistry } from "./terminal-sessions.js";
|
|
49
49
|
import { handleTerminalRequest } from "./terminal-routes.js";
|
|
50
50
|
import { handleScmRequest } from "./scm-routes.js";
|
|
51
|
+
import { handleBrowserRequest } from "./browser-routes.js";
|
|
51
52
|
function projectBrowserChatSession(record) {
|
|
52
53
|
const { telemetrySessionId, ...projected } = record;
|
|
53
54
|
void telemetrySessionId;
|
|
@@ -4267,6 +4268,10 @@ async function dispatchChatRequest(req, res, deps, pathname, workspaceScope) {
|
|
|
4267
4268
|
return true;
|
|
4268
4269
|
}
|
|
4269
4270
|
}
|
|
4271
|
+
// Browser probe API (read-only; explains iframe refusals to the panel).
|
|
4272
|
+
if (await handleBrowserRequest(req, res, {}, pathname)) {
|
|
4273
|
+
return true;
|
|
4274
|
+
}
|
|
4270
4275
|
if (!pathname.startsWith("/api/operator/v1/chat/") &&
|
|
4271
4276
|
!pathname.startsWith("/api/operator/v1/pi/"))
|
|
4272
4277
|
return false;
|