claude-code-session-manager 0.37.0 → 0.37.2
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/bin/cli.cjs +12 -1
- package/dist/assets/{TiptapBody-Cg8YZhVZ.js → TiptapBody-BtrSXTRp.js} +1 -1
- package/dist/assets/index-CD_LuJZF.css +32 -0
- package/dist/assets/{index-_iBXLuvt.js → index-uVGdpAGF.js} +498 -490
- package/dist/index.html +2 -2
- package/package.json +2 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +134 -36
- package/plugins/session-manager-dev/skills/develop/standards.md +24 -0
- package/plugins/session-manager-dev/skills/requesting-code-review/SKILL.md +20 -17
- package/scripts/lib/activeSessions.cjs +117 -0
- package/scripts/lib/watchdogHelpers.cjs +829 -0
- package/src/main/__tests__/classifyTranscriptLine.test.cjs +90 -0
- package/src/main/__tests__/docEdit.test.cjs +164 -2
- package/src/main/__tests__/prdCreate.test.cjs +265 -25
- package/src/main/__tests__/scheduler-admin-routes.test.cjs +150 -0
- package/src/main/__tests__/scheduler-boot-orphans.test.cjs +172 -0
- package/src/main/browserAgentServer.cjs +11 -10
- package/src/main/chatRunner.cjs +21 -2
- package/src/main/docEdit.cjs +125 -5
- package/src/main/health.cjs +15 -0
- package/src/main/index.cjs +12 -6
- package/src/main/ipcSchemas.cjs +14 -0
- package/src/main/lib/__tests__/localAdminHttp.test.cjs +120 -0
- package/src/main/lib/classifyTranscriptLine.cjs +89 -0
- package/src/main/lib/localAdminHttp.cjs +157 -0
- package/src/main/lib/personaImportHealth.cjs +161 -0
- package/src/main/lib/prdCreate.cjs +107 -17
- package/src/main/lib/rcaFeedbackHook.cjs +2 -2
- package/src/main/lib/singleInstanceGuard.cjs +20 -0
- package/src/main/scheduler.cjs +198 -54
- package/src/main/templates/PRD_AUTHORING.md +4 -4
- package/src/main/transcripts.cjs +1 -85
- package/src/preload/api.d.ts +12 -1
- package/src/preload/index.cjs +6 -0
- package/dist/assets/index-CTTjT08J.css +0 -32
- package/plugins/session-manager-dev/skills/security-review/SKILL.md +0 -105
- package/src/main/__tests__/adminServer.test.cjs +0 -380
- package/src/main/adminServer.cjs +0 -242
|
@@ -1,380 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* adminServer.test.cjs — unit tests for the loopback-only scheduler admin API.
|
|
3
|
-
*
|
|
4
|
-
* Run: timeout 120 node --test src/main/__tests__/adminServer.test.cjs
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
'use strict';
|
|
8
|
-
|
|
9
|
-
const { test } = require('node:test');
|
|
10
|
-
const assert = require('node:assert/strict');
|
|
11
|
-
const http = require('node:http');
|
|
12
|
-
const fs = require('node:fs');
|
|
13
|
-
const fsp = require('node:fs/promises');
|
|
14
|
-
const os = require('node:os');
|
|
15
|
-
const path = require('node:path');
|
|
16
|
-
const { createAdminServer } = require('../adminServer.cjs');
|
|
17
|
-
const config = require('../config.cjs');
|
|
18
|
-
|
|
19
|
-
function request(port, { method = 'GET', path: reqPath, token, body }) {
|
|
20
|
-
return new Promise((resolve, reject) => {
|
|
21
|
-
const headers = {};
|
|
22
|
-
if (token !== undefined) headers.Authorization = `Bearer ${token}`;
|
|
23
|
-
let payload;
|
|
24
|
-
if (body !== undefined) {
|
|
25
|
-
payload = JSON.stringify(body);
|
|
26
|
-
headers['Content-Type'] = 'application/json';
|
|
27
|
-
headers['Content-Length'] = Buffer.byteLength(payload);
|
|
28
|
-
}
|
|
29
|
-
const req = http.request({ hostname: '127.0.0.1', port, method, path: reqPath, headers }, (res) => {
|
|
30
|
-
const chunks = [];
|
|
31
|
-
res.on('data', (c) => chunks.push(c));
|
|
32
|
-
res.on('end', () => {
|
|
33
|
-
const text = Buffer.concat(chunks).toString('utf8');
|
|
34
|
-
let json = null;
|
|
35
|
-
try { json = JSON.parse(text); } catch { /* leave null for non-JSON bodies */ }
|
|
36
|
-
resolve({ status: res.statusCode, json, text });
|
|
37
|
-
});
|
|
38
|
-
});
|
|
39
|
-
req.on('error', reject);
|
|
40
|
-
if (payload) req.write(payload);
|
|
41
|
-
req.end();
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function makeFakeRemote({ jobs = [] } = {}) {
|
|
46
|
-
return {
|
|
47
|
-
async listJobs() {
|
|
48
|
-
return jobs.map((j) => ({ slug: j.slug, title: j.title, status: j.status, cwd: j.cwd }));
|
|
49
|
-
},
|
|
50
|
-
async resetJob(slug) {
|
|
51
|
-
const job = jobs.find((j) => j.slug === slug);
|
|
52
|
-
if (!job) return { ok: false, error: 'not found' };
|
|
53
|
-
job.status = 'pending';
|
|
54
|
-
job.runId = null;
|
|
55
|
-
job.startedAt = null;
|
|
56
|
-
job.finishedAt = null;
|
|
57
|
-
job.exitCode = null;
|
|
58
|
-
job.error = null;
|
|
59
|
-
delete job.runtime;
|
|
60
|
-
delete job.verifierVerdict;
|
|
61
|
-
return { ok: true, slug, status: 'pending' };
|
|
62
|
-
},
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
test('request without bearer token returns 401', async () => {
|
|
67
|
-
const remote = makeFakeRemote();
|
|
68
|
-
const admin = createAdminServer(remote);
|
|
69
|
-
const { port } = await admin.start();
|
|
70
|
-
try {
|
|
71
|
-
const res = await request(port, { path: '/admin/scheduler/jobs' });
|
|
72
|
-
assert.strictEqual(res.status, 401);
|
|
73
|
-
assert.strictEqual(res.json.ok, false);
|
|
74
|
-
} finally {
|
|
75
|
-
await admin.stop();
|
|
76
|
-
}
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
test('request with wrong bearer token returns 401', async () => {
|
|
80
|
-
const remote = makeFakeRemote();
|
|
81
|
-
const admin = createAdminServer(remote);
|
|
82
|
-
const { port } = await admin.start();
|
|
83
|
-
try {
|
|
84
|
-
const res = await request(port, { path: '/admin/scheduler/jobs', token: 'not-the-token' });
|
|
85
|
-
assert.strictEqual(res.status, 401);
|
|
86
|
-
} finally {
|
|
87
|
-
await admin.stop();
|
|
88
|
-
}
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test('GET /admin/scheduler/jobs with correct token returns 200 + array', async () => {
|
|
92
|
-
const remote = makeFakeRemote({
|
|
93
|
-
jobs: [{ slug: '10-foo', title: 'Foo', status: 'completed', cwd: '/tmp/foo' }],
|
|
94
|
-
});
|
|
95
|
-
const admin = createAdminServer(remote);
|
|
96
|
-
const { port, token } = await admin.start();
|
|
97
|
-
try {
|
|
98
|
-
const res = await request(port, { path: '/admin/scheduler/jobs', token });
|
|
99
|
-
assert.strictEqual(res.status, 200);
|
|
100
|
-
assert.ok(Array.isArray(res.json));
|
|
101
|
-
assert.strictEqual(res.json.length, 1);
|
|
102
|
-
assert.strictEqual(res.json[0].slug, '10-foo');
|
|
103
|
-
} finally {
|
|
104
|
-
await admin.stop();
|
|
105
|
-
}
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
test('POST /admin/scheduler/reset-job with unknown slug returns ok:false', async () => {
|
|
109
|
-
const remote = makeFakeRemote({ jobs: [] });
|
|
110
|
-
const admin = createAdminServer(remote);
|
|
111
|
-
const { port, token } = await admin.start();
|
|
112
|
-
try {
|
|
113
|
-
const res = await request(port, {
|
|
114
|
-
method: 'POST', path: '/admin/scheduler/reset-job', token, body: { slug: 'does-not-exist' },
|
|
115
|
-
});
|
|
116
|
-
assert.strictEqual(res.status, 200);
|
|
117
|
-
assert.strictEqual(res.json.ok, false);
|
|
118
|
-
} finally {
|
|
119
|
-
await admin.stop();
|
|
120
|
-
}
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
test('POST /admin/scheduler/reset-job with known slug flips job to pending and clears run fields', async () => {
|
|
124
|
-
const job = {
|
|
125
|
-
slug: '10-foo',
|
|
126
|
-
title: 'Foo',
|
|
127
|
-
status: 'completed',
|
|
128
|
-
cwd: '/tmp/foo',
|
|
129
|
-
runId: 'run-1',
|
|
130
|
-
startedAt: 100,
|
|
131
|
-
finishedAt: 200,
|
|
132
|
-
exitCode: 0,
|
|
133
|
-
error: null,
|
|
134
|
-
runtime: { pid: 1234 },
|
|
135
|
-
verifierVerdict: 'PASS',
|
|
136
|
-
};
|
|
137
|
-
const remote = makeFakeRemote({ jobs: [job] });
|
|
138
|
-
const admin = createAdminServer(remote);
|
|
139
|
-
const { port, token } = await admin.start();
|
|
140
|
-
try {
|
|
141
|
-
const res = await request(port, {
|
|
142
|
-
method: 'POST', path: '/admin/scheduler/reset-job', token, body: { slug: '10-foo' },
|
|
143
|
-
});
|
|
144
|
-
assert.strictEqual(res.status, 200);
|
|
145
|
-
assert.deepStrictEqual(res.json, { ok: true, slug: '10-foo', status: 'pending' });
|
|
146
|
-
assert.strictEqual(job.status, 'pending');
|
|
147
|
-
assert.strictEqual(job.runId, null);
|
|
148
|
-
assert.strictEqual(job.startedAt, null);
|
|
149
|
-
assert.strictEqual(job.finishedAt, null);
|
|
150
|
-
assert.strictEqual(job.exitCode, null);
|
|
151
|
-
assert.strictEqual(job.error, null);
|
|
152
|
-
assert.strictEqual('runtime' in job, false);
|
|
153
|
-
assert.strictEqual('verifierVerdict' in job, false);
|
|
154
|
-
} finally {
|
|
155
|
-
await admin.stop();
|
|
156
|
-
}
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
test('unknown path/method returns 404', async () => {
|
|
160
|
-
const remote = makeFakeRemote();
|
|
161
|
-
const admin = createAdminServer(remote);
|
|
162
|
-
const { port, token } = await admin.start();
|
|
163
|
-
try {
|
|
164
|
-
const res = await request(port, { path: '/admin/scheduler/nope', token });
|
|
165
|
-
assert.strictEqual(res.status, 404);
|
|
166
|
-
} finally {
|
|
167
|
-
await admin.stop();
|
|
168
|
-
}
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
test('server binds only to 127.0.0.1', async () => {
|
|
172
|
-
const remote = makeFakeRemote();
|
|
173
|
-
const admin = createAdminServer(remote);
|
|
174
|
-
await admin.start();
|
|
175
|
-
try {
|
|
176
|
-
assert.strictEqual(admin.server.address().address, '127.0.0.1');
|
|
177
|
-
} finally {
|
|
178
|
-
await admin.stop();
|
|
179
|
-
}
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
// ──────────────────────────────────────────── create-prd
|
|
183
|
-
|
|
184
|
-
const prdParser = require('../scheduler/prdParser.cjs');
|
|
185
|
-
|
|
186
|
-
// config.cjs's validateWrite only allows writes under a registered non-home
|
|
187
|
-
// allowedRoot's `.claude/` subtree (mirrors the real ROOT layout, which is
|
|
188
|
-
// $HOME/.claude/session-manager/...) — so the temp prdsDir must live under
|
|
189
|
-
// `<root>/.claude/...`, not directly under the temp root itself.
|
|
190
|
-
async function mkTmpPrdsDir() {
|
|
191
|
-
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-admin-create-prd-'));
|
|
192
|
-
config.addAllowedRoot(root);
|
|
193
|
-
const prdsDir = path.join(root, '.claude', 'prds');
|
|
194
|
-
await fsp.mkdir(prdsDir, { recursive: true });
|
|
195
|
-
return prdsDir;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Mirrors scheduler.cjs's real `remote.allocateParallelGroup` / `remote.writePrd`
|
|
199
|
-
// (same underlying prdParser.allocateParallelGroup + config.writeTextAtomic
|
|
200
|
-
// calls) but pointed at a throwaway temp dir instead of the user's real
|
|
201
|
-
// scheduled-plans/prds — so these tests never touch $HOME/.claude.
|
|
202
|
-
function makeFakeRemoteWithPrdsDir(prdsDir) {
|
|
203
|
-
return {
|
|
204
|
-
async allocateParallelGroup() {
|
|
205
|
-
return prdParser.allocateParallelGroup(prdsDir);
|
|
206
|
-
},
|
|
207
|
-
async readPrd(slug) {
|
|
208
|
-
try {
|
|
209
|
-
const text = await fsp.readFile(path.join(prdsDir, `${slug}.md`), 'utf8');
|
|
210
|
-
return { ok: true, text };
|
|
211
|
-
} catch (e) {
|
|
212
|
-
return { ok: false, error: e?.message };
|
|
213
|
-
}
|
|
214
|
-
},
|
|
215
|
-
async writePrd(slug, body) {
|
|
216
|
-
try {
|
|
217
|
-
const filePath = path.join(prdsDir, `${slug}.md`);
|
|
218
|
-
await config.writeTextAtomic(filePath, body);
|
|
219
|
-
return { ok: true };
|
|
220
|
-
} catch (e) {
|
|
221
|
-
return { ok: false, error: e?.message };
|
|
222
|
-
}
|
|
223
|
-
},
|
|
224
|
-
};
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function validCreateBody(overrides = {}) {
|
|
228
|
-
return {
|
|
229
|
-
title: 'Add widget frobnication',
|
|
230
|
-
cwd: os.homedir(),
|
|
231
|
-
estimateMinutes: 15,
|
|
232
|
-
goal: 'Add frobnication to the widget subsystem.',
|
|
233
|
-
acceptanceCriteria: ['widget frobnicates on click', 'timeout 300 npm run typecheck passes'],
|
|
234
|
-
implementationNotes: 'See src/widget.cjs:10.',
|
|
235
|
-
outOfScope: ['not touching gadgets'],
|
|
236
|
-
...overrides,
|
|
237
|
-
};
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
test('POST /admin/scheduler/create-prd without token returns 401', async () => {
|
|
241
|
-
const remote = makeFakeRemote();
|
|
242
|
-
const admin = createAdminServer(remote);
|
|
243
|
-
const { port } = await admin.start();
|
|
244
|
-
try {
|
|
245
|
-
const res = await request(port, {
|
|
246
|
-
method: 'POST', path: '/admin/scheduler/create-prd', body: validCreateBody(),
|
|
247
|
-
});
|
|
248
|
-
assert.strictEqual(res.status, 401);
|
|
249
|
-
} finally {
|
|
250
|
-
await admin.stop();
|
|
251
|
-
}
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
test('POST /admin/scheduler/create-prd with a valid payload writes a file with frontmatter + standards appended, returns {nn, filename, status}', async () => {
|
|
255
|
-
const prdsDir = await mkTmpPrdsDir();
|
|
256
|
-
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
257
|
-
const admin = createAdminServer(remote);
|
|
258
|
-
const { port, token } = await admin.start();
|
|
259
|
-
try {
|
|
260
|
-
const res = await request(port, {
|
|
261
|
-
method: 'POST', path: '/admin/scheduler/create-prd', token, body: validCreateBody(),
|
|
262
|
-
});
|
|
263
|
-
assert.strictEqual(res.status, 200);
|
|
264
|
-
assert.strictEqual(res.json.status, 'queued');
|
|
265
|
-
assert.strictEqual(typeof res.json.nn, 'number');
|
|
266
|
-
assert.match(res.json.filename, /^\d+-add-widget-frobnication\.md$/);
|
|
267
|
-
|
|
268
|
-
const written = await fsp.readFile(path.join(prdsDir, res.json.filename), 'utf8');
|
|
269
|
-
assert.match(written, /^---\n/);
|
|
270
|
-
assert.match(written, /title: Add widget frobnication/);
|
|
271
|
-
assert.match(written, /cwd: .+/);
|
|
272
|
-
assert.match(written, /estimateMinutes: 15/);
|
|
273
|
-
assert.match(written, /# Goal/);
|
|
274
|
-
assert.match(written, /# Acceptance criteria/);
|
|
275
|
-
assert.match(written, /- \[ \] widget frobnicates on click/);
|
|
276
|
-
assert.match(written, /# Implementation notes/);
|
|
277
|
-
assert.match(written, /# Out of scope/);
|
|
278
|
-
assert.match(written, /## Engineering standards/);
|
|
279
|
-
assert.ok(written.includes('Execution discipline'), 'must inline standards.md verbatim');
|
|
280
|
-
} finally {
|
|
281
|
-
await admin.stop();
|
|
282
|
-
}
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
test('POST /admin/scheduler/create-prd with malformed frontmatter (missing acceptanceCriteria) is rejected and writes no file', async () => {
|
|
286
|
-
const prdsDir = await mkTmpPrdsDir();
|
|
287
|
-
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
288
|
-
const admin = createAdminServer(remote);
|
|
289
|
-
const { port, token } = await admin.start();
|
|
290
|
-
try {
|
|
291
|
-
const body = validCreateBody();
|
|
292
|
-
delete body.acceptanceCriteria;
|
|
293
|
-
const res = await request(port, {
|
|
294
|
-
method: 'POST', path: '/admin/scheduler/create-prd', token, body,
|
|
295
|
-
});
|
|
296
|
-
assert.strictEqual(res.status, 400);
|
|
297
|
-
assert.strictEqual(res.json.ok, false);
|
|
298
|
-
const entries = await fsp.readdir(prdsDir);
|
|
299
|
-
assert.strictEqual(entries.filter((f) => f.endsWith('.md')).length, 0);
|
|
300
|
-
} finally {
|
|
301
|
-
await admin.stop();
|
|
302
|
-
}
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
test('POST /admin/scheduler/create-prd with cwd outside allowed roots is rejected and writes no file', async () => {
|
|
306
|
-
const prdsDir = await mkTmpPrdsDir();
|
|
307
|
-
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
308
|
-
const admin = createAdminServer(remote);
|
|
309
|
-
const { port, token } = await admin.start();
|
|
310
|
-
try {
|
|
311
|
-
const res = await request(port, {
|
|
312
|
-
method: 'POST', path: '/admin/scheduler/create-prd', token,
|
|
313
|
-
body: validCreateBody({ cwd: '/etc/passwd-adjacent-outside-home' }),
|
|
314
|
-
});
|
|
315
|
-
assert.strictEqual(res.status, 400);
|
|
316
|
-
assert.strictEqual(res.json.ok, false);
|
|
317
|
-
const entries = await fsp.readdir(prdsDir);
|
|
318
|
-
assert.strictEqual(entries.filter((f) => f.endsWith('.md')).length, 0);
|
|
319
|
-
} finally {
|
|
320
|
-
await admin.stop();
|
|
321
|
-
}
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
test('POST /admin/scheduler/create-prd rejects a title containing a newline (frontmatter-injection guard) and writes no file', async () => {
|
|
325
|
-
const prdsDir = await mkTmpPrdsDir();
|
|
326
|
-
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
327
|
-
const admin = createAdminServer(remote);
|
|
328
|
-
const { port, token } = await admin.start();
|
|
329
|
-
try {
|
|
330
|
-
const res = await request(port, {
|
|
331
|
-
method: 'POST', path: '/admin/scheduler/create-prd', token,
|
|
332
|
-
body: validCreateBody({ title: 'Legit title\n---\nEvil: injected' }),
|
|
333
|
-
});
|
|
334
|
-
assert.strictEqual(res.status, 400);
|
|
335
|
-
assert.strictEqual(res.json.ok, false);
|
|
336
|
-
const entries = await fsp.readdir(prdsDir);
|
|
337
|
-
assert.strictEqual(entries.filter((f) => f.endsWith('.md')).length, 0);
|
|
338
|
-
} finally {
|
|
339
|
-
await admin.stop();
|
|
340
|
-
}
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
test('POST /admin/scheduler/create-prd with an explicit parallelGroup+slug that already exists on disk returns 409 and does not clobber the file', async () => {
|
|
344
|
-
const prdsDir = await mkTmpPrdsDir();
|
|
345
|
-
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
346
|
-
const admin = createAdminServer(remote);
|
|
347
|
-
const { port, token } = await admin.start();
|
|
348
|
-
try {
|
|
349
|
-
await fsp.writeFile(path.join(prdsDir, '777-my-explicit-slug.md'), 'ORIGINAL CONTENT\n');
|
|
350
|
-
const res = await request(port, {
|
|
351
|
-
method: 'POST', path: '/admin/scheduler/create-prd', token,
|
|
352
|
-
body: validCreateBody({ slug: 'my-explicit-slug', parallelGroup: 777 }),
|
|
353
|
-
});
|
|
354
|
-
assert.strictEqual(res.status, 409);
|
|
355
|
-
assert.strictEqual(res.json.ok, false);
|
|
356
|
-
const stillThere = await fsp.readFile(path.join(prdsDir, '777-my-explicit-slug.md'), 'utf8');
|
|
357
|
-
assert.strictEqual(stillThere, 'ORIGINAL CONTENT\n', 'existing PRD file must not be overwritten');
|
|
358
|
-
} finally {
|
|
359
|
-
await admin.stop();
|
|
360
|
-
}
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
test('POST /admin/scheduler/create-prd honors an explicit slug + parallelGroup instead of deriving/allocating', async () => {
|
|
364
|
-
const prdsDir = await mkTmpPrdsDir();
|
|
365
|
-
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
366
|
-
const admin = createAdminServer(remote);
|
|
367
|
-
const { port, token } = await admin.start();
|
|
368
|
-
try {
|
|
369
|
-
const res = await request(port, {
|
|
370
|
-
method: 'POST', path: '/admin/scheduler/create-prd', token,
|
|
371
|
-
body: validCreateBody({ slug: 'my-explicit-slug', parallelGroup: 777 }),
|
|
372
|
-
});
|
|
373
|
-
assert.strictEqual(res.status, 200);
|
|
374
|
-
assert.strictEqual(res.json.nn, 777);
|
|
375
|
-
assert.strictEqual(res.json.filename, '777-my-explicit-slug.md');
|
|
376
|
-
assert.ok(fs.existsSync(path.join(prdsDir, '777-my-explicit-slug.md')));
|
|
377
|
-
} finally {
|
|
378
|
-
await admin.stop();
|
|
379
|
-
}
|
|
380
|
-
});
|
package/src/main/adminServer.cjs
DELETED
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* adminServer.cjs — loopback-only HTTP admin API for the scheduler.
|
|
3
|
-
*
|
|
4
|
-
* Foundation for PRD 449 (an MCP server wraps this HTTP API). Exposes a
|
|
5
|
-
* narrow, token-authed surface so a same-machine script/tool can reset a
|
|
6
|
-
* stuck scheduler job or list jobs without hand-editing queue.json from
|
|
7
|
-
* outside the running app (which would race the in-process mutate() queue
|
|
8
|
-
* — see scheduler.cjs's serialized-mutation comment).
|
|
9
|
-
*
|
|
10
|
-
* Security posture:
|
|
11
|
-
* - Binds 127.0.0.1 only, OS-assigned ephemeral port. Never reachable
|
|
12
|
-
* off-box.
|
|
13
|
-
* - Bearer token, regenerated every app boot, written to
|
|
14
|
-
* ~/.claude/session-manager/admin-api.json with 0600 perms.
|
|
15
|
-
* - Token compared with crypto.timingSafeEqual to avoid a timing
|
|
16
|
-
* side-channel on the comparison itself.
|
|
17
|
-
* - Three routes: reset-job (flips fields on an already-vetted job),
|
|
18
|
-
* jobs (read-only list), and create-prd (PRD 549 — authors a BRAND-NEW
|
|
19
|
-
* file that the scheduler will later run as `claude -p
|
|
20
|
-
* --dangerously-skip-permissions` in a caller-chosen, home-dir-bounded
|
|
21
|
-
* cwd; a materially larger blast radius than reset-job, justified only
|
|
22
|
-
* because it unblocks external automation with no other queueing path).
|
|
23
|
-
* cwd is validated via config.cjs's validatePath; title/cwd are
|
|
24
|
-
* newline-blocked at the zod boundary (ipcSchemas.cjs) to prevent
|
|
25
|
-
* frontmatter injection into the hand-rolled PRD serializer
|
|
26
|
-
* (prdCreate.cjs). pause/resume/cancel/update remain intentionally NOT
|
|
27
|
-
* exposed here.
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
'use strict';
|
|
31
|
-
|
|
32
|
-
const http = require('node:http');
|
|
33
|
-
const os = require('node:os');
|
|
34
|
-
const path = require('node:path');
|
|
35
|
-
const crypto = require('node:crypto');
|
|
36
|
-
const fsp = require('node:fs/promises');
|
|
37
|
-
const config = require('./config.cjs');
|
|
38
|
-
const { expandHome } = require('./lib/expandHome.cjs');
|
|
39
|
-
const { schemas } = require('./ipcSchemas.cjs');
|
|
40
|
-
const prdCreate = require('./lib/prdCreate.cjs');
|
|
41
|
-
|
|
42
|
-
const TOKEN_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'admin-api.json');
|
|
43
|
-
|
|
44
|
-
function timingSafeEqualStrings(a, b) {
|
|
45
|
-
const bufA = Buffer.from(String(a ?? ''));
|
|
46
|
-
const bufB = Buffer.from(String(b ?? ''));
|
|
47
|
-
if (bufA.length !== bufB.length) {
|
|
48
|
-
// Compare against a same-length buffer anyway so the failure path still
|
|
49
|
-
// takes constant time relative to the (fixed) token length, rather than
|
|
50
|
-
// returning early on a length mismatch.
|
|
51
|
-
crypto.timingSafeEqual(bufA, bufA);
|
|
52
|
-
return false;
|
|
53
|
-
}
|
|
54
|
-
return crypto.timingSafeEqual(bufA, bufB);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function readBody(req, maxBytes = 1024 * 1024) {
|
|
58
|
-
return new Promise((resolve, reject) => {
|
|
59
|
-
let total = 0;
|
|
60
|
-
const chunks = [];
|
|
61
|
-
req.on('data', (chunk) => {
|
|
62
|
-
total += chunk.length;
|
|
63
|
-
if (total > maxBytes) {
|
|
64
|
-
reject(new Error('body too large'));
|
|
65
|
-
req.destroy();
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
chunks.push(chunk);
|
|
69
|
-
});
|
|
70
|
-
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
71
|
-
req.on('error', reject);
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function sendJson(res, status, obj) {
|
|
76
|
-
const body = JSON.stringify(obj);
|
|
77
|
-
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) });
|
|
78
|
-
res.end(body);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Create the admin HTTP server. `remote` is scheduler.cjs's remote object
|
|
83
|
-
* (injected, not required directly, so this module stays testable without
|
|
84
|
-
* booting Electron).
|
|
85
|
-
*/
|
|
86
|
-
function createAdminServer(remote) {
|
|
87
|
-
let server = null;
|
|
88
|
-
let token = null;
|
|
89
|
-
|
|
90
|
-
async function ensureToken() {
|
|
91
|
-
token = crypto.randomBytes(32).toString('hex');
|
|
92
|
-
await config.writeJson(TOKEN_PATH, { port: null, token });
|
|
93
|
-
try { await fsp.chmod(TOKEN_PATH, 0o600); } catch { /* best-effort on platforms without POSIX perms */ }
|
|
94
|
-
return token;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
async function persistPort(port) {
|
|
98
|
-
await config.writeJson(TOKEN_PATH, { port, token });
|
|
99
|
-
try { await fsp.chmod(TOKEN_PATH, 0o600); } catch { /* */ }
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function authorized(req) {
|
|
103
|
-
const header = req.headers.authorization || '';
|
|
104
|
-
const match = /^Bearer (.+)$/.exec(header);
|
|
105
|
-
if (!match) return false;
|
|
106
|
-
return timingSafeEqualStrings(match[1], token);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
async function handleRequest(req, res) {
|
|
110
|
-
if (!authorized(req)) {
|
|
111
|
-
sendJson(res, 401, { ok: false, error: 'unauthorized' });
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
try {
|
|
115
|
-
if (req.method === 'GET' && req.url === '/admin/scheduler/jobs') {
|
|
116
|
-
const jobs = await remote.listJobs();
|
|
117
|
-
sendJson(res, 200, jobs);
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
if (req.method === 'POST' && req.url === '/admin/scheduler/reset-job') {
|
|
121
|
-
const raw = await readBody(req);
|
|
122
|
-
let parsed;
|
|
123
|
-
try {
|
|
124
|
-
parsed = raw ? JSON.parse(raw) : {};
|
|
125
|
-
} catch {
|
|
126
|
-
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
const slug = typeof parsed.slug === 'string' ? parsed.slug : null;
|
|
130
|
-
if (!slug) {
|
|
131
|
-
sendJson(res, 400, { ok: false, error: 'missing slug' });
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
const result = await remote.resetJob(slug);
|
|
135
|
-
sendJson(res, 200, result);
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
if (req.method === 'POST' && req.url === '/admin/scheduler/create-prd') {
|
|
139
|
-
const raw = await readBody(req);
|
|
140
|
-
let parsed;
|
|
141
|
-
try {
|
|
142
|
-
parsed = raw ? JSON.parse(raw) : {};
|
|
143
|
-
} catch {
|
|
144
|
-
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
let input;
|
|
149
|
-
try {
|
|
150
|
-
input = schemas.schedulerCreatePrd.parse(parsed);
|
|
151
|
-
} catch (e) {
|
|
152
|
-
sendJson(res, 400, { ok: false, error: 'invalid PRD payload', details: e?.issues ?? e?.message });
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// cwd is untrusted: this is the first *creating* mutation on a
|
|
157
|
-
// token-authed API whose product is "a command that will later run
|
|
158
|
-
// with --dangerously-skip-permissions in a chosen cwd". Route it
|
|
159
|
-
// through config.cjs's validatePath (allowedRoots = home dir) —
|
|
160
|
-
// same boundary every other fs-touching IPC handler uses — never a
|
|
161
|
-
// bespoke check here.
|
|
162
|
-
try {
|
|
163
|
-
config.validatePath(expandHome(input.cwd));
|
|
164
|
-
} catch (e) {
|
|
165
|
-
sendJson(res, 400, { ok: false, error: `cwd rejected: ${e?.message ?? 'outside allowed roots'}` });
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const slug = input.slug || prdCreate.deriveSlugFromTitle(input.title);
|
|
170
|
-
if (!slug || !prdCreate.PRD_CREATE_SLUG_RE.test(slug)) {
|
|
171
|
-
sendJson(res, 400, { ok: false, error: 'could not derive a valid kebab-case slug from title; supply "slug" explicitly' });
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// NN allocation is delegated to allocateParallelGroup() (PRD 548) via
|
|
176
|
-
// the injected remote — never re-derived here — unless the caller
|
|
177
|
-
// opted into an existing group explicitly.
|
|
178
|
-
const nn = input.parallelGroup ?? await remote.allocateParallelGroup();
|
|
179
|
-
const filenameSlug = `${nn}-${slug}`;
|
|
180
|
-
|
|
181
|
-
// An explicit `parallelGroup` bypasses allocateParallelGroup()'s
|
|
182
|
-
// collision-proof reservation, so re-check for an existing file at
|
|
183
|
-
// this exact destination before writing — remote.writePrd itself has
|
|
184
|
-
// no existence guard (by design, it doubles as the edit-in-place
|
|
185
|
-
// path for Scheduler UI PRD edits), so "create" must not silently
|
|
186
|
-
// clobber an existing job's PRD.
|
|
187
|
-
const existing = await remote.readPrd(filenameSlug);
|
|
188
|
-
if (existing?.ok) {
|
|
189
|
-
sendJson(res, 409, { ok: false, error: `PRD already exists: ${filenameSlug}.md` });
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
let standardsText;
|
|
194
|
-
try {
|
|
195
|
-
standardsText = await prdCreate.readStandards();
|
|
196
|
-
} catch (e) {
|
|
197
|
-
sendJson(res, 500, { ok: false, error: `could not read engineering standards: ${e?.message}` });
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const body = prdCreate.buildPrdBody(input, standardsText);
|
|
202
|
-
const writeResult = await remote.writePrd(filenameSlug, body);
|
|
203
|
-
if (!writeResult?.ok) {
|
|
204
|
-
sendJson(res, 500, { ok: false, error: writeResult?.error ?? 'write failed' });
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
sendJson(res, 200, { nn, filename: `${filenameSlug}.md`, status: 'queued' });
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
sendJson(res, 404, { ok: false, error: 'not found' });
|
|
212
|
-
} catch (e) {
|
|
213
|
-
sendJson(res, 500, { ok: false, error: e?.message ?? 'internal error' });
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
async function start() {
|
|
218
|
-
await ensureToken();
|
|
219
|
-
server = http.createServer((req, res) => {
|
|
220
|
-
handleRequest(req, res).catch(() => {
|
|
221
|
-
try { sendJson(res, 500, { ok: false, error: 'internal error' }); } catch { /* */ }
|
|
222
|
-
});
|
|
223
|
-
});
|
|
224
|
-
await new Promise((resolve, reject) => {
|
|
225
|
-
server.once('error', reject);
|
|
226
|
-
server.listen(0, '127.0.0.1', resolve);
|
|
227
|
-
});
|
|
228
|
-
const { port } = server.address();
|
|
229
|
-
await persistPort(port);
|
|
230
|
-
return { port, token };
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
async function stop() {
|
|
234
|
-
if (!server) return;
|
|
235
|
-
await new Promise((resolve) => server.close(() => resolve()));
|
|
236
|
-
server = null;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
return { start, stop, get token() { return token; }, get server() { return server; } };
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
module.exports = { createAdminServer, TOKEN_PATH };
|