@tea-agent/loop-agent 0.22.0 → 0.24.0
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/AGENTS.md +42 -108
- package/CHANGELOG.md +85 -0
- package/README.md +8 -5
- package/bin/agent-worker.js +0 -0
- package/dist/application/context-usage/skill-resolution-stats.js +263 -0
- package/dist/application/dag/generate-task-dag.js +17 -3
- package/dist/cli/command-definitions.js +8 -7
- package/dist/cli/program.js +17 -15
- package/dist/commands/doctor.js +269 -18
- package/dist/commands/init.js +101 -86
- package/dist/commands/stats.js +40 -11
- package/dist/executors/shell-executor.js +20 -7
- package/dist/shared/operator/capabilities.js +486 -3
- package/dist/worker/console/app-data.js +6 -0
- package/dist/worker/console/chat/artifact-card.js +23 -0
- package/dist/worker/console/chat/chat-event-store.js +495 -0
- package/dist/worker/console/chat/chat-ui-policy.js +25 -0
- package/dist/worker/console/chat/composer-draft-store.js +45 -0
- package/dist/worker/console/chat/context-panel.js +54 -0
- package/dist/worker/console/chat/contract-apply-receipt-store.js +174 -0
- package/dist/worker/console/chat/explore-tools.js +299 -0
- package/dist/worker/console/chat/human-gate-card.js +37 -0
- package/dist/worker/console/chat/instruction-skills.js +217 -0
- package/dist/worker/console/chat/interview-adapter.js +136 -0
- package/dist/worker/console/chat/model-resolver.js +106 -0
- package/dist/worker/console/chat/operation-card.js +23 -0
- package/dist/worker/console/chat/pi-console-config.js +158 -0
- package/dist/worker/console/chat/pi-runtime.js +1143 -0
- package/dist/worker/console/chat/repo-browser.js +140 -0
- package/dist/worker/console/chat/repo-walk.js +116 -0
- package/dist/worker/console/chat/resource-loader.js +67 -0
- package/dist/worker/console/chat/routes.js +1646 -0
- package/dist/worker/console/chat/runtime-context.js +24 -0
- package/dist/worker/console/chat/runtime-selection.js +37 -0
- package/dist/worker/console/chat/session-store.js +437 -0
- package/dist/worker/console/chat/shortcuts.js +15 -0
- package/dist/worker/console/chat/tool-adapter.js +125 -0
- package/dist/worker/console/chat/tools.js +195 -0
- package/dist/worker/console/chat/usage.js +37 -0
- package/dist/worker/console/chat/workspace-landing.js +56 -0
- package/dist/worker/console/dag-confirmation.js +42 -8
- package/dist/worker/console/human-gate-token.js +130 -0
- package/dist/worker/console/mutation-gate-receipt-store.js +184 -0
- package/dist/worker/console/operation-runner.js +6 -2
- package/dist/worker/console/operation-sse.js +26 -0
- package/dist/worker/console/operator-actions.js +420 -7
- package/dist/worker/console/server.js +68 -1
- package/dist/worker/console/static/assets/index-BTbrEHnO.css +1 -0
- package/dist/worker/console/static/assets/index-D9qLevoP.js +27 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/feature/profile-schema.js +1 -1
- package/dist/workflows/dag/backend-test-markdown-workflow.js +9 -5
- package/dist/workflows/dag/backend-test-result-contract.js +229 -0
- package/dist/workflows/dag/frontend-lint-baseline.js +4 -4
- package/dist/workflows/dag/init-hybrid.js +71 -22
- package/dist/workflows/dag/node-execution.js +38 -1
- package/dist/workflows/dag/output-protocol.js +89 -0
- package/dist/workflows/dag/prompt.js +35 -1
- package/dist/workflows/dag/recovery-recommendation.js +45 -0
- package/dist/workflows/dag/report.js +28 -1
- package/dist/workflows/dag/rerun-task.js +1 -1
- package/dist/workflows/dag/scheduler.js +9 -0
- package/dist/workflows/dag/types.js +12 -0
- package/dist/workflows/dag/validate.js +55 -0
- package/docs/README.md +73 -156
- package/docs/architecture/README.md +7 -6
- package/docs/architecture/dag-execution.md +2 -2
- package/docs/architecture/evolution.md +16 -14
- package/docs/architecture/system-overview.md +1 -1
- package/docs/architecture/worker-and-feature.md +3 -3
- package/docs/governance/README.md +15 -0
- package/docs/{harness-methodology-debugging.md → governance/harness-methodology-debugging.md} +27 -3
- package/docs/init-surface.manifest.json +22 -4
- package/docs/operations/README.md +12 -0
- package/docs/{local-development-environment.md → operations/local-development-environment.md} +1 -1
- package/docs/skills/vetted-skill-registry.md +23 -3
- package/docs/templates/README.md +55 -0
- package/docs/templates/backend-test-dag.json +2 -2
- package/docs/templates/evaluation/agents-map-slim-v1.candidate.json +9 -0
- package/docs/templates/evaluation/agents-map-slim-v1.md +87 -0
- package/docs/templates/evaluation/agents-map-verbose-v0.candidate.json +9 -0
- package/docs/templates/evaluation/agents-map-verbose-v0.md +153 -0
- package/docs/templates/hybrid-dag.json +1 -1
- package/docs/templates/progress-log.md +9 -2
- package/harness.json +4 -4
- package/package.json +5 -5
- package/scripts/kb-bootstrap-init-skeleton.sh +2 -2
- package/skills/agent-worker/SKILL.md +1 -1
- package/skills/grill-with-docs/SKILL.md +44 -52
- package/skills/grill-with-docs/adr-format.md +37 -26
- package/skills/grill-with-docs/context-format.md +18 -26
- package/skills/loop-agent/SKILL.md +28 -112
- package/skills/loop-agent/references/command-reference.md +9 -3
- package/skills/loop-agent/references/harness-policy.md +3 -3
- package/skills/loop-agent/references/hybrid-dag.md +1 -1
- package/skills/loop-agent/references/orchestrator-and-interventions.md +1 -1
- package/skills/loop-agent/references/task-workflow.md +2 -0
- package/skills/systematic-debugging/SKILL.md +20 -4
- package/skills/test-driven-development/SKILL.md +10 -3
- package/dist/worker/console/static/assets/index-CUDke82y.js +0 -18
- package/dist/worker/console/static/assets/index-wSEksVSO.css +0 -1
- /package/docs/{harness-methodology-tdd.md → governance/harness-methodology-tdd.md} +0 -0
- /package/docs/{harness-methodology-verification.md → governance/harness-methodology-verification.md} +0 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator Chat — closed tool whitelist (design §7.5 / roadmap M0).
|
|
3
|
+
*
|
|
4
|
+
* The Chat exposes the model-callable structured operator surface. Repository
|
|
5
|
+
* exploration is deliberately limited to safe-read/safe-grep/git-status/
|
|
6
|
+
* git-diff plus built-in find/ls; the bare SDK bash/read/grep tools are denied
|
|
7
|
+
* because they bypass the M0-A write and sensitive-data boundaries.
|
|
8
|
+
*
|
|
9
|
+
* Dynamic discovery derives model-callable operator actions directly from the
|
|
10
|
+
* capabilities registry. Explicit denial remains the defense-in-depth layer:
|
|
11
|
+
* a future resource-loader or SDK drift cannot reactivate removed coding or
|
|
12
|
+
* explore tools merely by changing the active-tool list.
|
|
13
|
+
*/
|
|
14
|
+
import { buildOperatorCapabilitiesDocument } from "../../../shared/operator/capabilities.js";
|
|
15
|
+
import { OFFICIAL_DENIED_TOOL_IDS } from "../resource-loader.js";
|
|
16
|
+
import { EXECUTABLE_GATE_ONLY_ACTIONS } from "./tool-adapter.js";
|
|
17
|
+
const EXECUTABLE_GATE_ONLY = new Set(EXECUTABLE_GATE_ONLY_ACTIONS);
|
|
18
|
+
/**
|
|
19
|
+
* Actions the model MAY invoke. Derived from the registry but filtered by the
|
|
20
|
+
* modelCallable policy: modelCallable="never" actions (e.g. confirmDagConfirmation)
|
|
21
|
+
* are the human confirmation itself and MUST NOT be model-callable (roadmap
|
|
22
|
+
* M0-B / G03). They are never registered as Pi tools AND denied at the gate.
|
|
23
|
+
*/
|
|
24
|
+
export const OPERATOR_CHAT_ALLOWED_TOOLS = Object.freeze(buildOperatorCapabilitiesDocument()
|
|
25
|
+
.actions.filter((a) => a.modelCallable !== "never" && !EXECUTABLE_GATE_ONLY.has(a.action))
|
|
26
|
+
.map((a) => a.action));
|
|
27
|
+
/**
|
|
28
|
+
* Operator actions that MUST NOT be model-callable (roadmap M0-B / G03).
|
|
29
|
+
* Derived from the registry's modelCallable="never" policy. These are the
|
|
30
|
+
* human confirmation actions (confirmDagConfirmation) — the model can only
|
|
31
|
+
* prepare them; the browser mutation gate executes the confirm with a
|
|
32
|
+
* server-signed confirmation token.
|
|
33
|
+
*/
|
|
34
|
+
export const OPERATOR_CHAT_MODEL_FORBIDDEN_ACTIONS = Object.freeze(buildOperatorCapabilitiesDocument()
|
|
35
|
+
.actions.filter((a) => a.modelCallable === "never" || EXECUTABLE_GATE_ONLY.has(a.action))
|
|
36
|
+
.map((a) => a.action));
|
|
37
|
+
/**
|
|
38
|
+
* Operator actions denied by the legacy MVP boundary. Kept EMPTY since the
|
|
39
|
+
* owner decided (2026-07-25) that the full operator surface is Chat-callable.
|
|
40
|
+
* Retained (empty) so downstream guards referencing it keep compiling and
|
|
41
|
+
* stay defensive against future additions.
|
|
42
|
+
*/
|
|
43
|
+
export const OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS = Object.freeze([]);
|
|
44
|
+
// Coding / generic tool ids that must never appear in a Chat session. M0-A
|
|
45
|
+
// removes bare bash (write-via-redirect) and SDK read/grep (no sensitive-file
|
|
46
|
+
// boundary); find/ls remain the only SDK explore builtins. Safe content probes
|
|
47
|
+
// are custom tools, not generic SDK tools.
|
|
48
|
+
const _ALLOWED_SDK_EXPLORE_TOOLS = new Set(["find", "ls"]);
|
|
49
|
+
const _REMOVED_SDK_EXPLORE_TOOLS = ["bash", "read", "grep"];
|
|
50
|
+
// Operator actions that OFFICIAL_DENIED_TOOL_IDS carries for Interview safety
|
|
51
|
+
// but that the Chat now deliberately allows. We compare on a COMPACTED form
|
|
52
|
+
// (strip - and _) because OFFICIAL lists both camelCase (`contractApply`) and
|
|
53
|
+
// snake_case (`contract_apply`) variants, and authorize() also normalizes —
|
|
54
|
+
// leaving any variant in would re-deny the action via normalization.
|
|
55
|
+
const _ALLOWED_OPERATOR_ACTIONS_COMPACT = new Set(OPERATOR_CHAT_ALLOWED_TOOLS.map((t) => t.toLowerCase().replace(/[-_]/g, "")));
|
|
56
|
+
export const OPERATOR_CHAT_DENIED_TOOLS = Object.freeze([
|
|
57
|
+
...new Set([
|
|
58
|
+
...OFFICIAL_DENIED_TOOL_IDS.filter((id) => {
|
|
59
|
+
const lower = id.toLowerCase();
|
|
60
|
+
if (_ALLOWED_SDK_EXPLORE_TOOLS.has(lower))
|
|
61
|
+
return false;
|
|
62
|
+
if (_ALLOWED_OPERATOR_ACTIONS_COMPACT.has(lower.replace(/[-_]/g, ""))) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}),
|
|
67
|
+
..._REMOVED_SDK_EXPLORE_TOOLS,
|
|
68
|
+
]),
|
|
69
|
+
]);
|
|
70
|
+
/**
|
|
71
|
+
* Operator actions denied by model policy (modelCallable="never"). These are
|
|
72
|
+
* human-only actions (the confirmation itself) — derived from the registry so
|
|
73
|
+
* adding a "never" policy there is the only edit needed. Kept separate from
|
|
74
|
+
* OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS (legacy empty MVP boundary list) to
|
|
75
|
+
* preserve the two distinct concerns (model policy vs MVP mutation boundary).
|
|
76
|
+
*/
|
|
77
|
+
const DENIED_MODEL_POLICY = new Set(OPERATOR_CHAT_MODEL_FORBIDDEN_ACTIONS.map((a) => a.toLowerCase()));
|
|
78
|
+
const ALLOWED = new Set(OPERATOR_CHAT_ALLOWED_TOOLS.map((t) => t.toLowerCase()));
|
|
79
|
+
const DENIED_ACTIONS = new Set(OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS.map((a) => a.toLowerCase()));
|
|
80
|
+
const DENIED_TOOLS = new Set(OPERATOR_CHAT_DENIED_TOOLS.map((t) => t.toLowerCase()));
|
|
81
|
+
export function isOperatorChatToolAllowed(toolId) {
|
|
82
|
+
return ALLOWED.has(toolId.trim().toLowerCase());
|
|
83
|
+
}
|
|
84
|
+
export function isOperatorChatToolDenied(toolId) {
|
|
85
|
+
const id = toolId.trim().toLowerCase();
|
|
86
|
+
if (DENIED_TOOLS.has(id))
|
|
87
|
+
return true;
|
|
88
|
+
if (DENIED_ACTIONS.has(id))
|
|
89
|
+
return true;
|
|
90
|
+
// modelCallable="never" actions are always denied to the model (roadmap G03).
|
|
91
|
+
if (DENIED_MODEL_POLICY.has(id))
|
|
92
|
+
return true;
|
|
93
|
+
// normalize snake/camel/kebab
|
|
94
|
+
const compact = id.replace(/[-_]/g, "");
|
|
95
|
+
for (const d of DENIED_TOOLS) {
|
|
96
|
+
if (d.replace(/[-_]/g, "") === compact)
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
for (const d of DENIED_ACTIONS) {
|
|
100
|
+
if (d.replace(/[-_]/g, "") === compact)
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
for (const d of DENIED_MODEL_POLICY) {
|
|
104
|
+
if (d.replace(/[-_]/g, "") === compact)
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Three-gate authorization (design §7.5): session create / setActiveTools /
|
|
111
|
+
* per-tool-call. Server-side only — UI hiding is insufficient.
|
|
112
|
+
*/
|
|
113
|
+
export function authorizeOperatorChatTool(toolId) {
|
|
114
|
+
const id = toolId.trim();
|
|
115
|
+
if (!id) {
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
code: "tool-unknown",
|
|
119
|
+
toolId: id,
|
|
120
|
+
message: "empty tool id is not allowed in General Operator Chat",
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
if (isOperatorChatToolDenied(id)) {
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
code: "tool-denied",
|
|
127
|
+
toolId: id,
|
|
128
|
+
message: `tool "${id}" is denied in General Operator Chat (no bash/read/grep/edit/write/apply_patch/full-tools/shell/coding-chat)`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (!isOperatorChatToolAllowed(id)) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
code: "tool-unknown",
|
|
135
|
+
toolId: id,
|
|
136
|
+
message: `tool "${id}" is not registered for General Operator Chat (new operator actions default to deny)`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return { ok: true, toolId: id };
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Filter a requested tool list against the Chat whitelist.
|
|
143
|
+
* Returns allowed + denied (for diagnostics / red-team evidence).
|
|
144
|
+
*/
|
|
145
|
+
export function filterOperatorChatTools(requested) {
|
|
146
|
+
const allowed = [];
|
|
147
|
+
const denied = [];
|
|
148
|
+
for (const toolId of requested) {
|
|
149
|
+
const decision = authorizeOperatorChatTool(toolId);
|
|
150
|
+
if (decision.ok)
|
|
151
|
+
allowed.push(decision.toolId);
|
|
152
|
+
else
|
|
153
|
+
denied.push(toolId);
|
|
154
|
+
}
|
|
155
|
+
return { allowed, denied };
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Assert that a requested tool set contains no prohibited coding or removed
|
|
159
|
+
* SDK explore tool. Used by red-team / boot checks (V12 / M0-A). bash is
|
|
160
|
+
* explicitly forbidden: accepting it would recreate the redirect write escape.
|
|
161
|
+
*/
|
|
162
|
+
export function assertNoWriteToolInList(requested) {
|
|
163
|
+
const writeVariants = [
|
|
164
|
+
"edit",
|
|
165
|
+
"write",
|
|
166
|
+
"apply_patch",
|
|
167
|
+
"apply-patch",
|
|
168
|
+
"full-tools",
|
|
169
|
+
"full_tools",
|
|
170
|
+
"coding-chat",
|
|
171
|
+
"coding_chat",
|
|
172
|
+
"shell",
|
|
173
|
+
"bash",
|
|
174
|
+
"read",
|
|
175
|
+
"grep",
|
|
176
|
+
];
|
|
177
|
+
const lower = new Set(requested.map((t) => t.trim().toLowerCase()));
|
|
178
|
+
for (const v of writeVariants) {
|
|
179
|
+
if (lower.has(v)) {
|
|
180
|
+
throw new Error(`file-writing tool "${v}" must not appear in Operator Chat tool list (V12 closed coding surface)`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Back-compat alias kept for callers that still reference the old name. It
|
|
186
|
+
* also rejects removed SDK explore tools, including bash.
|
|
187
|
+
* @deprecated use {@link assertNoWriteToolInList}.
|
|
188
|
+
*/
|
|
189
|
+
export function assertNoBashInToolList(requested) {
|
|
190
|
+
assertNoWriteToolInList(requested);
|
|
191
|
+
}
|
|
192
|
+
/** Canonical sorted tool list to hand to setActiveToolsByName. */
|
|
193
|
+
export function canonicalOperatorChatToolList() {
|
|
194
|
+
return [...OPERATOR_CHAT_ALLOWED_TOOLS].sort();
|
|
195
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
function record(value) {
|
|
2
|
+
return value && typeof value === "object" ? value : undefined;
|
|
3
|
+
}
|
|
4
|
+
function numberAt(source, keys) {
|
|
5
|
+
for (const key of keys)
|
|
6
|
+
if (typeof source[key] === "number" && Number.isFinite(source[key]))
|
|
7
|
+
return Math.max(0, source[key]);
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
export function extractUsageSample(value) {
|
|
11
|
+
const event = record(value);
|
|
12
|
+
if (!event)
|
|
13
|
+
return undefined;
|
|
14
|
+
const message = record(event.message);
|
|
15
|
+
const source = record(event.usage) ?? record(message?.usage) ?? record(event.tokenUsage);
|
|
16
|
+
if (!source)
|
|
17
|
+
return undefined;
|
|
18
|
+
const inputTokens = numberAt(source, ["input_tokens", "inputTokens", "prompt_tokens", "promptTokens"]);
|
|
19
|
+
const outputTokens = numberAt(source, ["output_tokens", "outputTokens", "completion_tokens", "completionTokens"]);
|
|
20
|
+
const totalTokens = numberAt(source, ["total_tokens", "totalTokens"]) ?? (inputTokens !== undefined || outputTokens !== undefined ? (inputTokens ?? 0) + (outputTokens ?? 0) : undefined);
|
|
21
|
+
if (inputTokens === undefined && outputTokens === undefined && totalTokens === undefined)
|
|
22
|
+
return undefined;
|
|
23
|
+
const responseKey = [event.responseId, message?.id, source.responseId].find((item) => typeof item === "string");
|
|
24
|
+
return { inputTokens, outputTokens, totalTokens, ...(responseKey ? { responseKey } : {}) };
|
|
25
|
+
}
|
|
26
|
+
export function aggregateUsage(samples) {
|
|
27
|
+
const keyed = new Map();
|
|
28
|
+
const anonymous = [];
|
|
29
|
+
for (const sample of samples)
|
|
30
|
+
sample.responseKey ? keyed.set(sample.responseKey, sample) : anonymous.push(sample);
|
|
31
|
+
const selected = [...keyed.values(), ...anonymous];
|
|
32
|
+
return selected.reduce((sum, sample) => ({
|
|
33
|
+
inputTokens: sum.inputTokens + (sample.inputTokens ?? 0), outputTokens: sum.outputTokens + (sample.outputTokens ?? 0), totalTokens: sum.totalTokens + (sample.totalTokens ?? ((sample.inputTokens ?? 0) + (sample.outputTokens ?? 0))),
|
|
34
|
+
cost: sample.cost === undefined ? sum.cost : (sum.cost ?? 0) + sample.cost,
|
|
35
|
+
estimated: Boolean(sum.estimated || sample.estimated),
|
|
36
|
+
}), { inputTokens: 0, outputTokens: 0, totalTokens: 0 });
|
|
37
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export const ACTIVE_CHAT_SESSION_STORAGE_KEY = "loop-console.operator-chat.active-session.v1";
|
|
2
|
+
export const DEFAULT_LANDING_STORAGE_KEY = "loop-console.operator-chat.default-landing.v1";
|
|
3
|
+
function configuredLanding(value) {
|
|
4
|
+
const normalized = value?.trim().toLowerCase();
|
|
5
|
+
if (normalized === "chat")
|
|
6
|
+
return "chat";
|
|
7
|
+
if (normalized === "tasks")
|
|
8
|
+
return "tasks";
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
function configuredWorkspace(value) {
|
|
12
|
+
const normalized = value?.trim().toLowerCase();
|
|
13
|
+
if (normalized === "chat" || normalized === "tasks" || normalized === "recovery")
|
|
14
|
+
return normalized;
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
function workspaceFromLocation(location) {
|
|
18
|
+
if (!location)
|
|
19
|
+
return undefined;
|
|
20
|
+
const query = new URLSearchParams(location.search);
|
|
21
|
+
const queryValue = configuredLanding(query.get("landing") ?? query.get("workspace"));
|
|
22
|
+
if (queryValue)
|
|
23
|
+
return queryValue;
|
|
24
|
+
const hash = location.hash.startsWith("#") ? location.hash.slice(1) : location.hash;
|
|
25
|
+
return configuredLanding(new URLSearchParams(hash).get("workspace"));
|
|
26
|
+
}
|
|
27
|
+
export function resolveDefaultLanding(input) {
|
|
28
|
+
return workspaceFromLocation(input.location) ??
|
|
29
|
+
configuredLanding(input.storage?.getItem(DEFAULT_LANDING_STORAGE_KEY)) ??
|
|
30
|
+
configuredLanding(input.env) ??
|
|
31
|
+
"tasks";
|
|
32
|
+
}
|
|
33
|
+
export function buildWorkspaceHref(workspace, params = {}) {
|
|
34
|
+
const query = new URLSearchParams({ workspace });
|
|
35
|
+
for (const [key, value] of Object.entries(params)) {
|
|
36
|
+
if (value?.trim())
|
|
37
|
+
query.set(key, value.trim());
|
|
38
|
+
}
|
|
39
|
+
return `/?${query.toString()}`;
|
|
40
|
+
}
|
|
41
|
+
export function parseWorkspaceHref(location) {
|
|
42
|
+
const query = new URLSearchParams(location.search);
|
|
43
|
+
const hash = location.hash.startsWith("#") ? location.hash.slice(1) : location.hash;
|
|
44
|
+
const hashQuery = new URLSearchParams(hash);
|
|
45
|
+
const workspace = configuredWorkspace(query.get("workspace") ?? query.get("landing") ?? hashQuery.get("workspace"));
|
|
46
|
+
if (!workspace)
|
|
47
|
+
return null;
|
|
48
|
+
const read = (key) => query.get(key)?.trim() || undefined;
|
|
49
|
+
return {
|
|
50
|
+
workspace,
|
|
51
|
+
...(read("taskId") ? { taskId: read("taskId") } : {}),
|
|
52
|
+
...(read("dagRunId") ? { dagRunId: read("dagRunId") } : {}),
|
|
53
|
+
...(read("fromNodeId") ? { fromNodeId: read("fromNodeId") } : {}),
|
|
54
|
+
...(read("featureId") ? { featureId: read("featureId") } : {}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { readJsonIfExists, sha256Utf8, writeSecureJson, } from "./app-data.js";
|
|
4
|
+
import { confirmationPayloadHash, issueHumanGateToken, verifyHumanGateToken, } from "./human-gate-token.js";
|
|
4
5
|
export const ALL_CONFIRMATION_CHALLENGES = [
|
|
5
6
|
"source",
|
|
6
7
|
"writer-scope",
|
|
@@ -88,8 +89,15 @@ function detectBindingDrift(rec, current) {
|
|
|
88
89
|
}
|
|
89
90
|
export class DagConfirmationStore {
|
|
90
91
|
appData;
|
|
91
|
-
|
|
92
|
+
humanGateSecret;
|
|
93
|
+
constructor(appData,
|
|
94
|
+
/**
|
|
95
|
+
* M0-B: per-process HMAC secret for human-gate tokens
|
|
96
|
+
* (BootCapabilityToken.confirmationToken). Required for prepare/confirm.
|
|
97
|
+
*/
|
|
98
|
+
humanGateSecret) {
|
|
92
99
|
this.appData = appData;
|
|
100
|
+
this.humanGateSecret = humanGateSecret;
|
|
93
101
|
}
|
|
94
102
|
async get(confirmationId) {
|
|
95
103
|
const rec = await readJsonIfExists(confirmationPath(this.appData, confirmationId));
|
|
@@ -114,15 +122,28 @@ export class DagConfirmationStore {
|
|
|
114
122
|
const now = input.now ?? new Date();
|
|
115
123
|
const ttl = input.ttlMs ?? DEFAULT_CONFIRMATION_TTL_MS;
|
|
116
124
|
const confirmationId = newConfirmationId();
|
|
125
|
+
const dagSha256 = hashDagBytes(input.dagBytes);
|
|
126
|
+
const expiresAt = new Date(now.getTime() + ttl).toISOString();
|
|
127
|
+
// M0-B: issue a server-signed human-gate token bound to the confirmation's
|
|
128
|
+
// payloadHash. The browser must present it at confirm; dispatch verifies
|
|
129
|
+
// the signature so a model cannot self-confirm.
|
|
130
|
+
const payloadHash = confirmationPayloadHash({
|
|
131
|
+
dagSha256,
|
|
132
|
+
sourceBindingSha256: input.sourceBindingSha256,
|
|
133
|
+
taskContractBindingCanonical: JSON.stringify(input.taskContractBinding),
|
|
134
|
+
controllerFingerprint: input.controllerFingerprint,
|
|
135
|
+
validationResultSha256: input.validationResultSha256,
|
|
136
|
+
});
|
|
137
|
+
const humanGateToken = issueHumanGateToken({ confirmationId, payloadHash, expiresAt }, this.humanGateSecret, now);
|
|
117
138
|
const rec = {
|
|
118
139
|
schemaVersion: 1,
|
|
119
140
|
confirmationId,
|
|
120
141
|
state: "prepared",
|
|
121
142
|
preparedAt: now.toISOString(),
|
|
122
|
-
expiresAt
|
|
143
|
+
expiresAt,
|
|
123
144
|
operatorSessionId: input.operatorSessionId,
|
|
124
145
|
dagHandle: input.dagHandle,
|
|
125
|
-
dagSha256
|
|
146
|
+
dagSha256,
|
|
126
147
|
dagBytesPath: input.dagBytesPath,
|
|
127
148
|
sourceBindingSha256: input.sourceBindingSha256,
|
|
128
149
|
taskContractBinding: input.taskContractBinding,
|
|
@@ -130,6 +151,7 @@ export class DagConfirmationStore {
|
|
|
130
151
|
validationResultSha256: input.validationResultSha256,
|
|
131
152
|
confirmedWriterScopes: input.confirmedWriterScopes ?? [],
|
|
132
153
|
confirmedChallenges: [],
|
|
154
|
+
humanGateToken,
|
|
133
155
|
reviewPacket: input.reviewPacket,
|
|
134
156
|
};
|
|
135
157
|
await writeSecureJson(confirmationPath(this.appData, confirmationId), rec);
|
|
@@ -195,14 +217,25 @@ export class DagConfirmationStore {
|
|
|
195
217
|
confirmation: stale,
|
|
196
218
|
};
|
|
197
219
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
220
|
+
// M0-B / roadmap G03: verify the human-gate token's HMAC signature. The
|
|
221
|
+
// old fixed-string challenge set (`source`/`writer-scope`/...) was a fake
|
|
222
|
+
// gate — a model could fill those strings directly. Now the browser must
|
|
223
|
+
// present the server-signed token issued at prepare; the signature binds
|
|
224
|
+
// (confirmationId + payloadHash + expiresAt) and is verified with the
|
|
225
|
+
// per-process boot secret. A model-side call has no way to forge it.
|
|
226
|
+
const payloadHash = confirmationPayloadHash({
|
|
227
|
+
dagSha256: current.dagSha256,
|
|
228
|
+
sourceBindingSha256: current.sourceBindingSha256,
|
|
229
|
+
taskContractBindingCanonical: JSON.stringify(current.taskContractBinding),
|
|
230
|
+
controllerFingerprint: current.controllerFingerprint,
|
|
231
|
+
validationResultSha256: current.validationResultSha256,
|
|
232
|
+
});
|
|
233
|
+
const tokenCheck = verifyHumanGateToken(input.humanGateToken, { confirmationId: input.confirmationId, payloadHash }, this.humanGateSecret, input.now);
|
|
234
|
+
if (!tokenCheck.ok) {
|
|
202
235
|
return {
|
|
203
236
|
ok: false,
|
|
204
237
|
code: "HUMAN_CONFIRMATION_REQUIRED",
|
|
205
|
-
message: `
|
|
238
|
+
message: `human-gate token verification failed: ${tokenCheck.code} — ${tokenCheck.message}`,
|
|
206
239
|
confirmation: rec,
|
|
207
240
|
};
|
|
208
241
|
}
|
|
@@ -212,6 +245,7 @@ export class DagConfirmationStore {
|
|
|
212
245
|
state: "confirmed",
|
|
213
246
|
confirmedAt: now.toISOString(),
|
|
214
247
|
confirmedChallenges: [...ALL_CONFIRMATION_CHALLENGES],
|
|
248
|
+
humanGateToken: input.humanGateToken ?? rec.humanGateToken,
|
|
215
249
|
};
|
|
216
250
|
await writeSecureJson(confirmationPath(this.appData, rec.confirmationId), confirmed);
|
|
217
251
|
return { ok: true, confirmation: confirmed };
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator Chat — Human Gate confirmation token (roadmap M0-B / G03).
|
|
3
|
+
*
|
|
4
|
+
* Closes the "fake human gate" gap (roadmap §3.2 risk 1): the old
|
|
5
|
+
* confirmDagConfirmation only checked that a FIXED set of challenge strings
|
|
6
|
+
* (`source`/`writer-scope`/`verification`/`human-gates`) was present — a model
|
|
7
|
+
* could fill those in directly and self-confirm. This module issues a
|
|
8
|
+
* server-signed token bound to (confirmationId, payloadHash, expiresAt) and
|
|
9
|
+
* verifies its HMAC signature at dispatch time, so confirmation can only
|
|
10
|
+
* originate from the browser mutation gate that received the signed token.
|
|
11
|
+
*
|
|
12
|
+
* The HMAC key is the per-process boot confirmation secret
|
|
13
|
+
* (BootCapabilityToken.confirmationToken). This binds the human-gate token to
|
|
14
|
+
* the same Console boot session that the browser established via the HttpOnly
|
|
15
|
+
* cookie — a model-side tool call cannot fabricate it.
|
|
16
|
+
*/
|
|
17
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
18
|
+
/** Canonical message string covered by the signature. */
|
|
19
|
+
export function canonicalTokenMessage(p) {
|
|
20
|
+
return [
|
|
21
|
+
`v1`,
|
|
22
|
+
`confirmationId=${p.confirmationId}`,
|
|
23
|
+
`payloadHash=${p.payloadHash}`,
|
|
24
|
+
`expiresAt=${p.expiresAt}`,
|
|
25
|
+
].join("\n");
|
|
26
|
+
}
|
|
27
|
+
function b64url(buf) {
|
|
28
|
+
return buf.toString("base64url");
|
|
29
|
+
}
|
|
30
|
+
/** Issue a signed token. `secret` = BootCapabilityToken.confirmationToken. */
|
|
31
|
+
export function issueHumanGateToken(payload, secret, now = new Date()) {
|
|
32
|
+
const sig = createHmac("sha256", secret).update(canonicalTokenMessage(payload)).digest();
|
|
33
|
+
return {
|
|
34
|
+
...payload,
|
|
35
|
+
signature: b64url(sig),
|
|
36
|
+
issuedAt: now.toISOString(),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Verify a presented token. Checks signature, expiry, and that the token is
|
|
41
|
+
* bound to the given confirmationId + current payloadHash. Constant-time
|
|
42
|
+
* signature compare. Returns a typed failure (no thrown secrets).
|
|
43
|
+
*/
|
|
44
|
+
export function verifyHumanGateToken(presented, expected, secret, now = new Date()) {
|
|
45
|
+
if (!presented || typeof presented !== "object") {
|
|
46
|
+
return { ok: false, code: "MISSING_TOKEN", message: "no human-gate token presented" };
|
|
47
|
+
}
|
|
48
|
+
if (presented.confirmationId !== expected.confirmationId) {
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
code: "CONFIRMATION_MISMATCH",
|
|
52
|
+
message: "token is bound to a different confirmationId",
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (presented.payloadHash !== expected.payloadHash) {
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
code: "PAYLOAD_MISMATCH",
|
|
59
|
+
message: "token payloadHash does not match the current confirmation bindings",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const expectedSig = createHmac("sha256", secret)
|
|
63
|
+
.update(canonicalTokenMessage(presented))
|
|
64
|
+
.digest();
|
|
65
|
+
let presentedSig;
|
|
66
|
+
try {
|
|
67
|
+
presentedSig = Buffer.from(presented.signature, "base64url");
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return { ok: false, code: "INVALID_SIGNATURE", message: "signature is not valid base64url" };
|
|
71
|
+
}
|
|
72
|
+
if (presentedSig.length !== expectedSig.length ||
|
|
73
|
+
!timingSafeEqual(presentedSig, expectedSig)) {
|
|
74
|
+
return { ok: false, code: "INVALID_SIGNATURE", message: "signature verification failed" };
|
|
75
|
+
}
|
|
76
|
+
if (Date.parse(presented.expiresAt) <= now.getTime()) {
|
|
77
|
+
return { ok: false, code: "EXPIRED", message: "token has expired" };
|
|
78
|
+
}
|
|
79
|
+
return { ok: true, token: presented };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Compute the payloadHash for a confirmation receipt: SHA-256 over the binding
|
|
83
|
+
* snapshot (dagSha256 + sourceBindingSha256 + taskContractBinding canonical +
|
|
84
|
+
* controllerFingerprint + validationResultSha256). Changing any binding
|
|
85
|
+
* invalidates the token (defense against drift-then-confirm).
|
|
86
|
+
*/
|
|
87
|
+
import { createHash } from "node:crypto";
|
|
88
|
+
import { sha256Utf8 } from "./app-data.js";
|
|
89
|
+
export function confirmationPayloadHash(b) {
|
|
90
|
+
const canonical = [
|
|
91
|
+
`dag=${b.dagSha256}`,
|
|
92
|
+
`source=${b.sourceBindingSha256}`,
|
|
93
|
+
`contract=${sha256Utf8(b.taskContractBindingCanonical)}`,
|
|
94
|
+
`controller=${b.controllerFingerprint}`,
|
|
95
|
+
`validation=${b.validationResultSha256}`,
|
|
96
|
+
].join("\n");
|
|
97
|
+
return createHash("sha256").update(canonical).digest("hex");
|
|
98
|
+
}
|
|
99
|
+
/** Stable contract-apply binding; revision and observed hash prevent drift-then-confirm. */
|
|
100
|
+
export function contractApplyPayloadHash(b) {
|
|
101
|
+
const canonical = [
|
|
102
|
+
"v1-contract-apply",
|
|
103
|
+
`taskId=${b.taskId}`,
|
|
104
|
+
`revision=${b.revision}`,
|
|
105
|
+
`canonicalHash=${b.canonicalHash}`,
|
|
106
|
+
`draftSha256=${b.draftSha256}`,
|
|
107
|
+
`expectedObservedHash=${b.expectedObservedHash}`,
|
|
108
|
+
].join("\n");
|
|
109
|
+
return createHash("sha256").update(canonical).digest("hex");
|
|
110
|
+
}
|
|
111
|
+
/** Stable mutation-gate binding; params are canonical-JSON with sorted keys. */
|
|
112
|
+
export function mutationGatePayloadHash(b) {
|
|
113
|
+
const paramsCanonical = stableJson(b.actionParams);
|
|
114
|
+
const canonical = [
|
|
115
|
+
"v1-mutation-gate",
|
|
116
|
+
`action=${b.action}`,
|
|
117
|
+
`operatorSessionId=${b.operatorSessionId}`,
|
|
118
|
+
`actionParams=${paramsCanonical}`,
|
|
119
|
+
].join("\n");
|
|
120
|
+
return createHash("sha256").update(canonical).digest("hex");
|
|
121
|
+
}
|
|
122
|
+
function stableJson(value) {
|
|
123
|
+
if (value === null || typeof value !== "object")
|
|
124
|
+
return JSON.stringify(value);
|
|
125
|
+
if (Array.isArray(value))
|
|
126
|
+
return `[${value.map((entry) => stableJson(entry)).join(",")}]`;
|
|
127
|
+
const obj = value;
|
|
128
|
+
const keys = Object.keys(obj).sort();
|
|
129
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableJson(obj[key])}`).join(",")}}`;
|
|
130
|
+
}
|