poi-plugin-mcp 0.2.24 → 0.2.26
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/lib/bridge-controller.js +75 -1
- package/lib/poi-http-bridge.js +144 -0
- package/lib/poi-interaction-recorder.js +72 -10
- package/lib/settings.js +9 -0
- package/lib/writer-tokens.js +130 -0
- package/package.json +1 -1
package/lib/bridge-controller.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const path = require('path')
|
|
1
2
|
const { createPoiDataBridge } = require('./poi-http-bridge')
|
|
2
3
|
const { loadOrCreateInputToken } = require('./input-token')
|
|
3
4
|
const {
|
|
@@ -10,13 +11,14 @@ const {
|
|
|
10
11
|
function createBridgeController(options = {}) {
|
|
11
12
|
const settingsPath = options.settingsPath || DEFAULT_SETTINGS_FILE
|
|
12
13
|
const logger = options.logger || console
|
|
13
|
-
|
|
14
|
+
let createBridge = options.createBridge || createPoiDataBridge
|
|
14
15
|
|
|
15
16
|
let settings = loadSettings(settingsPath)
|
|
16
17
|
let bridge = null
|
|
17
18
|
let recorder = null
|
|
18
19
|
let inputToken = options.inputToken || null
|
|
19
20
|
let pending = Promise.resolve()
|
|
21
|
+
let reloading = false
|
|
20
22
|
|
|
21
23
|
function enqueue(action) {
|
|
22
24
|
pending = pending.then(action, action)
|
|
@@ -48,6 +50,8 @@ function createBridgeController(options = {}) {
|
|
|
48
50
|
port: settings.port,
|
|
49
51
|
portFile: options.portFile,
|
|
50
52
|
logger,
|
|
53
|
+
isWriterTokenEnforced: () => settings.writerTokenEnforced === true,
|
|
54
|
+
reloadModules: hotReloadModules,
|
|
51
55
|
})
|
|
52
56
|
}
|
|
53
57
|
|
|
@@ -99,6 +103,64 @@ function createBridgeController(options = {}) {
|
|
|
99
103
|
await runningBridge.stop()
|
|
100
104
|
}
|
|
101
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Hot reload (2026-09-12): purge this plugin's lib/ require cache and
|
|
108
|
+
* rebuild the bridge from the on-disk code. Live instances (this
|
|
109
|
+
* controller, the telemetry object wired in index.js, the recorder) keep
|
|
110
|
+
* their closures and state; only the module table refreshes. Returns after
|
|
111
|
+
* validation — the swap is scheduled so the HTTP response can flush first.
|
|
112
|
+
*/
|
|
113
|
+
function hotReloadModules() {
|
|
114
|
+
if (reloading) {
|
|
115
|
+
return Promise.reject(new Error('a reload is already in progress'))
|
|
116
|
+
}
|
|
117
|
+
return enqueue(async () => {
|
|
118
|
+
reloading = true
|
|
119
|
+
try {
|
|
120
|
+
const libRoot = __dirname
|
|
121
|
+
for (const key of Object.keys(require.cache)) {
|
|
122
|
+
if (key.startsWith(libRoot + path.sep) || key === libRoot) {
|
|
123
|
+
delete require.cache[key]
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// Fresh require throws on a broken build → old bridge untouched.
|
|
127
|
+
const fresh = require('./poi-http-bridge')
|
|
128
|
+
const freshFactory = fresh.createPoiDataBridge
|
|
129
|
+
if (typeof freshFactory !== 'function') {
|
|
130
|
+
throw new Error('fresh poi-http-bridge does not export createPoiDataBridge')
|
|
131
|
+
}
|
|
132
|
+
// Re-read settings: the fresh settings module may normalize new keys.
|
|
133
|
+
const freshSettings = require('./settings')
|
|
134
|
+
settings = freshSettings.normalizeSettings(
|
|
135
|
+
freshSettings.loadSettings(settingsPath),
|
|
136
|
+
)
|
|
137
|
+
createBridge = freshFactory
|
|
138
|
+
const wasEnabled = settings.enabled
|
|
139
|
+
// Let the /admin/reload response flush on the old connection before
|
|
140
|
+
// it drops; the swap then runs on the controller's serial queue.
|
|
141
|
+
setTimeout(() => {
|
|
142
|
+
enqueue(async () => {
|
|
143
|
+
try {
|
|
144
|
+
await stopCurrentBridge()
|
|
145
|
+
if (wasEnabled) await ensureStarted()
|
|
146
|
+
logger.log('[poi-plugin-mcp] hot reload complete')
|
|
147
|
+
} catch (error) {
|
|
148
|
+
logger.error(`[poi-plugin-mcp] hot reload swap failed: ${error.message}`)
|
|
149
|
+
// Best effort: the factory is already fresh; the next
|
|
150
|
+
// ensureStarted (any settings apply or manual start) recovers.
|
|
151
|
+
try {
|
|
152
|
+
if (wasEnabled) await ensureStarted()
|
|
153
|
+
} catch (_) { /* surfaced by the next status poll */ }
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
}, 250)
|
|
157
|
+
return { port: settings.port, deferredSwap: true }
|
|
158
|
+
} finally {
|
|
159
|
+
reloading = false
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
102
164
|
function persist(nextSettings) {
|
|
103
165
|
settings = saveSettings(normalizeSettings(nextSettings), settingsPath)
|
|
104
166
|
return settings
|
|
@@ -175,6 +237,18 @@ function createBridgeController(options = {}) {
|
|
|
175
237
|
return { ...settings }
|
|
176
238
|
},
|
|
177
239
|
|
|
240
|
+
/**
|
|
241
|
+
* 2026-09-12 hot reload: rebuild the HTTP/input layer from the on-disk
|
|
242
|
+
* lib/ code without restarting poi. Telemetry getters are re-injected
|
|
243
|
+
* (event generations survive); the recorder session is untouched.
|
|
244
|
+
*
|
|
245
|
+
* Ordering: the fresh modules are required BEFORE anything stops — a bad
|
|
246
|
+
* build throws here, the old bridge keeps serving, and the caller gets a
|
|
247
|
+
* 500 with the require error. The swap itself is deferred so the
|
|
248
|
+
* /admin/reload response flushes on the old connection before it drops.
|
|
249
|
+
*/
|
|
250
|
+
reloadModules: hotReloadModules,
|
|
251
|
+
|
|
178
252
|
getStatus() {
|
|
179
253
|
const actualPort = bridge ? bridge.getPort() : 0
|
|
180
254
|
const recorderStatus = recorder && typeof recorder.getStatus === 'function'
|
package/lib/poi-http-bridge.js
CHANGED
|
@@ -5,6 +5,7 @@ const os = require('os')
|
|
|
5
5
|
const path = require('path')
|
|
6
6
|
const packageJson = require('../package.json')
|
|
7
7
|
const { loadOrCreateInputToken } = require('./input-token')
|
|
8
|
+
const { getWriterTokenRegistry, hasValidWriterToken } = require('./writer-tokens')
|
|
8
9
|
const { createPoiInputProvider } = require('./poi-input')
|
|
9
10
|
const { createPoiInputLease } = require('./poi-input-lease')
|
|
10
11
|
const { createPoiScreenshotProvider } = require('./poi-screenshot')
|
|
@@ -107,6 +108,14 @@ function createPoiDataBridge(options = {}) {
|
|
|
107
108
|
inputEnabled ? loadOrCreateInputToken(options.inputTokenFile) : null
|
|
108
109
|
)
|
|
109
110
|
const inputLease = options.inputLease || createPoiInputLease(options.inputLeaseOptions)
|
|
111
|
+
// 2026-09-12 Phase C: daemon-delegated writer tokens gate the INPUT
|
|
112
|
+
// surface (clicks, lease takes, debug-eval). Module-level singleton so
|
|
113
|
+
// settings-triggered bridge re-creation keeps the registered set; a hot
|
|
114
|
+
// reload purges the module, and the daemon's periodic re-registration
|
|
115
|
+
// self-heals within one idle cycle.
|
|
116
|
+
const writerTokens = options.writerTokens || getWriterTokenRegistry()
|
|
117
|
+
const isWriterTokenEnforced = options.isWriterTokenEnforced ||
|
|
118
|
+
(() => options.writerTokenEnforced === true)
|
|
110
119
|
let captureScreenshot = options.captureScreenshot || null
|
|
111
120
|
let performInput = options.performInput || null
|
|
112
121
|
let dataQuery = options.dataQuery || null
|
|
@@ -116,6 +125,33 @@ function createPoiDataBridge(options = {}) {
|
|
|
116
125
|
let inputPending = Promise.resolve()
|
|
117
126
|
const pendingActionEventWaits = new Set()
|
|
118
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Writer-token gate for input-capable endpoints. Callers have already
|
|
130
|
+
* validated the static bearer (transport auth). Three outcomes:
|
|
131
|
+
* registered token -> pass; missing/rejected token + enforcement -> 403
|
|
132
|
+
* with a reason a stale process can act on; missing token in observation
|
|
133
|
+
* mode -> pass and count (legacyAuth), which is the bridge-side
|
|
134
|
+
* foreign-writer signal.
|
|
135
|
+
*/
|
|
136
|
+
function authorizeWriterInput(req) {
|
|
137
|
+
const headerValue = req.headers['x-writer-token']
|
|
138
|
+
if (hasValidWriterToken(headerValue, writerTokens)) {
|
|
139
|
+
return { ok: true }
|
|
140
|
+
}
|
|
141
|
+
if (isWriterTokenEnforced()) {
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
status: 403,
|
|
145
|
+
error:
|
|
146
|
+
'Writer token rejected: no registered X-Writer-Token matches. ' +
|
|
147
|
+
'Stale process from a previous daemon generation? Re-submit the work ' +
|
|
148
|
+
'through the daemon (kc daemon submit) or renew the token.',
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
writerTokens.noteLegacyAuth()
|
|
152
|
+
return { ok: true, legacy: true }
|
|
153
|
+
}
|
|
154
|
+
|
|
119
155
|
function readStore() {
|
|
120
156
|
const store = getStore()
|
|
121
157
|
if (!store || !store.info) {
|
|
@@ -176,6 +212,17 @@ function createPoiDataBridge(options = {}) {
|
|
|
176
212
|
)
|
|
177
213
|
return
|
|
178
214
|
}
|
|
215
|
+
// /debug/evaluate executes arbitrary JS in the game WebView — it is a
|
|
216
|
+
// write-capable channel and takes the writer-token gate. /query is a
|
|
217
|
+
// read and stays static-bearer (diagnostics keep working).
|
|
218
|
+
if (endpoint === '/debug/evaluate') {
|
|
219
|
+
const writer = authorizeWriterInput(req)
|
|
220
|
+
if (!writer.ok) {
|
|
221
|
+
drainRequest(req)
|
|
222
|
+
sendInputJson(res, writer.status, { error: writer.error })
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
}
|
|
179
226
|
try {
|
|
180
227
|
const body = await readRequestBody(
|
|
181
228
|
req,
|
|
@@ -261,6 +308,12 @@ function createPoiDataBridge(options = {}) {
|
|
|
261
308
|
sendInputJson(res, 403, { error: 'WebView input is disabled.' })
|
|
262
309
|
return
|
|
263
310
|
}
|
|
311
|
+
const writer = authorizeWriterInput(req)
|
|
312
|
+
if (!writer.ok) {
|
|
313
|
+
drainRequest(req)
|
|
314
|
+
sendInputJson(res, writer.status, { error: writer.error })
|
|
315
|
+
return
|
|
316
|
+
}
|
|
264
317
|
|
|
265
318
|
try {
|
|
266
319
|
const body = await readRequestBody(
|
|
@@ -295,6 +348,15 @@ function createPoiDataBridge(options = {}) {
|
|
|
295
348
|
)
|
|
296
349
|
return
|
|
297
350
|
}
|
|
351
|
+
// Lease take/renew are input-adjacent (they gate who may click); the
|
|
352
|
+
// writer-token applies. Release is also gated for symmetry — leases
|
|
353
|
+
// expire via TTL anyway, so a stale cleanup tool failing here is safe.
|
|
354
|
+
const leaseWriter = authorizeWriterInput(req)
|
|
355
|
+
if (!leaseWriter.ok) {
|
|
356
|
+
drainRequest(req)
|
|
357
|
+
sendInputJson(res, leaseWriter.status, { error: leaseWriter.error })
|
|
358
|
+
return
|
|
359
|
+
}
|
|
298
360
|
if (endpoint === '/input/lease') {
|
|
299
361
|
if (req.method !== 'GET') {
|
|
300
362
|
drainRequest(req)
|
|
@@ -527,6 +589,79 @@ function createPoiDataBridge(options = {}) {
|
|
|
527
589
|
return
|
|
528
590
|
}
|
|
529
591
|
|
|
592
|
+
// 2026-09-12 Phase C: writer-token administration. The static bearer is
|
|
593
|
+
// the ADMIN credential here — exactly one caller (the daemon) uses it
|
|
594
|
+
// to delegate; input endpoints no longer accept it as a writer.
|
|
595
|
+
if (endpoint === '/writer-tokens') {
|
|
596
|
+
if (!hasValidBearerToken(req.headers.authorization, inputToken)) {
|
|
597
|
+
drainRequest(req)
|
|
598
|
+
sendInputJson(res, 401, { error: 'A valid Bearer token is required.' })
|
|
599
|
+
return
|
|
600
|
+
}
|
|
601
|
+
if (req.method === 'GET') {
|
|
602
|
+
sendInputJson(res, 200, {
|
|
603
|
+
enforced: isWriterTokenEnforced(),
|
|
604
|
+
...writerTokens.status(),
|
|
605
|
+
})
|
|
606
|
+
return
|
|
607
|
+
}
|
|
608
|
+
if (req.method !== 'PUT') {
|
|
609
|
+
drainRequest(req)
|
|
610
|
+
sendInputJson(res, 405, { error: '/writer-tokens accepts GET or PUT.' })
|
|
611
|
+
return
|
|
612
|
+
}
|
|
613
|
+
try {
|
|
614
|
+
const body = await readRequestBody(
|
|
615
|
+
req,
|
|
616
|
+
MAX_QUERY_BODY_BYTES,
|
|
617
|
+
'Writer token request body exceeds 64KB.',
|
|
618
|
+
)
|
|
619
|
+
const payload = JSON.parse(body || '{}')
|
|
620
|
+
const outcome = writerTokens.register(payload)
|
|
621
|
+
sendInputJson(res, 200, { ok: true, ...outcome, ...writerTokens.status() })
|
|
622
|
+
} catch (error) {
|
|
623
|
+
sendInputJson(res, 400, { error: error.message })
|
|
624
|
+
}
|
|
625
|
+
return
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// 2026-09-12 hot reload: rebuild the HTTP/input layer from the on-disk
|
|
629
|
+
// lib/ code without restarting poi. Telemetry state and the recorder
|
|
630
|
+
// session survive (the controller re-injects the same getters); open
|
|
631
|
+
// connections drop and clients reconnect.
|
|
632
|
+
if (endpoint === '/admin/reload') {
|
|
633
|
+
if (!hasValidBearerToken(req.headers.authorization, inputToken)) {
|
|
634
|
+
drainRequest(req)
|
|
635
|
+
sendInputJson(res, 401, { error: 'A valid Bearer token is required.' })
|
|
636
|
+
return
|
|
637
|
+
}
|
|
638
|
+
if (req.method !== 'POST') {
|
|
639
|
+
drainRequest(req)
|
|
640
|
+
sendInputJson(res, 405, { error: '/admin/reload only accepts POST.' })
|
|
641
|
+
return
|
|
642
|
+
}
|
|
643
|
+
drainRequest(req)
|
|
644
|
+
if (typeof options.reloadModules !== 'function') {
|
|
645
|
+
sendInputJson(res, 501, { error: 'reloadModules is not wired by this controller build' })
|
|
646
|
+
return
|
|
647
|
+
}
|
|
648
|
+
try {
|
|
649
|
+
const outcome = await options.reloadModules()
|
|
650
|
+
// After a successful reload THIS bridge instance is stopped and the
|
|
651
|
+
// fresh one owns the port — report the outcome's port, not the
|
|
652
|
+
// stale local getPort().
|
|
653
|
+
sendInputJson(res, 200, {
|
|
654
|
+
ok: true,
|
|
655
|
+
reloaded: true,
|
|
656
|
+
port: (outcome && outcome.port) || getPort(),
|
|
657
|
+
...(outcome || {}),
|
|
658
|
+
})
|
|
659
|
+
} catch (error) {
|
|
660
|
+
sendInputJson(res, 500, { ok: false, error: error.message })
|
|
661
|
+
}
|
|
662
|
+
return
|
|
663
|
+
}
|
|
664
|
+
|
|
530
665
|
if (endpoint === '/screenshot') {
|
|
531
666
|
if (req.method !== 'GET') {
|
|
532
667
|
sendJson(
|
|
@@ -769,6 +904,15 @@ function createPoiDataBridge(options = {}) {
|
|
|
769
904
|
actualPort = 0
|
|
770
905
|
resolve()
|
|
771
906
|
})
|
|
907
|
+
// Keep-alive clients (the daemon holds persistent connections) never
|
|
908
|
+
// let close() finish on their own — reap them, but only after the
|
|
909
|
+
// aborted long-poll responses had a turn to flush (live 0912: an
|
|
910
|
+
// immediate reap raced the wait-abort writes into ECONNRESET).
|
|
911
|
+
setImmediate(() => {
|
|
912
|
+
if (typeof closingServer.closeAllConnections === 'function') {
|
|
913
|
+
closingServer.closeAllConnections()
|
|
914
|
+
}
|
|
915
|
+
})
|
|
772
916
|
})
|
|
773
917
|
}
|
|
774
918
|
|
|
@@ -16,8 +16,17 @@ const DEFAULT_MAX_SESSION_BYTES = 8 * 1024 * 1024 * 1024
|
|
|
16
16
|
const DEFAULT_MAX_TIMELINE_EVENTS = 20000
|
|
17
17
|
const DEFAULT_MAX_SESSION_DURATION_MS = 4 * 60 * 60 * 1000
|
|
18
18
|
const DEFAULT_MAX_SCREENSHOT_BYTES = 16 * 1024 * 1024
|
|
19
|
-
const DEFAULT_MAX_STORED_SESSIONS =
|
|
20
|
-
|
|
19
|
+
const DEFAULT_MAX_STORED_SESSIONS = envPositiveInteger(
|
|
20
|
+
'POI_MCP_RECORDING_MAX_SESSIONS',
|
|
21
|
+
200,
|
|
22
|
+
)
|
|
23
|
+
const DEFAULT_MAX_TOTAL_RECORDING_BYTES = envPositiveInteger(
|
|
24
|
+
'POI_MCP_RECORDING_MAX_TOTAL_BYTES',
|
|
25
|
+
20 * 1024 * 1024 * 1024,
|
|
26
|
+
)
|
|
27
|
+
// Byte-gate headroom: evict oldest sessions until the root is below this
|
|
28
|
+
// fraction of the cap so a fresh session cannot immediately re-trip the gate.
|
|
29
|
+
const RECORDING_BYTE_HEADROOM_FRACTION = 0.9
|
|
21
30
|
const DEFAULT_FINAL_MANIFEST_RESERVE_BYTES = 64 * 1024
|
|
22
31
|
const DEFAULT_CHECKPOINT_DELAYS_MS = Object.freeze([0, 250, 1000])
|
|
23
32
|
const DEFAULT_OUTPUT_ROOT = process.platform === 'win32'
|
|
@@ -713,9 +722,17 @@ function createPoiInteractionRecorder(options = {}) {
|
|
|
713
722
|
if (running) return getStatus()
|
|
714
723
|
try {
|
|
715
724
|
const recordingRoot = await inspectRecordingRoot(outputRoot)
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
725
|
+
// Retention: both the session-count and byte gates evict oldest-first
|
|
726
|
+
// instead of refusing to start; the byte gate leaves headroom for the
|
|
727
|
+
// session that is about to begin.
|
|
728
|
+
const evictCount = recordingRetentionEvictionCount({
|
|
729
|
+
sessions: recordingRoot.sessions,
|
|
730
|
+
totalBytes: recordingRoot.byteCount,
|
|
731
|
+
maxStoredSessions,
|
|
732
|
+
maxTotalRecordingBytes,
|
|
733
|
+
})
|
|
734
|
+
if (evictCount > 0) {
|
|
735
|
+
await pruneOldestSessions(recordingRoot.sessionDirectories, evictCount, logger)
|
|
719
736
|
}
|
|
720
737
|
const afterPrune = await inspectRecordingRoot(outputRoot)
|
|
721
738
|
if (afterPrune.byteCount >= maxTotalRecordingBytes) {
|
|
@@ -950,7 +967,7 @@ async function inspectRecordingRoot(outputRoot) {
|
|
|
950
967
|
entries = await fs.promises.readdir(outputRoot, { withFileTypes: true })
|
|
951
968
|
} catch (error) {
|
|
952
969
|
if (error && error.code === 'ENOENT') {
|
|
953
|
-
return { sessionCount: 0, byteCount: 0, sessionDirectories: [] }
|
|
970
|
+
return { sessionCount: 0, byteCount: 0, sessionDirectories: [], sessions: [] }
|
|
954
971
|
}
|
|
955
972
|
throw error
|
|
956
973
|
}
|
|
@@ -969,9 +986,12 @@ async function inspectRecordingRoot(outputRoot) {
|
|
|
969
986
|
if (!error || error.code !== 'ENOENT') throw error
|
|
970
987
|
}
|
|
971
988
|
}
|
|
972
|
-
|
|
989
|
+
// Attribute every byte to its top-level directory so retention can rank
|
|
990
|
+
// sessions by size as well as age.
|
|
991
|
+
const rootBytes = new Map(directories.map((directory) => [directory, 0]))
|
|
992
|
+
const stack = directories.map((directory) => ({ directory, root: directory }))
|
|
973
993
|
while (stack.length > 0) {
|
|
974
|
-
const directory = stack.pop()
|
|
994
|
+
const { directory, root } = stack.pop()
|
|
975
995
|
let children
|
|
976
996
|
try {
|
|
977
997
|
children = await fs.promises.readdir(directory, { withFileTypes: true })
|
|
@@ -983,10 +1003,12 @@ async function inspectRecordingRoot(outputRoot) {
|
|
|
983
1003
|
if (child.isSymbolicLink()) continue
|
|
984
1004
|
const childPath = path.join(directory, child.name)
|
|
985
1005
|
if (child.isDirectory()) {
|
|
986
|
-
stack.push(childPath)
|
|
1006
|
+
stack.push({ directory: childPath, root })
|
|
987
1007
|
} else if (child.isFile()) {
|
|
988
1008
|
try {
|
|
989
|
-
|
|
1009
|
+
const size = (await fs.promises.stat(childPath)).size
|
|
1010
|
+
byteCount += size
|
|
1011
|
+
rootBytes.set(root, (rootBytes.get(root) || 0) + size)
|
|
990
1012
|
} catch (error) {
|
|
991
1013
|
if (!error || error.code !== 'ENOENT') throw error
|
|
992
1014
|
}
|
|
@@ -996,13 +1018,41 @@ async function inspectRecordingRoot(outputRoot) {
|
|
|
996
1018
|
const sessionDirectories = directories.filter((directory) =>
|
|
997
1019
|
SESSION_DIRECTORY_PATTERN.test(path.basename(directory)),
|
|
998
1020
|
)
|
|
1021
|
+
const sessions = sessionDirectories.map((directory) => ({
|
|
1022
|
+
directory,
|
|
1023
|
+
bytes: rootBytes.get(directory) || 0,
|
|
1024
|
+
}))
|
|
999
1025
|
return {
|
|
1000
1026
|
sessionCount: sessionDirectories.length,
|
|
1001
1027
|
byteCount,
|
|
1002
1028
|
sessionDirectories,
|
|
1029
|
+
sessions,
|
|
1003
1030
|
}
|
|
1004
1031
|
}
|
|
1005
1032
|
|
|
1033
|
+
// Oldest-first eviction count for the two retention gates. `sessions` must be
|
|
1034
|
+
// sorted oldest-first (inspectRecordingRoot guarantees this via name sort).
|
|
1035
|
+
function recordingRetentionEvictionCount({
|
|
1036
|
+
sessions,
|
|
1037
|
+
totalBytes,
|
|
1038
|
+
maxStoredSessions,
|
|
1039
|
+
maxTotalRecordingBytes,
|
|
1040
|
+
}) {
|
|
1041
|
+
if (!Array.isArray(sessions)) return 0
|
|
1042
|
+
const countOverflow = sessions.length >= maxStoredSessions
|
|
1043
|
+
? sessions.length - maxStoredSessions + 1
|
|
1044
|
+
: 0
|
|
1045
|
+
const byteTarget = Math.floor(maxTotalRecordingBytes * RECORDING_BYTE_HEADROOM_FRACTION)
|
|
1046
|
+
let byteEvictions = 0
|
|
1047
|
+
let recovered = 0
|
|
1048
|
+
for (const session of sessions) {
|
|
1049
|
+
if (totalBytes - recovered <= byteTarget) break
|
|
1050
|
+
recovered += Number.isFinite(session.bytes) ? session.bytes : 0
|
|
1051
|
+
byteEvictions += 1
|
|
1052
|
+
}
|
|
1053
|
+
return Math.max(countOverflow, byteEvictions)
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1006
1056
|
async function pruneOldestSessions(sessionDirectories, count, logger) {
|
|
1007
1057
|
if (count <= 0) return
|
|
1008
1058
|
for (const directory of sessionDirectories.slice(0, count)) {
|
|
@@ -1550,6 +1600,14 @@ function positiveInteger(value, fallback, name) {
|
|
|
1550
1600
|
return selected
|
|
1551
1601
|
}
|
|
1552
1602
|
|
|
1603
|
+
function envPositiveInteger(name, fallback) {
|
|
1604
|
+
const raw = process.env[name]
|
|
1605
|
+
if (raw === undefined || raw === '') return fallback
|
|
1606
|
+
const parsed = Number(raw)
|
|
1607
|
+
if (!Number.isSafeInteger(parsed) || parsed <= 0) return fallback
|
|
1608
|
+
return parsed
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1553
1611
|
function positiveNumber(value, fallback, name) {
|
|
1554
1612
|
const selected = value == null ? fallback : value
|
|
1555
1613
|
if (!Number.isFinite(selected) || selected <= 0) {
|
|
@@ -1687,7 +1745,11 @@ module.exports = {
|
|
|
1687
1745
|
DEFAULT_MAX_STORED_SESSIONS,
|
|
1688
1746
|
DEFAULT_MAX_TOTAL_RECORDING_BYTES,
|
|
1689
1747
|
DEFAULT_OUTPUT_ROOT,
|
|
1748
|
+
RECORDING_BYTE_HEADROOM_FRACTION,
|
|
1690
1749
|
captureEquipmentUiStateFromWebContents: defaultCaptureEquipmentUiState,
|
|
1691
1750
|
captureWebStorageFromWebContents: defaultCaptureWebStorage,
|
|
1692
1751
|
createPoiInteractionRecorder,
|
|
1752
|
+
inspectRecordingRoot,
|
|
1753
|
+
pruneOldestSessions,
|
|
1754
|
+
recordingRetentionEvictionCount,
|
|
1693
1755
|
}
|
package/lib/settings.js
CHANGED
|
@@ -8,6 +8,12 @@ const DEFAULT_SETTINGS = Object.freeze({
|
|
|
8
8
|
inputEnabled: false,
|
|
9
9
|
recordingEnabled: false,
|
|
10
10
|
debugEvalEnabled: false,
|
|
11
|
+
// 2026-09-12 Phase C: when true, INPUT endpoints (/input, /input/lease*,
|
|
12
|
+
// /debug/evaluate) require a registered X-Writer-Token in addition to the
|
|
13
|
+
// static bearer. Observation mode (false) serves legacy static-bearer
|
|
14
|
+
// requests but counts them — the daemon registers tokens and sends them
|
|
15
|
+
// either way, so flipping this only rejects leftovers.
|
|
16
|
+
writerTokenEnforced: false,
|
|
11
17
|
})
|
|
12
18
|
|
|
13
19
|
const DEFAULT_SETTINGS_FILE = path.join(os.homedir(), '.poi-mcp', 'settings.json')
|
|
@@ -26,6 +32,9 @@ function normalizeSettings(input = {}) {
|
|
|
26
32
|
debugEvalEnabled: typeof input.debugEvalEnabled === 'boolean'
|
|
27
33
|
? input.debugEvalEnabled
|
|
28
34
|
: DEFAULT_SETTINGS.debugEvalEnabled,
|
|
35
|
+
writerTokenEnforced: typeof input.writerTokenEnforced === 'boolean'
|
|
36
|
+
? input.writerTokenEnforced
|
|
37
|
+
: DEFAULT_SETTINGS.writerTokenEnforced,
|
|
29
38
|
}
|
|
30
39
|
}
|
|
31
40
|
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
const crypto = require('crypto')
|
|
2
|
+
|
|
3
|
+
const TOKEN_PATTERN = /^[a-f0-9]{32,128}$/
|
|
4
|
+
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Daemon-delegated writer tokens (2026-09-12 single-writer plan, Phase C).
|
|
8
|
+
*
|
|
9
|
+
* The static input token authorizes whoever can read the file — forever. A
|
|
10
|
+
* zombie from an earlier session therefore holds a valid credential forever.
|
|
11
|
+
* This registry replaces that for INPUT endpoints: the daemon mints short
|
|
12
|
+
* lived tokens (a generation token for itself, one per spawned job), pushes
|
|
13
|
+
* them here over the admin endpoint, and revokes job tokens when the reaper
|
|
14
|
+
* collects the job. A process that missed a rotation is rejected with a
|
|
15
|
+
* readable reason instead of silently competing for clicks.
|
|
16
|
+
*
|
|
17
|
+
* Module-level singleton: bridge restarts (settings apply, hot reload) must
|
|
18
|
+
* not silently drop the registered set — and when a hot reload DOES purge
|
|
19
|
+
* this module, the daemon's periodic re-registration self-heals within one
|
|
20
|
+
* idle cycle. The threat model is accidents, not adversaries: holding a
|
|
21
|
+
* current token is a sufficient proxy for "delegated by the current daemon
|
|
22
|
+
* generation", so there is deliberately no per-pid challenge/response.
|
|
23
|
+
*/
|
|
24
|
+
function createWriterTokenRegistry() {
|
|
25
|
+
const entries = new Map() // token -> { label, registeredAt, expiresAt }
|
|
26
|
+
let legacyAuthCount = 0
|
|
27
|
+
let lastLegacyAuthAt = null
|
|
28
|
+
|
|
29
|
+
function sweep(now = Date.now()) {
|
|
30
|
+
for (const [token, entry] of entries) {
|
|
31
|
+
if (entry.expiresAt <= now) entries.delete(token)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
register(payload, now = Date.now()) {
|
|
37
|
+
const mode = payload && (payload.mode === 'add' || payload.mode === 'remove') ? payload.mode : 'replace'
|
|
38
|
+
const list = payload && Array.isArray(payload.tokens) ? payload.tokens : []
|
|
39
|
+
if (mode === 'replace') entries.clear()
|
|
40
|
+
let accepted = 0
|
|
41
|
+
let rejected = []
|
|
42
|
+
for (const item of list) {
|
|
43
|
+
const token = item && typeof item.token === 'string' ? item.token : null
|
|
44
|
+
if (!TOKEN_PATTERN.test(token || '')) {
|
|
45
|
+
rejected.push('invalid token shape')
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
const ttlMs = Number.isFinite(item.ttlMs) && item.ttlMs > 0 && item.ttlMs <= 7 * DEFAULT_TTL_MS
|
|
49
|
+
? Math.trunc(item.ttlMs)
|
|
50
|
+
: DEFAULT_TTL_MS
|
|
51
|
+
entries.set(token, {
|
|
52
|
+
label: typeof item.label === 'string' && item.label !== '' ? item.label.slice(0, 128) : 'unlabeled',
|
|
53
|
+
registeredAt: now,
|
|
54
|
+
expiresAt: now + ttlMs,
|
|
55
|
+
})
|
|
56
|
+
accepted += 1
|
|
57
|
+
}
|
|
58
|
+
if (mode === 'remove') {
|
|
59
|
+
for (const item of list) {
|
|
60
|
+
if (item && typeof item.label === 'string') {
|
|
61
|
+
for (const [token, entry] of entries) {
|
|
62
|
+
if (entry.label === item.label) entries.delete(token)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
sweep(now)
|
|
68
|
+
return { accepted, rejected: rejected.length }
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
validate(token, now = Date.now()) {
|
|
72
|
+
if (typeof token !== 'string' || !TOKEN_PATTERN.test(token)) return false
|
|
73
|
+
const entry = entries.get(token)
|
|
74
|
+
if (!entry) return false
|
|
75
|
+
if (entry.expiresAt <= now) {
|
|
76
|
+
entries.delete(token)
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
return true
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
noteLegacyAuth(now = Date.now()) {
|
|
83
|
+
legacyAuthCount += 1
|
|
84
|
+
lastLegacyAuthAt = new Date(now).toISOString()
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
status(now = Date.now()) {
|
|
88
|
+
sweep(now)
|
|
89
|
+
return {
|
|
90
|
+
registered: entries.size,
|
|
91
|
+
tokens: [...entries.values()].map((entry) => ({
|
|
92
|
+
label: entry.label,
|
|
93
|
+
registeredAt: new Date(entry.registeredAt).toISOString(),
|
|
94
|
+
expiresAt: new Date(entry.expiresAt).toISOString(),
|
|
95
|
+
})),
|
|
96
|
+
legacyAuthCount,
|
|
97
|
+
lastLegacyAuthAt,
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let singleton = null
|
|
104
|
+
|
|
105
|
+
function getWriterTokenRegistry() {
|
|
106
|
+
if (!singleton) singleton = createWriterTokenRegistry()
|
|
107
|
+
return singleton
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function mintWriterToken() {
|
|
111
|
+
return crypto.randomBytes(32).toString('hex')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function hasValidWriterToken(headerValue, registry, now = Date.now()) {
|
|
115
|
+
if (typeof headerValue !== 'string' || headerValue === '') return false
|
|
116
|
+
// In-process registry lookup: the timing-safe compare discipline applies to
|
|
117
|
+
// the STATIC bearer token (a file secret); membership here is an in-memory
|
|
118
|
+
// Map hit against an unregistered attacker-chosen value with no secret to
|
|
119
|
+
// leak.
|
|
120
|
+
return registry.validate(headerValue, now)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = {
|
|
124
|
+
TOKEN_PATTERN,
|
|
125
|
+
DEFAULT_TTL_MS,
|
|
126
|
+
createWriterTokenRegistry,
|
|
127
|
+
getWriterTokenRegistry,
|
|
128
|
+
mintWriterToken,
|
|
129
|
+
hasValidWriterToken,
|
|
130
|
+
}
|