sparda-mcp 0.5.2 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +158 -40
- package/SKILL.md +144 -0
- package/demo-app/package.json +7 -0
- package/demo-app/src/app.js +28 -0
- package/demo-app/src/routes/users.js +8 -0
- package/package.json +25 -3
- package/src/commands/demo.js +154 -0
- package/src/commands/doctor.js +51 -17
- package/src/commands/hook.js +21 -5
- package/src/commands/init.js +105 -33
- package/src/commands/remove.js +29 -10
- package/src/commands/report.js +372 -0
- package/src/commands/sync.js +37 -8
- package/src/detect.js +68 -18
- package/src/generator/express.js +86 -28
- package/src/generator/fastapi.js +66 -20
- package/src/generator/manifest.js +20 -13
- package/src/index.js +22 -2
- package/src/parser/express.js +144 -44
- package/src/parser/fastapi.js +13 -4
- package/src/probe/express-shim-esm.mjs +2 -2
- package/src/probe/express-shim.cjs +65 -22
- package/src/probe/integrate.js +34 -12
- package/src/probe/probe.js +59 -33
- package/src/probe/reconcile.js +24 -22
- package/src/security/sanitize.js +5 -1
- package/src/server/condenser.js +34 -12
- package/src/server/confirmation.js +75 -19
- package/src/server/context-carrier.js +36 -31
- package/src/server/crystallize.js +32 -9
- package/src/server/engine.js +118 -38
- package/src/server/idle.js +20 -4
- package/src/server/persistence.js +16 -5
- package/src/server/stdio.js +490 -162
- package/src/ui/style.js +30 -18
- package/templates/fastapi-router.txt +41 -13
package/src/probe/probe.js
CHANGED
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
* ESM, Node ≥ 18. Zero new dependencies.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { fork, spawn
|
|
15
|
-
import { fileURLToPath }
|
|
14
|
+
import { fork, spawn } from 'node:child_process';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
16
|
import { dirname, resolve, extname } from 'node:path';
|
|
17
|
-
import { platform }
|
|
17
|
+
import { platform } from 'node:process';
|
|
18
18
|
|
|
19
19
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
20
|
|
|
@@ -26,13 +26,18 @@ const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
|
26
26
|
|
|
27
27
|
// ── Public API ─────────────────────────────────────────────────────────────────
|
|
28
28
|
|
|
29
|
-
export async function probeRoutes({
|
|
29
|
+
export async function probeRoutes({
|
|
30
|
+
framework,
|
|
31
|
+
entryFile,
|
|
32
|
+
projectRoot,
|
|
33
|
+
timeoutMs = 8000,
|
|
34
|
+
}) {
|
|
30
35
|
if (framework === 'express') return probeExpress({ entryFile, projectRoot, timeoutMs });
|
|
31
36
|
if (framework === 'fastapi') return probeFastAPI({ entryFile, projectRoot, timeoutMs });
|
|
32
|
-
throw Object.assign(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
);
|
|
37
|
+
throw Object.assign(new Error(`Unknown framework: ${framework}`), {
|
|
38
|
+
code: 'USER',
|
|
39
|
+
hint: 'framework must be "express" or "fastapi"',
|
|
40
|
+
});
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
// ── Express probe ─────────────────────────────────────────────────────────────
|
|
@@ -42,12 +47,12 @@ async function probeExpress({ entryFile, projectRoot, timeoutMs }) {
|
|
|
42
47
|
|
|
43
48
|
if (ext === '.ts' || ext === '.mts' || ext === '.cts') {
|
|
44
49
|
process.stderr.write(
|
|
45
|
-
'[sparda] --probe skipped: .ts entry needs a runtime loader; static discovery still applied.\n'
|
|
50
|
+
'[sparda] --probe skipped: .ts entry needs a runtime loader; static discovery still applied.\n',
|
|
46
51
|
);
|
|
47
52
|
return [];
|
|
48
53
|
}
|
|
49
54
|
|
|
50
|
-
const isEsm
|
|
55
|
+
const isEsm = ext === '.mjs';
|
|
51
56
|
const shimFlag = isEsm ? ['--import', SHIM_ESM] : ['--require', SHIM_CJS];
|
|
52
57
|
|
|
53
58
|
const routes = [];
|
|
@@ -61,24 +66,30 @@ async function probeExpress({ entryFile, projectRoot, timeoutMs }) {
|
|
|
61
66
|
if (settled) return;
|
|
62
67
|
settled = true;
|
|
63
68
|
clearTimeout(killTimer);
|
|
64
|
-
try {
|
|
69
|
+
try {
|
|
70
|
+
child && child.kill('SIGKILL');
|
|
71
|
+
} catch {}
|
|
65
72
|
resolve_(result);
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
killTimer = setTimeout(() => {
|
|
69
|
-
process.stderr.write(
|
|
76
|
+
process.stderr.write(
|
|
77
|
+
'[sparda] --probe: timeout waiting for Express routes; using static floor.\n',
|
|
78
|
+
);
|
|
70
79
|
settle(routes);
|
|
71
80
|
}, timeoutMs);
|
|
72
81
|
|
|
73
82
|
try {
|
|
74
83
|
child = fork(entryFile, [], {
|
|
75
|
-
cwd:
|
|
84
|
+
cwd: projectRoot,
|
|
76
85
|
execArgv: shimFlag,
|
|
77
|
-
silent:
|
|
78
|
-
env:
|
|
86
|
+
silent: true,
|
|
87
|
+
env: { ...process.env, SPARDA_PROBE: '1' },
|
|
79
88
|
});
|
|
80
89
|
} catch (spawnErr) {
|
|
81
|
-
process.stderr.write(
|
|
90
|
+
process.stderr.write(
|
|
91
|
+
`[sparda] --probe: failed to spawn child: ${spawnErr.message}\n`,
|
|
92
|
+
);
|
|
82
93
|
clearTimeout(killTimer);
|
|
83
94
|
resolve_([]);
|
|
84
95
|
return;
|
|
@@ -88,15 +99,18 @@ async function probeExpress({ entryFile, projectRoot, timeoutMs }) {
|
|
|
88
99
|
|
|
89
100
|
child.on('message', (msg) => {
|
|
90
101
|
if (!msg || typeof msg !== 'object') return;
|
|
91
|
-
if (msg.type === '__done__') {
|
|
102
|
+
if (msg.type === '__done__') {
|
|
103
|
+
settle(routes);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
92
106
|
if (msg.type === 'route') {
|
|
93
107
|
const method = normalizeMethod(msg.method);
|
|
94
|
-
const path
|
|
108
|
+
const path = normalizePath(msg.path);
|
|
95
109
|
routes.push({
|
|
96
110
|
method,
|
|
97
111
|
path,
|
|
98
112
|
pathParams: extractPathParams(path),
|
|
99
|
-
source:
|
|
113
|
+
source: 'dynamic',
|
|
100
114
|
writeClass: WRITE_METHODS.has(method) ? 'write' : 'read',
|
|
101
115
|
});
|
|
102
116
|
}
|
|
@@ -116,7 +130,9 @@ async function probeExpress({ entryFile, projectRoot, timeoutMs }) {
|
|
|
116
130
|
async function probeFastAPI({ entryFile, projectRoot, timeoutMs }) {
|
|
117
131
|
const python = await resolvePython();
|
|
118
132
|
if (!python) {
|
|
119
|
-
process.stderr.write(
|
|
133
|
+
process.stderr.write(
|
|
134
|
+
'[sparda] --probe: python3/python not found; static discovery still applied.\n',
|
|
135
|
+
);
|
|
120
136
|
return [];
|
|
121
137
|
}
|
|
122
138
|
|
|
@@ -129,21 +145,25 @@ async function probeFastAPI({ entryFile, projectRoot, timeoutMs }) {
|
|
|
129
145
|
if (settled) return;
|
|
130
146
|
settled = true;
|
|
131
147
|
clearTimeout(killTimer);
|
|
132
|
-
try {
|
|
148
|
+
try {
|
|
149
|
+
child && child.kill();
|
|
150
|
+
} catch {}
|
|
133
151
|
resolve_(result);
|
|
134
152
|
}
|
|
135
153
|
|
|
136
154
|
killTimer = setTimeout(() => {
|
|
137
|
-
process.stderr.write(
|
|
155
|
+
process.stderr.write(
|
|
156
|
+
'[sparda] --probe: FastAPI probe timeout; using static floor.\n',
|
|
157
|
+
);
|
|
138
158
|
settle(parsePythonOutput(Buffer.concat(stdoutChunks).toString('utf8')));
|
|
139
159
|
}, timeoutMs);
|
|
140
160
|
|
|
141
161
|
let child;
|
|
142
162
|
try {
|
|
143
163
|
child = spawn(python, [PY_PROBE, entryFile], {
|
|
144
|
-
cwd:
|
|
164
|
+
cwd: projectRoot,
|
|
145
165
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
146
|
-
env:
|
|
166
|
+
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
|
|
147
167
|
});
|
|
148
168
|
} catch (err) {
|
|
149
169
|
process.stderr.write(`[sparda] --probe: failed to spawn python: ${err.message}\n`);
|
|
@@ -169,10 +189,10 @@ function parsePythonOutput(raw) {
|
|
|
169
189
|
const arr = JSON.parse(raw.trim());
|
|
170
190
|
if (!Array.isArray(arr)) return [];
|
|
171
191
|
return arr.map((r) => ({
|
|
172
|
-
method:
|
|
173
|
-
path:
|
|
192
|
+
method: normalizeMethod(r.method),
|
|
193
|
+
path: normalizeFastAPIParams(r.path ?? '/'),
|
|
174
194
|
pathParams: extractPathParams(r.path ?? '/'),
|
|
175
|
-
source:
|
|
195
|
+
source: 'dynamic',
|
|
176
196
|
writeClass: WRITE_METHODS.has(normalizeMethod(r.method)) ? 'write' : 'read',
|
|
177
197
|
}));
|
|
178
198
|
} catch {
|
|
@@ -187,9 +207,7 @@ function parsePythonOutput(raw) {
|
|
|
187
207
|
// §A.6: 1500 ms timeout kills MS Store stub that never returns.
|
|
188
208
|
|
|
189
209
|
async function resolvePython() {
|
|
190
|
-
const candidates = platform === 'win32'
|
|
191
|
-
? ['python', 'python3']
|
|
192
|
-
: ['python3', 'python'];
|
|
210
|
+
const candidates = platform === 'win32' ? ['python', 'python3'] : ['python3', 'python'];
|
|
193
211
|
for (const cmd of candidates) {
|
|
194
212
|
if (await checkCommand(cmd)) return cmd;
|
|
195
213
|
}
|
|
@@ -201,7 +219,9 @@ function checkCommand(cmd) {
|
|
|
201
219
|
let child;
|
|
202
220
|
// §A.6: 1500 ms timeout — kills Microsoft Store python stub that hangs
|
|
203
221
|
const timer = setTimeout(() => {
|
|
204
|
-
try {
|
|
222
|
+
try {
|
|
223
|
+
child && child.kill();
|
|
224
|
+
} catch {}
|
|
205
225
|
res(false);
|
|
206
226
|
}, 1500);
|
|
207
227
|
|
|
@@ -213,8 +233,14 @@ function checkCommand(cmd) {
|
|
|
213
233
|
return;
|
|
214
234
|
}
|
|
215
235
|
|
|
216
|
-
child.on('error', () => {
|
|
217
|
-
|
|
236
|
+
child.on('error', () => {
|
|
237
|
+
clearTimeout(timer);
|
|
238
|
+
res(false);
|
|
239
|
+
});
|
|
240
|
+
child.on('exit', (code) => {
|
|
241
|
+
clearTimeout(timer);
|
|
242
|
+
res(code === 0);
|
|
243
|
+
});
|
|
218
244
|
});
|
|
219
245
|
}
|
|
220
246
|
|
package/src/probe/reconcile.js
CHANGED
|
@@ -37,9 +37,9 @@ export function reconcile(staticRoutes, probedRoutes) {
|
|
|
37
37
|
if (!probedRoutes || probedRoutes.length === 0) {
|
|
38
38
|
const sorted = sortRoutes([...staticRoutes]);
|
|
39
39
|
return {
|
|
40
|
-
routes:
|
|
41
|
-
gaps:
|
|
42
|
-
staticCount:
|
|
40
|
+
routes: sorted,
|
|
41
|
+
gaps: [],
|
|
42
|
+
staticCount: staticRoutes.length,
|
|
43
43
|
dynamicCount: 0,
|
|
44
44
|
};
|
|
45
45
|
}
|
|
@@ -66,11 +66,11 @@ export function reconcile(staticRoutes, probedRoutes) {
|
|
|
66
66
|
} else {
|
|
67
67
|
// Gap: probe found something the AST missed
|
|
68
68
|
const gap = {
|
|
69
|
-
method:
|
|
70
|
-
path:
|
|
69
|
+
method: (dyn.method ?? 'GET').toUpperCase(),
|
|
70
|
+
path: normalizeFastAPIParams(dyn.path ?? '/'),
|
|
71
71
|
pathParams: extractPathParams(dyn.path ?? '/'),
|
|
72
|
-
source:
|
|
73
|
-
confirmed:
|
|
72
|
+
source: 'dynamic',
|
|
73
|
+
confirmed: false,
|
|
74
74
|
writeClass: dyn.writeClass ?? inferWriteClass(dyn.method),
|
|
75
75
|
};
|
|
76
76
|
gaps.push(gap);
|
|
@@ -82,9 +82,9 @@ export function reconcile(staticRoutes, probedRoutes) {
|
|
|
82
82
|
const sorted = sortRoutes(union);
|
|
83
83
|
|
|
84
84
|
return {
|
|
85
|
-
routes:
|
|
85
|
+
routes: sorted,
|
|
86
86
|
gaps,
|
|
87
|
-
staticCount:
|
|
87
|
+
staticCount: staticRoutes.length,
|
|
88
88
|
dynamicCount: gaps.length,
|
|
89
89
|
};
|
|
90
90
|
}
|
|
@@ -97,7 +97,7 @@ export function reconcile(staticRoutes, probedRoutes) {
|
|
|
97
97
|
*/
|
|
98
98
|
function routeKey(route) {
|
|
99
99
|
const method = (route.method ?? 'GET').toUpperCase();
|
|
100
|
-
const path
|
|
100
|
+
const path = normalizePath(route.path ?? '/');
|
|
101
101
|
return `${method} ${path}`;
|
|
102
102
|
}
|
|
103
103
|
|
|
@@ -106,14 +106,15 @@ function routeKey(route) {
|
|
|
106
106
|
* Used only for comparison (key generation).
|
|
107
107
|
*/
|
|
108
108
|
function normalizePath(path) {
|
|
109
|
-
return (
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
109
|
+
return (
|
|
110
|
+
(path ?? '/')
|
|
111
|
+
.replace(/\{[^}]+\}/g, ':param') // FastAPI {id}
|
|
112
|
+
.replace(/:[a-zA-Z_]\w*/g, ':param') // Express :id
|
|
113
|
+
.replace(/\*\*[^/]*/g, ':wildcard') // FastAPI **rest
|
|
114
|
+
.replace(/\/+/g, '/')
|
|
115
|
+
.replace(/\/$/, '') // strip trailing slash (except root)
|
|
116
|
+
.toLowerCase() || '/'
|
|
117
|
+
);
|
|
117
118
|
}
|
|
118
119
|
|
|
119
120
|
/**
|
|
@@ -121,10 +122,11 @@ function normalizePath(path) {
|
|
|
121
122
|
* Express :param style is already canonical.
|
|
122
123
|
*/
|
|
123
124
|
function normalizeFastAPIParams(path) {
|
|
124
|
-
return (
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
125
|
+
return (
|
|
126
|
+
(path ?? '/')
|
|
127
|
+
.replace(/\{([^}]+)\}/g, ':$1') // {id} → :id
|
|
128
|
+
.replace(/\/+/g, '/') || '/'
|
|
129
|
+
);
|
|
128
130
|
}
|
|
129
131
|
|
|
130
132
|
/** Extract ordered param names from a path (Express or FastAPI style). */
|
package/src/security/sanitize.js
CHANGED
|
@@ -8,7 +8,11 @@ const DANGEROUS = [
|
|
|
8
8
|
];
|
|
9
9
|
|
|
10
10
|
export function sanitizeDescription(raw, fallback) {
|
|
11
|
-
let text = String(raw ?? '')
|
|
11
|
+
let text = String(raw ?? '')
|
|
12
|
+
.replace(/[\r\n]+/g, ' ')
|
|
13
|
+
.replace(/\s{2,}/g, ' ')
|
|
14
|
+
.trim()
|
|
15
|
+
.slice(0, 300);
|
|
12
16
|
const flagged = DANGEROUS.some((rx) => rx.test(text));
|
|
13
17
|
if (flagged) text = '';
|
|
14
18
|
text = text.replace(/[<>{}]/g, '');
|
package/src/server/condenser.js
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import { writeManifestSync } from './persistence.js';
|
|
8
8
|
|
|
9
|
-
const WINDOW = 20;
|
|
10
|
-
const MAX_VALUES = 50;
|
|
11
|
-
const MAX_NODES = 200;
|
|
9
|
+
const WINDOW = 20; // calls remembered per session (ring)
|
|
10
|
+
const MAX_VALUES = 50; // scalars kept per payload
|
|
11
|
+
const MAX_NODES = 200; // payload walk budget — nothing heavy, ever
|
|
12
12
|
const MAX_DEPTH = 4;
|
|
13
|
-
const MAX_CHAIN = 5;
|
|
13
|
+
const MAX_CHAIN = 5; // a circuit longer than this is noise, not a workflow
|
|
14
14
|
const MAX_LINKS = 10;
|
|
15
15
|
const MAX_CIRCUITS = 30; // bounded memory, same philosophy as antibodies (ADR-010)
|
|
16
16
|
// an emergent capability is a suggestion only after N observations (survival rule)
|
|
@@ -35,8 +35,7 @@ export function createSequenceRecorder({ manifest, manifestPath, harvester, onCi
|
|
|
35
35
|
const argEntries = extractArgEntries(args);
|
|
36
36
|
let link = null;
|
|
37
37
|
// most recent producer wins: the freshest output is the likeliest source
|
|
38
|
-
outer:
|
|
39
|
-
for (let i = ring.length - 1; i >= 0; i--) {
|
|
38
|
+
outer: for (let i = ring.length - 1; i >= 0; i--) {
|
|
40
39
|
for (const [argName, value] of argEntries) {
|
|
41
40
|
if (ring[i].outValues.has(value)) {
|
|
42
41
|
// fromKey = where the value lived in the producer's output — the structure
|
|
@@ -54,12 +53,29 @@ export function createSequenceRecorder({ manifest, manifestPath, harvester, onCi
|
|
|
54
53
|
const sig = chain.join('>');
|
|
55
54
|
const now = new Date().toISOString();
|
|
56
55
|
const circuits = manifest.labs.circuits;
|
|
57
|
-
const c =
|
|
56
|
+
const c =
|
|
57
|
+
circuits[sig] ??
|
|
58
|
+
(circuits[sig] = {
|
|
59
|
+
steps: chain,
|
|
60
|
+
links: [],
|
|
61
|
+
seen: 0,
|
|
62
|
+
firstSeen: now,
|
|
63
|
+
lastSeen: now,
|
|
64
|
+
});
|
|
58
65
|
c.seen += 1;
|
|
59
66
|
c.lastSeen = now;
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
c.links.
|
|
67
|
+
if (
|
|
68
|
+
c.links.length < MAX_LINKS &&
|
|
69
|
+
!c.links.some(
|
|
70
|
+
(l) => l.from === link.from.tool && l.to === tool && l.arg === link.arg,
|
|
71
|
+
)
|
|
72
|
+
) {
|
|
73
|
+
c.links.push({
|
|
74
|
+
from: link.from.tool,
|
|
75
|
+
to: tool,
|
|
76
|
+
arg: link.arg,
|
|
77
|
+
fromKey: link.fromKey,
|
|
78
|
+
});
|
|
63
79
|
}
|
|
64
80
|
evict(circuits);
|
|
65
81
|
persist();
|
|
@@ -71,7 +87,11 @@ export function createSequenceRecorder({ manifest, manifestPath, harvester, onCi
|
|
|
71
87
|
const keys = Object.keys(circuits);
|
|
72
88
|
if (keys.length <= MAX_CIRCUITS) return;
|
|
73
89
|
keys
|
|
74
|
-
.sort(
|
|
90
|
+
.sort(
|
|
91
|
+
(a, b) =>
|
|
92
|
+
circuits[a].seen - circuits[b].seen ||
|
|
93
|
+
circuits[a].lastSeen.localeCompare(circuits[b].lastSeen),
|
|
94
|
+
)
|
|
75
95
|
.slice(0, keys.length - MAX_CIRCUITS)
|
|
76
96
|
.forEach((k) => delete circuits[k]);
|
|
77
97
|
}
|
|
@@ -82,7 +102,9 @@ export function createSequenceRecorder({ manifest, manifestPath, harvester, onCi
|
|
|
82
102
|
const onDisk = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
83
103
|
onDisk.labs = { ...onDisk.labs, circuits: manifest.labs.circuits };
|
|
84
104
|
writeManifestSync(manifestPath, onDisk);
|
|
85
|
-
} catch {
|
|
105
|
+
} catch {
|
|
106
|
+
/* disk briefly unavailable — circuits stay in memory */
|
|
107
|
+
}
|
|
86
108
|
}
|
|
87
109
|
|
|
88
110
|
return { record, circuits: () => manifest.labs.circuits };
|
|
@@ -49,9 +49,15 @@ export function initiateWrite({ token, label } = {}) {
|
|
|
49
49
|
sweep();
|
|
50
50
|
if (pendingWrites.has(token)) return; // dialog already armed for this token
|
|
51
51
|
if (pendingWrites.size >= MAX_PENDING) evictOldest();
|
|
52
|
-
pendingWrites.set(token, {
|
|
52
|
+
pendingWrites.set(token, {
|
|
53
|
+
state: S1,
|
|
54
|
+
expiresAt: Date.now() + TIMEOUT_MS,
|
|
55
|
+
label: String(label ?? token.slice(0, 12)),
|
|
56
|
+
});
|
|
53
57
|
// Signal 2 runs in parallel — it NEVER blocks the MCP response.
|
|
54
|
-
setImmediate(() => {
|
|
58
|
+
setImmediate(() => {
|
|
59
|
+
requestSignal2(token).catch(() => markDenied(token));
|
|
60
|
+
});
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
// ── Pre-approval: the human already said yes out-of-band via native elicitation (client UI). ──
|
|
@@ -60,7 +66,11 @@ export function preapproveWrite(token) {
|
|
|
60
66
|
if (typeof token !== 'string' || !token) return;
|
|
61
67
|
sweep();
|
|
62
68
|
if (!pendingWrites.has(token) && pendingWrites.size >= MAX_PENDING) evictOldest();
|
|
63
|
-
pendingWrites.set(token, {
|
|
69
|
+
pendingWrites.set(token, {
|
|
70
|
+
state: S2,
|
|
71
|
+
expiresAt: Date.now() + TIMEOUT_MS,
|
|
72
|
+
label: 'elicitation',
|
|
73
|
+
});
|
|
64
74
|
}
|
|
65
75
|
|
|
66
76
|
async function requestSignal2(token) {
|
|
@@ -71,7 +81,10 @@ async function requestSignal2(token) {
|
|
|
71
81
|
: await openOSDialog(entry.label, token);
|
|
72
82
|
const e = pendingWrites.get(token);
|
|
73
83
|
if (!e || e.state !== S1) return; // consumed, expired, or pre-approved meanwhile
|
|
74
|
-
if (Date.now() > e.expiresAt) {
|
|
84
|
+
if (Date.now() > e.expiresAt) {
|
|
85
|
+
pendingWrites.delete(token);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
75
88
|
e.state = approved ? S2 : DENIED;
|
|
76
89
|
}
|
|
77
90
|
|
|
@@ -88,12 +101,22 @@ export function confirmWrite(token) {
|
|
|
88
101
|
sweep();
|
|
89
102
|
const entry = pendingWrites.get(token);
|
|
90
103
|
if (!entry) return { ok: false, reason: 'unknown_token' };
|
|
91
|
-
if (Date.now() > entry.expiresAt) {
|
|
104
|
+
if (Date.now() > entry.expiresAt) {
|
|
105
|
+
pendingWrites.delete(token);
|
|
106
|
+
return { ok: false, reason: 'expired' };
|
|
107
|
+
}
|
|
92
108
|
switch (entry.state) {
|
|
93
|
-
case S1:
|
|
94
|
-
|
|
95
|
-
case
|
|
96
|
-
|
|
109
|
+
case S1:
|
|
110
|
+
return { ok: false, reason: 'awaiting_human' }; // keep — the AI must retry after the click
|
|
111
|
+
case DENIED:
|
|
112
|
+
pendingWrites.delete(token);
|
|
113
|
+
return { ok: false, reason: 'human_denied' };
|
|
114
|
+
case S2:
|
|
115
|
+
pendingWrites.delete(token);
|
|
116
|
+
return { ok: true }; // burn — single use
|
|
117
|
+
default:
|
|
118
|
+
pendingWrites.delete(token);
|
|
119
|
+
return { ok: false, reason: 'invalid_state' };
|
|
97
120
|
}
|
|
98
121
|
}
|
|
99
122
|
|
|
@@ -114,17 +137,31 @@ export function buildDialogSpawn(platform, env, msg, title) {
|
|
|
114
137
|
'[System.Windows.Forms.MessageBoxDefaultButton]::Button2);',
|
|
115
138
|
'if ($r -eq [System.Windows.Forms.DialogResult]::Yes) { exit 0 } else { exit 1 }',
|
|
116
139
|
].join(' ');
|
|
117
|
-
return {
|
|
140
|
+
return {
|
|
141
|
+
cmd: 'powershell',
|
|
142
|
+
args: ['-NoProfile', '-NonInteractive', '-STA', '-Command', ps],
|
|
143
|
+
};
|
|
118
144
|
}
|
|
119
145
|
if (platform === 'darwin') {
|
|
120
146
|
// osascript reads the message from the inherited env (system attribute) — no interpolation.
|
|
121
|
-
const osa =
|
|
122
|
-
|
|
147
|
+
const osa =
|
|
148
|
+
'display dialog (system attribute "SPARDA_DLG_MSG") with title (system attribute "SPARDA_DLG_TITLE") ' +
|
|
149
|
+
'buttons {"Deny", "Allow"} default button "Deny" cancel button "Deny" with icon caution';
|
|
123
150
|
return { cmd: 'osascript', args: ['-e', osa] };
|
|
124
151
|
}
|
|
125
152
|
// Linux/BSD: zenity needs an X or Wayland display. Without one, fail closed.
|
|
126
153
|
if (env.DISPLAY || env.WAYLAND_DISPLAY) {
|
|
127
|
-
return {
|
|
154
|
+
return {
|
|
155
|
+
cmd: 'zenity',
|
|
156
|
+
args: [
|
|
157
|
+
'--question',
|
|
158
|
+
`--title=${title}`,
|
|
159
|
+
`--text=${msg}`,
|
|
160
|
+
'--ok-label=Allow',
|
|
161
|
+
'--cancel-label=Deny',
|
|
162
|
+
'--width=420',
|
|
163
|
+
],
|
|
164
|
+
};
|
|
128
165
|
}
|
|
129
166
|
return null; // headless → caller denies
|
|
130
167
|
}
|
|
@@ -136,7 +173,9 @@ function openOSDialog(label, token) {
|
|
|
136
173
|
const plan = buildDialogSpawn(process.platform, process.env, msg, title);
|
|
137
174
|
if (!plan) {
|
|
138
175
|
// R2: human log to stderr. Fail closed — no approval channel means no write.
|
|
139
|
-
process.stderr.write(
|
|
176
|
+
process.stderr.write(
|
|
177
|
+
`[sparda] no desktop display — write auto-denied (Signal 2 unreachable): ${label}\n`,
|
|
178
|
+
);
|
|
140
179
|
return resolve(false);
|
|
141
180
|
}
|
|
142
181
|
let proc;
|
|
@@ -148,16 +187,33 @@ function openOSDialog(label, token) {
|
|
|
148
187
|
} catch {
|
|
149
188
|
return resolve(false); // spawner missing → deny
|
|
150
189
|
}
|
|
151
|
-
const timer = setTimeout(() => {
|
|
152
|
-
|
|
153
|
-
|
|
190
|
+
const timer = setTimeout(() => {
|
|
191
|
+
try {
|
|
192
|
+
proc.kill();
|
|
193
|
+
} catch {
|
|
194
|
+
/* already gone */
|
|
195
|
+
}
|
|
196
|
+
resolve(false);
|
|
197
|
+
}, TIMEOUT_MS);
|
|
198
|
+
proc.on('close', (code) => {
|
|
199
|
+
clearTimeout(timer);
|
|
200
|
+
resolve(code === 0);
|
|
201
|
+
});
|
|
202
|
+
proc.on('error', () => {
|
|
203
|
+
clearTimeout(timer);
|
|
204
|
+
resolve(false);
|
|
205
|
+
}); // binary absent (e.g. no zenity) → deny
|
|
154
206
|
});
|
|
155
207
|
}
|
|
156
208
|
|
|
157
209
|
// test hooks: inject a fake dialog, inspect/clear the pending map. Never used in production.
|
|
158
210
|
export const _confirmTestHooks = {
|
|
159
|
-
setDialogProvider: (fn) => {
|
|
160
|
-
|
|
211
|
+
setDialogProvider: (fn) => {
|
|
212
|
+
dialogProvider = fn;
|
|
213
|
+
},
|
|
214
|
+
clearDialogProvider: () => {
|
|
215
|
+
dialogProvider = null;
|
|
216
|
+
},
|
|
161
217
|
getPendingCount: () => pendingWrites.size,
|
|
162
218
|
clearAll: () => pendingWrites.clear(),
|
|
163
219
|
TIMEOUT_MS,
|