create-harness-vibe-coding 0.8.17 → 0.8.19
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 +22 -0
- package/README-CN.md +2 -0
- package/README.md +3 -0
- package/package.json +5 -2
- package/src/generator.js +613 -97
- package/src/index.js +556 -309
- package/src/prompts.js +18 -0
- package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js +17 -0
- package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js.map +1 -0
- package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js +7 -0
- package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js.map +1 -0
- package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js +12 -0
- package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js.map +1 -0
- package/src/ui/dist/assets/TaskList-BN4r8s1i.js +50 -0
- package/src/ui/dist/assets/TaskList-BN4r8s1i.js.map +1 -0
- package/src/ui/dist/assets/TerminalDrawer-6GBZ9nXN.css +32 -0
- package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js +74 -0
- package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js.map +1 -0
- package/src/ui/dist/assets/WorkflowRoute-BnuhLJ6X.css +1 -0
- package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js +39 -0
- package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js.map +1 -0
- package/src/ui/dist/assets/index-BOVYWntB.js +117 -0
- package/src/ui/dist/assets/index-BOVYWntB.js.map +1 -0
- package/src/ui/dist/assets/index-DaUObq0H.css +1 -0
- package/src/ui/dist/assets/maximize-2-LNrr_rKr.js +7 -0
- package/src/ui/dist/assets/maximize-2-LNrr_rKr.js.map +1 -0
- package/src/ui/dist/assets/plus-QuTJyoT3.js +7 -0
- package/src/ui/dist/assets/plus-QuTJyoT3.js.map +1 -0
- package/src/ui/dist/assets/proxy-D0vUbds5.js +2 -0
- package/src/ui/dist/assets/proxy-D0vUbds5.js.map +1 -0
- package/src/ui/dist/assets/refresh-cw-JNmExijd.js +7 -0
- package/src/ui/dist/assets/refresh-cw-JNmExijd.js.map +1 -0
- package/src/ui/dist/assets/terminal-BBQvnOAK.js +7 -0
- package/src/ui/dist/assets/terminal-BBQvnOAK.js.map +1 -0
- package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js +7 -0
- package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js.map +1 -0
- package/src/ui/dist/assets/x-DoIOGAJz.js +7 -0
- package/src/ui/dist/assets/x-DoIOGAJz.js.map +1 -0
- package/src/ui/dist/index.html +18 -0
- package/src/ui/index.html +17 -0
- package/src/ui/package.json +33 -0
- package/src/ui/pnpm-lock.yaml +1582 -0
- package/src/ui/pnpm-workspace.yaml +2 -0
- package/src/ui/src/App.tsx +86 -0
- package/src/ui/src/api.ts +93 -0
- package/src/ui/src/components/AgentsRoute.tsx +503 -0
- package/src/ui/src/components/Footer.tsx +69 -0
- package/src/ui/src/components/Header.tsx +175 -0
- package/src/ui/src/components/LoadingView.tsx +22 -0
- package/src/ui/src/components/RolesRoute.tsx +169 -0
- package/src/ui/src/components/SettingsRoute.tsx +166 -0
- package/src/ui/src/components/TaskList.tsx +388 -0
- package/src/ui/src/components/TerminalDrawer.tsx +611 -0
- package/src/ui/src/components/WorkflowRoute.tsx +670 -0
- package/src/ui/src/hooks/useReducedMotion.ts +17 -0
- package/src/ui/src/hooks/useServerConnection.ts +114 -0
- package/src/ui/src/index.css +281 -0
- package/src/ui/src/main.tsx +11 -0
- package/src/ui/src/types.ts +108 -0
- package/src/ui/tsconfig.json +21 -0
- package/src/ui/vite.config.ts +19 -0
- package/src/wf-ui-server/__tests__/a2a-store.test.mjs +79 -0
- package/src/wf-ui-server/__tests__/peer-capsule.test.mjs +268 -0
- package/src/wf-ui-server/__tests__/pty-adapter.test.mjs +70 -0
- package/src/wf-ui-server/__tests__/runtime-config.test.mjs +43 -0
- package/src/wf-ui-server/__tests__/runtime-detector.test.mjs +90 -0
- package/src/wf-ui-server/__tests__/security.test.mjs +59 -0
- package/src/wf-ui-server/__tests__/server.integration.test.mjs +150 -0
- package/src/wf-ui-server/__tests__/session-registry.test.mjs +238 -0
- package/src/wf-ui-server/__tests__/settings.test.mjs +135 -0
- package/src/wf-ui-server/__tests__/task-parser.test.mjs +200 -0
- package/src/wf-ui-server/__tests__/terminal-store.test.mjs +138 -0
- package/src/wf-ui-server/__tests__/token.test.mjs +48 -0
- package/src/wf-ui-server/__tests__/ws-events.integration.test.mjs +419 -0
- package/src/wf-ui-server/__tests__/ws-terminal.integration.test.mjs +383 -0
- package/src/wf-ui-server/a2a-store.mjs +296 -0
- package/src/wf-ui-server/peer-capsule.mjs +213 -0
- package/src/wf-ui-server/pty-adapter.mjs +155 -0
- package/src/wf-ui-server/runtime-config.mjs +153 -0
- package/src/wf-ui-server/runtime-detector.mjs +374 -0
- package/src/wf-ui-server/security.mjs +67 -0
- package/src/wf-ui-server/server.mjs +849 -0
- package/src/wf-ui-server/session-registry.mjs +204 -0
- package/src/wf-ui-server/settings.mjs +100 -0
- package/src/wf-ui-server/task-parser.mjs +133 -0
- package/src/wf-ui-server/terminal-store.mjs +225 -0
- package/src/wf-ui-server/token.mjs +41 -0
- package/src/wf-ui-server/ws-events.mjs +354 -0
- package/src/wf-ui-server/ws-terminal.mjs +473 -0
- package/templates/common/.claude/commands/wf-command-create.md +58 -0
- package/templates/common/.claude/commands/wf-help.md +5 -0
- package/templates/common/.claude/commands/wf-task-archive.md +26 -0
- package/templates/common/.claude/commands/wf-task-list.md +26 -0
- package/templates/common/.claude/commands/wf-task-record.md +24 -0
- package/templates/common/.claude/commands/wf-ui.md +26 -0
- package/templates/common/.claude/commands/wf-update.md +54 -9
- package/templates/common/.claude/rules/ecc/common.md +1 -1
- package/templates/common/.claude/skills/wf-agents-docs/SKILL.md +15 -30
- package/templates/common/.claude/skills/wf-auto/SKILL.md +3 -3
- package/templates/common/.claude/skills/wf-auto-spark/SKILL.md +2 -2
- package/templates/common/.claude/skills/wf-command-create/SKILL.md +37 -0
- package/templates/common/.claude/skills/wf-max/SKILL.md +1 -1
- package/templates/common/.claude/skills/wf-review/SKILL.md +29 -2
- package/templates/common/.claude/skills/wf-task-archive/SKILL.md +28 -0
- package/templates/common/.claude/skills/wf-task-list/SKILL.md +28 -0
- package/templates/common/.claude/skills/wf-task-record/SKILL.md +28 -0
- package/templates/common/.claude/skills/wf-ui/SKILL.md +78 -0
- package/templates/common/.claude/skills/wf-update/SKILL.md +55 -58
- package/templates/common/.harness-version +105 -47
- package/templates/common/.opencode/commands/wf-command-create.md +61 -0
- package/templates/common/.opencode/commands/wf-help.md +5 -0
- package/templates/common/.opencode/commands/wf-task-archive.md +29 -0
- package/templates/common/.opencode/commands/wf-task-list.md +29 -0
- package/templates/common/.opencode/commands/wf-task-record.md +27 -0
- package/templates/common/.opencode/commands/wf-ui.md +26 -0
- package/templates/common/.opencode/commands/wf-update.md +54 -9
- package/templates/common/CLAUDE.md +8 -6
- package/templates/common/Harness/MEMORY.md +11 -0
- package/templates/common/Harness/README.md +21 -38
- package/templates/common/Harness/a2a/role-graph.json +79 -0
- package/templates/common/Harness/a2a/runtime-registry.json +5 -0
- package/templates/common/Harness/a2a/skills/terminal-control.json +15 -0
- package/templates/common/Harness/ownership.manifest.json +142 -2
- package/templates/common/Harness/scripts/README.md +134 -0
- package/templates/common/Harness/scripts/a2a-terminal.mjs +191 -0
- package/templates/common/Harness/scripts/context-budget.mjs +1 -1
- package/templates/common/Harness/scripts/sync-host-global.mjs +278 -0
- package/templates/common/Harness/scripts/task-state.mjs +949 -26
- package/templates/common/Harness/scripts/validate-harness.mjs +637 -62
- package/templates/common/Harness/scripts/wf-remove.mjs +42 -5
- package/templates/common/Harness/scripts/wf-update-check.mjs +37 -9
- package/templates/common/Harness/scripts/wf-update-runner.mjs +325 -0
- package/templates/common/Harness/settings.json +35 -0
- package/templates/common/Harness/specs/guides/SETUP.md +8 -0
- package/templates/common/Harness/specs/protocols/MEMORY_PROTOCOL.md +15 -0
- package/templates/common/Harness/specs/protocols/TASK_ARCHIVE.md +16 -5
- package/templates/common/Harness/specs/runtime/command-surface.json +229 -0
- package/templates/common/Harness/specs/runtime/subagents.md +6 -0
- package/templates/common/Harness/specs/workflows/WF-AUTO-SPARK.md +8 -8
- package/templates/common/Harness/specs/workflows/WF-AUTO.md +12 -12
- package/templates/common/Harness/specs/workflows/WF-MAX.md +5 -0
- package/templates/common/Harness/specs/workflows/WF-STATE.md +66 -0
- package/templates/common/Harness/tasks/_template/NAMING.md +16 -16
- package/templates/common/Harness/tasks/_template/PLAN.md +17 -60
- package/templates/common/Harness/tasks/_template/PROBLEM.md +18 -0
- package/templates/common/Harness/tasks/_template/PROGRESS.md +9 -14
- package/templates/common/Harness/tasks/_template/REFERENCES.md +26 -0
- package/templates/common/Harness/tasks/_template/STATE.json +6 -0
- package/templates/common/Harness/tasks/_template/ARTIFACTS.md +0 -3
- package/templates/common/Harness/tasks/_template/NOTES.md +0 -3
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { RUNTIME_IDS } from './runtime-detector.mjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Set of allowed agent runtimes for PTY sessions.
|
|
6
|
+
* No fake agents in product code.
|
|
7
|
+
*/
|
|
8
|
+
export const ALLOWED_RUNTIMES = RUNTIME_IDS;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generate a short peer ID from a runtime name.
|
|
12
|
+
* @param {string} runtime
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
function generatePeerId(runtime) {
|
|
16
|
+
const suffix = crypto.randomBytes(4).toString('hex');
|
|
17
|
+
return `${runtime}-${suffix}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Generate a session ID (UUID v4).
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
export function generateSessionId() {
|
|
25
|
+
return crypto.randomUUID();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* SessionRegistry manages PTY sessions for peer agents.
|
|
30
|
+
*
|
|
31
|
+
* Each session tracks: agent runtime, terminal dimensions, lifecycle status,
|
|
32
|
+
* process info, and optional WebSocket attach state.
|
|
33
|
+
*/
|
|
34
|
+
export class SessionRegistry {
|
|
35
|
+
constructor() {
|
|
36
|
+
/** @type {Map<string, object>} */
|
|
37
|
+
this._sessions = new Map();
|
|
38
|
+
/** @type {Map<string, Promise<void>>} */
|
|
39
|
+
this._locks = new Map();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async withLock(sessionId, fn) {
|
|
43
|
+
const key = String(sessionId || '__global__');
|
|
44
|
+
const previous = this._locks.get(key) || Promise.resolve();
|
|
45
|
+
let release;
|
|
46
|
+
const current = new Promise((resolve) => { release = resolve; });
|
|
47
|
+
const chain = previous.then(() => current, () => current);
|
|
48
|
+
this._locks.set(key, chain);
|
|
49
|
+
|
|
50
|
+
await previous.catch(() => {});
|
|
51
|
+
try {
|
|
52
|
+
return await fn();
|
|
53
|
+
} finally {
|
|
54
|
+
release();
|
|
55
|
+
if (this._locks.get(key) === chain) {
|
|
56
|
+
this._locks.delete(key);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Create a new session.
|
|
63
|
+
* @param {object} opts
|
|
64
|
+
* @param {string} opts.taskId
|
|
65
|
+
* @param {string} opts.runtime - Must be in ALLOWED_RUNTIMES
|
|
66
|
+
* @param {number} [opts.cols=120]
|
|
67
|
+
* @param {number} [opts.rows=32]
|
|
68
|
+
* @param {string} [opts.projectRoot]
|
|
69
|
+
* @returns {object} The created session object
|
|
70
|
+
* @throws {Error} If runtime is not in ALLOWED_RUNTIMES
|
|
71
|
+
*/
|
|
72
|
+
create({
|
|
73
|
+
taskId = null,
|
|
74
|
+
runtime,
|
|
75
|
+
role = 'terminal-agent',
|
|
76
|
+
objective = '',
|
|
77
|
+
cols = 120,
|
|
78
|
+
rows = 32,
|
|
79
|
+
projectRoot = '',
|
|
80
|
+
subagentMode = 'wf-subagents',
|
|
81
|
+
workflowMode = null,
|
|
82
|
+
model = '',
|
|
83
|
+
provider = '',
|
|
84
|
+
ceoPrompt = '',
|
|
85
|
+
}) {
|
|
86
|
+
if (!ALLOWED_RUNTIMES.has(runtime)) {
|
|
87
|
+
throw new Error(`Invalid runtime '${runtime}'. Must be one of: ${[...ALLOWED_RUNTIMES].join(', ')}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const now = new Date().toISOString();
|
|
91
|
+
const sessionId = generateSessionId();
|
|
92
|
+
const peerId = generatePeerId(runtime);
|
|
93
|
+
|
|
94
|
+
const session = {
|
|
95
|
+
sessionId,
|
|
96
|
+
taskId: taskId || null,
|
|
97
|
+
peerId,
|
|
98
|
+
runtime,
|
|
99
|
+
role,
|
|
100
|
+
objective,
|
|
101
|
+
subagentMode,
|
|
102
|
+
workflowMode,
|
|
103
|
+
model,
|
|
104
|
+
provider,
|
|
105
|
+
ceoPrompt,
|
|
106
|
+
status: 'starting',
|
|
107
|
+
cols,
|
|
108
|
+
rows,
|
|
109
|
+
attachMode: false,
|
|
110
|
+
startedAt: now,
|
|
111
|
+
updatedAt: now,
|
|
112
|
+
lastActivityAt: now,
|
|
113
|
+
pid: null,
|
|
114
|
+
exitCode: null,
|
|
115
|
+
blockedReason: null,
|
|
116
|
+
blockedHint: null,
|
|
117
|
+
ptyProvider: null,
|
|
118
|
+
agentSessionId: null,
|
|
119
|
+
resumeCommand: null,
|
|
120
|
+
terminalSeq: 0,
|
|
121
|
+
transcriptRing: '',
|
|
122
|
+
wsClientCount: 0,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
this._sessions.set(sessionId, session);
|
|
126
|
+
return session;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Get a session by ID.
|
|
131
|
+
* @param {string} sessionId
|
|
132
|
+
* @returns {object|undefined}
|
|
133
|
+
*/
|
|
134
|
+
get(sessionId) {
|
|
135
|
+
return this._sessions.get(sessionId);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Get all sessions as an array.
|
|
140
|
+
* @returns {object[]}
|
|
141
|
+
*/
|
|
142
|
+
getAll() {
|
|
143
|
+
return [...this._sessions.values()];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Get all sessions for a given task ID.
|
|
148
|
+
* @param {string} taskId
|
|
149
|
+
* @returns {object[]}
|
|
150
|
+
*/
|
|
151
|
+
getAllForTask(taskId) {
|
|
152
|
+
return [...this._sessions.values()].filter(s => s.taskId === taskId);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Update session fields. Merges the patch into the session and sets updatedAt.
|
|
157
|
+
* @param {string} sessionId
|
|
158
|
+
* @param {object} patch - Fields to merge (status, cols, rows, pid, exitCode, attachMode, wsClientCount, transcriptRing)
|
|
159
|
+
*/
|
|
160
|
+
update(sessionId, patch) {
|
|
161
|
+
const session = this._sessions.get(sessionId);
|
|
162
|
+
if (!session) return;
|
|
163
|
+
|
|
164
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
165
|
+
if (value !== undefined && key in session) {
|
|
166
|
+
session[key] = value;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
session.updatedAt = new Date().toISOString();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Remove a session from the registry.
|
|
174
|
+
* @param {string} sessionId
|
|
175
|
+
*/
|
|
176
|
+
remove(sessionId) {
|
|
177
|
+
this._sessions.delete(sessionId);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Stop and remove a web-owned session. Persisted recovery metadata lives on disk.
|
|
182
|
+
* @param {string} sessionId
|
|
183
|
+
* @returns {object|null} The removed session when found
|
|
184
|
+
*/
|
|
185
|
+
stop(sessionId) {
|
|
186
|
+
const session = this._sessions.get(sessionId);
|
|
187
|
+
if (!session) return null;
|
|
188
|
+
this._sessions.delete(sessionId);
|
|
189
|
+
return {
|
|
190
|
+
...session,
|
|
191
|
+
status: 'saved',
|
|
192
|
+
stoppedAt: new Date().toISOString(),
|
|
193
|
+
updatedAt: new Date().toISOString(),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Number of active sessions.
|
|
199
|
+
* @returns {number}
|
|
200
|
+
*/
|
|
201
|
+
count() {
|
|
202
|
+
return this._sessions.size;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { RUNTIME_DEFINITIONS } from './runtime-detector.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Default settings for the wf-ui server.
|
|
7
|
+
* These are the baseline values overridden by project settings.
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_SETTINGS = {
|
|
10
|
+
server: { host: '127.0.0.1', port: 0 },
|
|
11
|
+
terminal: { enabled: false, attachMode: false, defaultRuntime: 'codex' },
|
|
12
|
+
peers: { allowlist: RUNTIME_DEFINITIONS.map((runtime) => runtime.id) },
|
|
13
|
+
ui: { theme: 'auto', language: 'en', reducedMotion: false },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Deep-merge two plain objects. Only known keys from the defaults
|
|
18
|
+
* structure are accepted; unknown keys in the override are silently ignored.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} defaults - Default settings object
|
|
21
|
+
* @param {object} override - Project override settings object
|
|
22
|
+
* @returns {object} Merged result
|
|
23
|
+
*/
|
|
24
|
+
function deepMerge(defaults, override) {
|
|
25
|
+
const result = { ...defaults };
|
|
26
|
+
|
|
27
|
+
for (const key of Object.keys(override)) {
|
|
28
|
+
if (!Object.hasOwn(defaults, key)) {
|
|
29
|
+
// Silently ignore unknown keys
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const defVal = defaults[key];
|
|
34
|
+
const ovrVal = override[key];
|
|
35
|
+
|
|
36
|
+
if (defVal !== null && typeof defVal === 'object' && !Array.isArray(defVal) &&
|
|
37
|
+
ovrVal !== null && typeof ovrVal === 'object' && !Array.isArray(ovrVal)) {
|
|
38
|
+
result[key] = deepMerge(defVal, ovrVal);
|
|
39
|
+
} else {
|
|
40
|
+
result[key] = ovrVal;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Read project settings from Harness/settings.json relative to projectRoot.
|
|
49
|
+
* Merges project settings over defaults. Non-existent settings.json produces
|
|
50
|
+
* defaults. Unknown keys in project settings are silently ignored.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} projectRoot - Absolute path to the project root directory
|
|
53
|
+
* @returns {object} Merged settings object
|
|
54
|
+
*/
|
|
55
|
+
export function loadSettings(projectRoot) {
|
|
56
|
+
if (!projectRoot) {
|
|
57
|
+
return JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const settingsPath = path.join(projectRoot, 'Harness', 'settings.json');
|
|
61
|
+
|
|
62
|
+
if (!fs.existsSync(settingsPath)) {
|
|
63
|
+
return JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let raw;
|
|
67
|
+
try {
|
|
68
|
+
raw = fs.readFileSync(settingsPath, 'utf8');
|
|
69
|
+
} catch {
|
|
70
|
+
return JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let projectSettings;
|
|
74
|
+
try {
|
|
75
|
+
projectSettings = JSON.parse(raw);
|
|
76
|
+
} catch {
|
|
77
|
+
return JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return deepMerge(DEFAULT_SETTINGS, projectSettings);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve settings with explicit precedence.
|
|
85
|
+
* The precedence object may specify a projectRoot; project settings
|
|
86
|
+
* override defaults.
|
|
87
|
+
*
|
|
88
|
+
* @param {object} precedence - Precedence configuration
|
|
89
|
+
* @param {string} [precedence.projectRoot] - Project root for settings lookup
|
|
90
|
+
* @returns {object} Merged settings object
|
|
91
|
+
*/
|
|
92
|
+
export function resolveSettings(precedence = {}) {
|
|
93
|
+
const projectRoot = precedence.projectRoot;
|
|
94
|
+
|
|
95
|
+
if (projectRoot) {
|
|
96
|
+
return loadSettings(projectRoot);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
|
|
100
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
function readState(taskDir) {
|
|
5
|
+
const statePath = path.join(taskDir, 'STATE.json');
|
|
6
|
+
if (!fs.existsSync(statePath)) return null;
|
|
7
|
+
try { return JSON.parse(fs.readFileSync(statePath, 'utf8')); }
|
|
8
|
+
catch (err) { throw new Error(`STATE.json at ${statePath} contains malformed JSON: ${err.message}`); }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function normalizeAcceptance(acceptance) {
|
|
12
|
+
if (!Array.isArray(acceptance)) return [];
|
|
13
|
+
return acceptance.map((ac) => {
|
|
14
|
+
if (typeof ac === 'string') return { id: '', text: ac, status: 'pending' };
|
|
15
|
+
if (ac && typeof ac === 'object') return {
|
|
16
|
+
id: ac.id || '',
|
|
17
|
+
text: ac.text || JSON.stringify(ac),
|
|
18
|
+
status: ac.status || 'pending',
|
|
19
|
+
};
|
|
20
|
+
return { id: '', text: String(ac), status: 'pending' };
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function parseTaskCapsule(taskDir) {
|
|
25
|
+
const state = readState(taskDir);
|
|
26
|
+
if (!state) return null;
|
|
27
|
+
if (state.schemaVersion === undefined || state.schemaVersion === null) {
|
|
28
|
+
throw new Error(`STATE.json at ${taskDir} is missing required field: schemaVersion`);
|
|
29
|
+
}
|
|
30
|
+
const links = state.links || { dependsOn: [], blocks: [], related: [] };
|
|
31
|
+
return {
|
|
32
|
+
taskId: state.taskId || path.basename(taskDir),
|
|
33
|
+
status: state.status || 'unknown',
|
|
34
|
+
phase: state.phase || null,
|
|
35
|
+
gate: state.gate || null,
|
|
36
|
+
tier: state.tier || null,
|
|
37
|
+
mode: state.mode || null,
|
|
38
|
+
updatedAt: state.updatedAt || null,
|
|
39
|
+
activeQuestion: state.activeQuestion || null,
|
|
40
|
+
nextAction: state.nextAction || null,
|
|
41
|
+
defaultRuntime: state.defaultRuntime || state.defaultAgentRuntime || state.agentRuntime || state.cliAgent || null,
|
|
42
|
+
acceptance: normalizeAcceptance(state.acceptance),
|
|
43
|
+
dependsOn: Array.isArray(links.dependsOn) ? links.dependsOn : [],
|
|
44
|
+
blocks: Array.isArray(links.blocks) ? links.blocks : [],
|
|
45
|
+
hasPlan: fs.existsSync(path.join(taskDir, 'PLAN.md')),
|
|
46
|
+
hasProgress: fs.existsSync(path.join(taskDir, 'PROGRESS.md')),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseTaskList(tasksRoot) {
|
|
51
|
+
let entries;
|
|
52
|
+
try { entries = fs.readdirSync(tasksRoot, { withFileTypes: true }); }
|
|
53
|
+
catch { return []; }
|
|
54
|
+
const capsules = [];
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
if (!entry.isDirectory() || entry.name.startsWith('_')) continue;
|
|
57
|
+
try {
|
|
58
|
+
const c = parseTaskCapsule(path.join(tasksRoot, entry.name));
|
|
59
|
+
if (c) capsules.push(c);
|
|
60
|
+
} catch { /* skip unparseable */ }
|
|
61
|
+
}
|
|
62
|
+
capsules.sort((a, b) => {
|
|
63
|
+
const da = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
|
64
|
+
const db = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
|
65
|
+
return db - da;
|
|
66
|
+
});
|
|
67
|
+
return capsules;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Parse archived tasks from _archive/YYYY/task-name/ */
|
|
71
|
+
export function parseArchivedTasks(tasksRoot) {
|
|
72
|
+
const archiveRoot = path.join(tasksRoot, '_archive');
|
|
73
|
+
if (!fs.existsSync(archiveRoot)) return [];
|
|
74
|
+
const capsules = [];
|
|
75
|
+
let years;
|
|
76
|
+
try { years = fs.readdirSync(archiveRoot, { withFileTypes: true }); }
|
|
77
|
+
catch { return []; }
|
|
78
|
+
|
|
79
|
+
function addArchivedTask(taskDir, archivedYear, archivedPath) {
|
|
80
|
+
try {
|
|
81
|
+
const c = parseTaskCapsule(taskDir);
|
|
82
|
+
if (c) {
|
|
83
|
+
c.archivedYear = archivedYear;
|
|
84
|
+
c.archivedPath = archivedPath;
|
|
85
|
+
capsules.push(c);
|
|
86
|
+
}
|
|
87
|
+
} catch { /* skip */ }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const y of years) {
|
|
91
|
+
if (!y.isDirectory()) continue;
|
|
92
|
+
const yearDir = path.join(archiveRoot, y.name);
|
|
93
|
+
let yearEntries;
|
|
94
|
+
try { yearEntries = fs.readdirSync(yearDir, { withFileTypes: true }); }
|
|
95
|
+
catch { continue; }
|
|
96
|
+
const monthDirs = yearEntries.filter(e => e.isDirectory() && /^\d{2}$/.test(e.name));
|
|
97
|
+
for (const month of monthDirs) {
|
|
98
|
+
const monthDir = path.join(yearDir, month.name);
|
|
99
|
+
let dayEntries;
|
|
100
|
+
try { dayEntries = fs.readdirSync(monthDir, { withFileTypes: true }); }
|
|
101
|
+
catch { continue; }
|
|
102
|
+
for (const day of dayEntries.filter(e => e.isDirectory() && /^\d{2}$/.test(e.name))) {
|
|
103
|
+
const dayDir = path.join(monthDir, day.name);
|
|
104
|
+
let taskEntries;
|
|
105
|
+
try { taskEntries = fs.readdirSync(dayDir, { withFileTypes: true }); }
|
|
106
|
+
catch { continue; }
|
|
107
|
+
for (const t of taskEntries.filter(e => e.isDirectory())) {
|
|
108
|
+
addArchivedTask(path.join(dayDir, t.name), y.name, `${y.name}/${month.name}/${day.name}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const t of yearEntries.filter(e => e.isDirectory() && !/^\d{2}$/.test(e.name))) {
|
|
114
|
+
addArchivedTask(path.join(yearDir, t.name), y.name, y.name);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
capsules.sort((a, b) => {
|
|
118
|
+
const da = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
|
119
|
+
const db = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
|
120
|
+
return db - da;
|
|
121
|
+
});
|
|
122
|
+
return capsules;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Read a task file content (STATE.json, PLAN.md, PROGRESS.md) */
|
|
126
|
+
export function readTaskFile(taskDir, filename) {
|
|
127
|
+
const safe = path.basename(filename);
|
|
128
|
+
const filePath = path.join(taskDir, safe);
|
|
129
|
+
if (!fs.existsSync(filePath)) return null;
|
|
130
|
+
try {
|
|
131
|
+
return { filename: safe, content: fs.readFileSync(filePath, 'utf8') };
|
|
132
|
+
} catch { return null; }
|
|
133
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { validateTaskId } from './security.mjs';
|
|
4
|
+
|
|
5
|
+
const MAX_READ_LIMIT = 2000;
|
|
6
|
+
|
|
7
|
+
function validateSessionId(sessionId) {
|
|
8
|
+
return validateTaskId(sessionId);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isBoundTaskId(taskId) {
|
|
12
|
+
return Boolean(taskId && validateTaskId(taskId));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function sessionsRoot(projectRoot, taskId) {
|
|
16
|
+
if (isBoundTaskId(taskId)) {
|
|
17
|
+
return path.join(projectRoot, 'Harness', 'tasks', taskId, 'sessions');
|
|
18
|
+
}
|
|
19
|
+
return path.join(projectRoot, 'Harness', 'a2a', 'sessions');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sessionDir(projectRoot, taskId, sessionId) {
|
|
23
|
+
if (taskId && !validateTaskId(taskId)) throw new Error(`Invalid taskId: ${taskId}`);
|
|
24
|
+
if (!validateSessionId(sessionId)) throw new Error(`Invalid sessionId: ${sessionId}`);
|
|
25
|
+
return path.join(sessionsRoot(projectRoot, taskId), sessionId);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readJson(filePath, fallback) {
|
|
29
|
+
try {
|
|
30
|
+
if (!fs.existsSync(filePath)) return fallback;
|
|
31
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
32
|
+
} catch {
|
|
33
|
+
return fallback;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function writeJson(filePath, data) {
|
|
38
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
39
|
+
fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readJsonl(filePath) {
|
|
43
|
+
try {
|
|
44
|
+
if (!fs.existsSync(filePath)) return [];
|
|
45
|
+
return fs.readFileSync(filePath, 'utf8')
|
|
46
|
+
.split(/\r?\n/)
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.map((line) => JSON.parse(line));
|
|
49
|
+
} catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function appendJsonl(filePath, data) {
|
|
55
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
56
|
+
fs.appendFileSync(filePath, `${JSON.stringify(data)}\n`, 'utf8');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function persistSession(projectRoot, session, patch = {}) {
|
|
60
|
+
const dir = sessionDir(projectRoot, session.taskId, session.sessionId);
|
|
61
|
+
const current = readJson(path.join(dir, 'STATE.json'), {});
|
|
62
|
+
const state = {
|
|
63
|
+
...current,
|
|
64
|
+
...session,
|
|
65
|
+
...patch,
|
|
66
|
+
updatedAt: new Date().toISOString(),
|
|
67
|
+
};
|
|
68
|
+
writeJson(path.join(dir, 'STATE.json'), state);
|
|
69
|
+
return state;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function appendSessionEvent(projectRoot, session, event) {
|
|
73
|
+
const dir = sessionDir(projectRoot, session.taskId, session.sessionId);
|
|
74
|
+
appendJsonl(path.join(dir, 'events.jsonl'), {
|
|
75
|
+
ts: new Date().toISOString(),
|
|
76
|
+
taskId: session.taskId || null,
|
|
77
|
+
sessionId: session.sessionId,
|
|
78
|
+
runtime: session.runtime,
|
|
79
|
+
...event,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function appendTerminalData(projectRoot, session, data, stream = 'stdout') {
|
|
84
|
+
const dir = sessionDir(projectRoot, session.taskId, session.sessionId);
|
|
85
|
+
const statePath = path.join(dir, 'STATE.json');
|
|
86
|
+
const state = readJson(statePath, session);
|
|
87
|
+
const nextSeq = Number(state.terminalSeq || 0) + 1;
|
|
88
|
+
const entry = {
|
|
89
|
+
seq: nextSeq,
|
|
90
|
+
ts: new Date().toISOString(),
|
|
91
|
+
taskId: session.taskId || null,
|
|
92
|
+
sessionId: session.sessionId,
|
|
93
|
+
runtime: session.runtime,
|
|
94
|
+
stream,
|
|
95
|
+
data: String(data || ''),
|
|
96
|
+
};
|
|
97
|
+
appendJsonl(path.join(dir, 'terminal.jsonl'), entry);
|
|
98
|
+
writeJson(statePath, {
|
|
99
|
+
...state,
|
|
100
|
+
terminalSeq: nextSeq,
|
|
101
|
+
lastActivityAt: entry.ts,
|
|
102
|
+
updatedAt: entry.ts,
|
|
103
|
+
});
|
|
104
|
+
return entry;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function allSessionDirs(projectRoot) {
|
|
108
|
+
const tasksRoot = path.join(projectRoot, 'Harness', 'tasks');
|
|
109
|
+
let taskEntries = [];
|
|
110
|
+
try {
|
|
111
|
+
taskEntries = fs.readdirSync(tasksRoot, { withFileTypes: true });
|
|
112
|
+
} catch {
|
|
113
|
+
taskEntries = [];
|
|
114
|
+
}
|
|
115
|
+
const dirs = [];
|
|
116
|
+
for (const taskEntry of taskEntries) {
|
|
117
|
+
if (!taskEntry.isDirectory() || taskEntry.name.startsWith('_')) continue;
|
|
118
|
+
const root = path.join(tasksRoot, taskEntry.name, 'sessions');
|
|
119
|
+
let sessionEntries = [];
|
|
120
|
+
try {
|
|
121
|
+
sessionEntries = fs.readdirSync(root, { withFileTypes: true });
|
|
122
|
+
} catch {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
for (const sessionEntry of sessionEntries) {
|
|
126
|
+
if (sessionEntry.isDirectory()) {
|
|
127
|
+
dirs.push({
|
|
128
|
+
taskId: taskEntry.name,
|
|
129
|
+
sessionId: sessionEntry.name,
|
|
130
|
+
dir: path.join(root, sessionEntry.name),
|
|
131
|
+
relRoot: `Harness/tasks/${taskEntry.name}/sessions/${sessionEntry.name}`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const a2aSessionsRoot = path.join(projectRoot, 'Harness', 'a2a', 'sessions');
|
|
138
|
+
try {
|
|
139
|
+
for (const sessionEntry of fs.readdirSync(a2aSessionsRoot, { withFileTypes: true })) {
|
|
140
|
+
if (sessionEntry.isDirectory()) {
|
|
141
|
+
dirs.push({
|
|
142
|
+
taskId: null,
|
|
143
|
+
sessionId: sessionEntry.name,
|
|
144
|
+
dir: path.join(a2aSessionsRoot, sessionEntry.name),
|
|
145
|
+
relRoot: `Harness/a2a/sessions/${sessionEntry.name}`,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// No unbound A2A sessions yet.
|
|
151
|
+
}
|
|
152
|
+
return dirs;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function listTerminalSessions(projectRoot) {
|
|
156
|
+
return allSessionDirs(projectRoot)
|
|
157
|
+
.map(({ dir }) => readJson(path.join(dir, 'STATE.json'), null))
|
|
158
|
+
.filter(Boolean)
|
|
159
|
+
.sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function findTerminalSession(projectRoot, sessionId) {
|
|
163
|
+
if (!validateSessionId(sessionId)) return null;
|
|
164
|
+
return allSessionDirs(projectRoot).find((entry) => entry.sessionId === sessionId) || null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function readTerminalRange(projectRoot, { sessionId, fromSeq, toSeq, tail } = {}) {
|
|
168
|
+
const found = findTerminalSession(projectRoot, sessionId);
|
|
169
|
+
if (!found) return { sessionId, entries: [] };
|
|
170
|
+
let entries = readJsonl(path.join(found.dir, 'terminal.jsonl'));
|
|
171
|
+
if (Number.isFinite(fromSeq)) entries = entries.filter((entry) => entry.seq >= fromSeq);
|
|
172
|
+
if (Number.isFinite(toSeq)) entries = entries.filter((entry) => entry.seq <= toSeq);
|
|
173
|
+
if (Number.isFinite(tail) && tail > 0) entries = entries.slice(-Math.min(tail, MAX_READ_LIMIT));
|
|
174
|
+
else entries = entries.slice(0, MAX_READ_LIMIT);
|
|
175
|
+
return { sessionId, taskId: found.taskId, entries };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function globToRegex(pattern) {
|
|
179
|
+
const safe = String(pattern || '**/*')
|
|
180
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
181
|
+
.replace(/\*\*/g, '::DOUBLE_STAR::')
|
|
182
|
+
.replace(/\*/g, '[^/]*')
|
|
183
|
+
.replace(/::DOUBLE_STAR::/g, '.*');
|
|
184
|
+
return new RegExp(`^${safe}$`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function globTerminalEvents(projectRoot, { pattern = '**/*', since, limit = 200 } = {}) {
|
|
188
|
+
const regex = globToRegex(pattern.replace(/\\/g, '/'));
|
|
189
|
+
const max = Math.min(Math.max(Number(limit) || 200, 1), MAX_READ_LIMIT);
|
|
190
|
+
const sinceMs = since ? new Date(since).getTime() : null;
|
|
191
|
+
const events = [];
|
|
192
|
+
for (const found of allSessionDirs(projectRoot)) {
|
|
193
|
+
for (const filename of ['events.jsonl', 'terminal.jsonl', 'input-requests.jsonl']) {
|
|
194
|
+
const rel = `${found.relRoot}/${filename}`;
|
|
195
|
+
if (!regex.test(rel)) continue;
|
|
196
|
+
const rows = readJsonl(path.join(found.dir, filename));
|
|
197
|
+
for (const row of rows) {
|
|
198
|
+
const tsMs = row.ts ? new Date(row.ts).getTime() : 0;
|
|
199
|
+
if (sinceMs && tsMs < sinceMs) continue;
|
|
200
|
+
events.push({ source: rel, ...row });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return events
|
|
205
|
+
.sort((a, b) => String(a.ts || '').localeCompare(String(b.ts || '')))
|
|
206
|
+
.slice(-max);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function recordInputRequest(projectRoot, sessionId, text) {
|
|
210
|
+
const found = findTerminalSession(projectRoot, sessionId);
|
|
211
|
+
if (!found) throw new Error(`Session not found: ${sessionId}`);
|
|
212
|
+
const state = readJson(path.join(found.dir, 'STATE.json'), null);
|
|
213
|
+
if (!state?.attachMode) {
|
|
214
|
+
throw new Error('Input rejected: session is in watch mode. Enable attach mode first.');
|
|
215
|
+
}
|
|
216
|
+
const event = {
|
|
217
|
+
ts: new Date().toISOString(),
|
|
218
|
+
type: 'terminal.input.requested',
|
|
219
|
+
taskId: found.taskId || null,
|
|
220
|
+
sessionId,
|
|
221
|
+
data: String(text || ''),
|
|
222
|
+
};
|
|
223
|
+
appendJsonl(path.join(found.dir, 'input-requests.jsonl'), event);
|
|
224
|
+
return event;
|
|
225
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate a cryptographically random token as a hex string.
|
|
5
|
+
* @returns {string} Hex string of at least 32 characters (128 bits).
|
|
6
|
+
*/
|
|
7
|
+
export function generateToken() {
|
|
8
|
+
return crypto.randomBytes(32).toString('hex');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Timing-safe comparison of two tokens using `crypto.timingSafeEqual`.
|
|
13
|
+
*
|
|
14
|
+
* Both arguments are coerced to strings. If lengths differ, or either value
|
|
15
|
+
* is null/undefined, returns false without leaking the length mismatch
|
|
16
|
+
* source in the return path (the comparison still runs via a synthetic buffer).
|
|
17
|
+
*
|
|
18
|
+
* @param {string} token - The token to validate.
|
|
19
|
+
* @param {string} expectedToken - The expected token to compare against.
|
|
20
|
+
* @returns {boolean} True if both tokens are equal; false otherwise.
|
|
21
|
+
*/
|
|
22
|
+
export function validateToken(token, expectedToken) {
|
|
23
|
+
if (typeof token !== 'string' || typeof expectedToken !== 'string') {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Use timingSafeEqual even on different-length inputs to avoid leaking
|
|
28
|
+
// length information through the return path. We compare the provided
|
|
29
|
+
// token against itself when lengths don't match, so the crypto call
|
|
30
|
+
// always takes the same form.
|
|
31
|
+
const bufToken = Buffer.from(token, 'utf8');
|
|
32
|
+
const bufExpected = Buffer.from(expectedToken, 'utf8');
|
|
33
|
+
|
|
34
|
+
if (bufToken.length !== bufExpected.length) {
|
|
35
|
+
// Dummy comparison to maintain timing invariants (no data leak)
|
|
36
|
+
crypto.timingSafeEqual(bufToken, bufToken);
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return crypto.timingSafeEqual(bufToken, bufExpected);
|
|
41
|
+
}
|