poi-plugin-mcp 0.2.25 → 0.2.27
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/pixi-hit-ledger.js +259 -0
- package/lib/poi-http-bridge.js +188 -0
- package/lib/settings.js +9 -0
- package/lib/writer-tokens.js +133 -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'
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Per-click PIXI hit ledger (0913 admiral ruling: every dispatched click must
|
|
5
|
+
* name the control it landed on).
|
|
6
|
+
*
|
|
7
|
+
* Evidence contract — this ledger is a WITNESS, never a judge:
|
|
8
|
+
* - The snapshot is read immediately BEFORE the synthetic input event is
|
|
9
|
+
* queued, so a tree mutation inside the dispatch window can still make
|
|
10
|
+
* it stale. The authoritative "did the click take effect" signal stays
|
|
11
|
+
* the matching API event plus the final-state audit.
|
|
12
|
+
* - `eventHit` comes from the game's own hit resolution (EventSystem /
|
|
13
|
+
* InteractionManager hitTest) when available; `containment` is a
|
|
14
|
+
* world-AABB approximation whose stack makes occlusion visible.
|
|
15
|
+
* - Failure is fail-soft: an unavailable probe records why and never
|
|
16
|
+
* blocks the dispatch.
|
|
17
|
+
*/
|
|
18
|
+
const fs = require('node:fs')
|
|
19
|
+
const path = require('node:path')
|
|
20
|
+
|
|
21
|
+
const MAX_STACK = 8
|
|
22
|
+
const MAX_VISIT = 4000
|
|
23
|
+
const MAX_DEPTH = 40
|
|
24
|
+
// Wall-clock bound for the whole snapshot. The page-side script has its own
|
|
25
|
+
// 1500ms timeout, but a throttled renderer can stall the IPC channel until
|
|
26
|
+
// the runtime's 10s inspection bound — a witness must never gate dispatch.
|
|
27
|
+
const CAPTURE_WALL_MS = 1800
|
|
28
|
+
|
|
29
|
+
function buildHitTestScript(x, y) {
|
|
30
|
+
return `(() => {
|
|
31
|
+
const px = ${JSON.stringify(x)}, py = ${JSON.stringify(y)};
|
|
32
|
+
const shared = globalThis.PIXI && globalThis.PIXI.ticker && globalThis.PIXI.ticker.shared;
|
|
33
|
+
const head = shared && shared._head;
|
|
34
|
+
const renderers = [];
|
|
35
|
+
let node = head ? head.next : null;
|
|
36
|
+
let guard = 0;
|
|
37
|
+
while (node && guard < 64) {
|
|
38
|
+
if (node.context && node.context.renderer) renderers.push(node.context.renderer);
|
|
39
|
+
node = node.next;
|
|
40
|
+
guard += 1;
|
|
41
|
+
}
|
|
42
|
+
const renderer = renderers.find(
|
|
43
|
+
(r) => r && r.view && r.view.isConnected === true && r._lastObjectRendered,
|
|
44
|
+
);
|
|
45
|
+
if (!renderer) return { available: false, reason: 'stale_renderer' };
|
|
46
|
+
const describe = (o) => {
|
|
47
|
+
const record = {};
|
|
48
|
+
try { record.cls = (o.constructor && o.constructor.name) || null; } catch (_) { record.cls = null; }
|
|
49
|
+
try { record.name = typeof o.name === 'string' && o.name ? o.name : null; } catch (_) { record.name = null; }
|
|
50
|
+
try {
|
|
51
|
+
const tex = o.texture;
|
|
52
|
+
record.texture = tex && typeof tex.url === 'string' ? tex.url : null;
|
|
53
|
+
// Atlas forensics (0913 admiral request): the sprite's frame inside its
|
|
54
|
+
// source atlas sheet + the sheet URL let a hit record be mapped back to
|
|
55
|
+
// the exact rectangle of the ORIGINAL art for offline cropping/filter.
|
|
56
|
+
try {
|
|
57
|
+
const base = tex && tex.baseTexture;
|
|
58
|
+
const frame = tex && tex.frame;
|
|
59
|
+
record.atlas = {
|
|
60
|
+
url: base && typeof base.imageUrl === 'string' ? base.imageUrl : null,
|
|
61
|
+
frame: frame
|
|
62
|
+
? [
|
|
63
|
+
Math.round(frame.x), Math.round(frame.y),
|
|
64
|
+
Math.round(frame.width), Math.round(frame.height),
|
|
65
|
+
]
|
|
66
|
+
: null,
|
|
67
|
+
rotate: tex && typeof tex.rotate === 'number' ? tex.rotate : 0,
|
|
68
|
+
};
|
|
69
|
+
} catch (_) { record.atlas = null; }
|
|
70
|
+
} catch (_) { record.texture = null; record.atlas = null; }
|
|
71
|
+
try {
|
|
72
|
+
const b = o.getBounds ? o.getBounds() : null;
|
|
73
|
+
record.bounds = b
|
|
74
|
+
? [Math.round(b.x), Math.round(b.y), Math.round(b.width), Math.round(b.height)]
|
|
75
|
+
: null;
|
|
76
|
+
} catch (_) { record.bounds = null; }
|
|
77
|
+
try {
|
|
78
|
+
record.interactive = o.eventMode
|
|
79
|
+
? o.eventMode !== 'auto' && o.eventMode !== 'none' && o.eventMode !== 'passive'
|
|
80
|
+
: o.interactive === true;
|
|
81
|
+
} catch (_) { record.interactive = null; }
|
|
82
|
+
try { record.visible = o.visible !== false; } catch (_) { record.visible = null; }
|
|
83
|
+
try { record.alpha = typeof o.alpha === 'number' ? Math.round(o.alpha * 100) / 100 : null; } catch (_) { record.alpha = null; }
|
|
84
|
+
return record;
|
|
85
|
+
};
|
|
86
|
+
const chain = (o) => {
|
|
87
|
+
const path = [];
|
|
88
|
+
let cur = o;
|
|
89
|
+
let steps = 0;
|
|
90
|
+
while (cur && steps < 6) {
|
|
91
|
+
path.push((cur.constructor && cur.constructor.name) || 'unknown');
|
|
92
|
+
cur = cur.parent;
|
|
93
|
+
steps += 1;
|
|
94
|
+
}
|
|
95
|
+
return path;
|
|
96
|
+
};
|
|
97
|
+
// 1) The game's own hit resolution — interactive targets only.
|
|
98
|
+
let eventHit = null;
|
|
99
|
+
try {
|
|
100
|
+
const point = { x: px, y: py };
|
|
101
|
+
const events = renderer.events
|
|
102
|
+
|| (renderer.plugins && renderer.plugins.event)
|
|
103
|
+
|| (renderer.plugins && renderer.plugins.interaction);
|
|
104
|
+
if (events && typeof events.hitTest === 'function') {
|
|
105
|
+
const target = events.hitTest(point);
|
|
106
|
+
if (target) {
|
|
107
|
+
eventHit = describe(target);
|
|
108
|
+
eventHit.path = chain(target);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch (_) { eventHit = null; }
|
|
112
|
+
// 2) World-AABB containment stack — approximate paint order (later entry
|
|
113
|
+
// ≈ on top within its branch); makes occlusion (family pages over stale
|
|
114
|
+
// choice pairs) visible in the record itself.
|
|
115
|
+
const stack = [];
|
|
116
|
+
let visited = 0;
|
|
117
|
+
const walk = (o, depth) => {
|
|
118
|
+
if (!o || depth > ${MAX_DEPTH} || visited > ${MAX_VISIT} || stack.length >= ${MAX_STACK}) return;
|
|
119
|
+
visited += 1;
|
|
120
|
+
try {
|
|
121
|
+
if (o.visible !== false && o.renderable !== false) {
|
|
122
|
+
const b = o.getBounds ? o.getBounds() : null;
|
|
123
|
+
if (
|
|
124
|
+
b && px >= b.x && px <= b.x + b.width && py >= b.y && py <= b.y + b.height
|
|
125
|
+
) {
|
|
126
|
+
stack.push(describe(o));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
} catch (_) { /* a node that cannot be described is skipped, not fatal */ }
|
|
130
|
+
if (stack.length >= ${MAX_STACK}) return;
|
|
131
|
+
const kids = o.children;
|
|
132
|
+
if (Array.isArray(kids)) {
|
|
133
|
+
for (let i = 0; i < kids.length; i += 1) walk(kids[i], depth + 1);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
try { walk(renderer._lastObjectRendered, 0); } catch (_) { /* partial stack is still evidence */ }
|
|
137
|
+
return {
|
|
138
|
+
available: true,
|
|
139
|
+
point: { x: px, y: py },
|
|
140
|
+
eventHit,
|
|
141
|
+
containmentCount: stack.length,
|
|
142
|
+
topmost: stack.length > 0 ? stack[stack.length - 1] : null,
|
|
143
|
+
stack,
|
|
144
|
+
};
|
|
145
|
+
})()`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function createPixiHitLedger(options = {}) {
|
|
149
|
+
const evaluate = options.evaluate
|
|
150
|
+
const listFrames = options.listFrames
|
|
151
|
+
const logger = options.logger || console
|
|
152
|
+
const now = options.now || (() => new Date())
|
|
153
|
+
const captureWallMs = options.captureWallMs || CAPTURE_WALL_MS
|
|
154
|
+
const appendFile = options.appendFile
|
|
155
|
+
|| ((file, line) => fs.promises.appendFile(file, line, 'utf8'))
|
|
156
|
+
const ledgerFile = options.ledgerFile || null
|
|
157
|
+
let lastFrameId = null
|
|
158
|
+
let lastFrameLookupAt = 0
|
|
159
|
+
let ledgerWrite = Promise.resolve()
|
|
160
|
+
|
|
161
|
+
if (typeof evaluate !== 'function' || typeof listFrames !== 'function') {
|
|
162
|
+
return Object.freeze({
|
|
163
|
+
async capture() { return Object.freeze({ available: false, reason: 'runtime_unavailable' }) },
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function resolveGameFrameId() {
|
|
168
|
+
const at = Date.now()
|
|
169
|
+
if (lastFrameId && at - lastFrameLookupAt < 2000) return lastFrameId
|
|
170
|
+
const frames = await listFrames()
|
|
171
|
+
const game = frames.find((f) => /kcs2/i.test(f.url || '')) || frames[0]
|
|
172
|
+
if (!game) return null
|
|
173
|
+
lastFrameId = game.id
|
|
174
|
+
lastFrameLookupAt = at
|
|
175
|
+
return game.id
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function appendToLedger(line) {
|
|
179
|
+
if (!ledgerFile) return
|
|
180
|
+
ledgerWrite = ledgerWrite
|
|
181
|
+
.then(() => appendFile(ledgerFile, line))
|
|
182
|
+
.catch((error) => {
|
|
183
|
+
logger.error(`[poi-plugin-mcp] hit ledger append failed: ${error.message}`)
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Snapshot what the live PIXI tree shows at the operation's canonical
|
|
189
|
+
* point. Never throws: an unavailable probe is a recorded outcome.
|
|
190
|
+
*/
|
|
191
|
+
async function capture(operation, context = {}) {
|
|
192
|
+
const capturedAt = now().toISOString()
|
|
193
|
+
const isClick = operation.operation === 'click'
|
|
194
|
+
const point = isClick
|
|
195
|
+
? { x: operation.x, y: operation.y }
|
|
196
|
+
: operation.operation === 'drag'
|
|
197
|
+
? { x: operation.fromX, y: operation.fromY }
|
|
198
|
+
: null
|
|
199
|
+
if (point === null) return Object.freeze({ available: false, reason: 'not_pointer' })
|
|
200
|
+
const record = {
|
|
201
|
+
at: capturedAt,
|
|
202
|
+
operation: operation.operation,
|
|
203
|
+
point,
|
|
204
|
+
...(context.leaseId === undefined ? {} : { leaseId: context.leaseId }),
|
|
205
|
+
...(context.ownerSessionId === undefined ? {} : { ownerSessionId: context.ownerSessionId }),
|
|
206
|
+
...(context.runId === undefined ? {} : { runId: context.runId }),
|
|
207
|
+
...(context.action === undefined ? {} : { action: context.action }),
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
const frameId = await resolveGameFrameId()
|
|
211
|
+
if (frameId === null) {
|
|
212
|
+
return Object.freeze({ ...record, available: false, reason: 'game_frame_not_found' })
|
|
213
|
+
}
|
|
214
|
+
const response = await Promise.race([
|
|
215
|
+
evaluate({
|
|
216
|
+
frameId,
|
|
217
|
+
script: buildHitTestScript(point.x, point.y),
|
|
218
|
+
timeoutMs: 1500,
|
|
219
|
+
}),
|
|
220
|
+
new Promise((_, reject) => {
|
|
221
|
+
setTimeout(
|
|
222
|
+
() => reject(new Error('capture_wall_timeout')),
|
|
223
|
+
captureWallMs,
|
|
224
|
+
)
|
|
225
|
+
}),
|
|
226
|
+
])
|
|
227
|
+
const value = response && response.value
|
|
228
|
+
// Every outcome is appended — a witness with gaps is worse than a
|
|
229
|
+
// witness with anomalies (a probe that ran but returned nothing
|
|
230
|
+
// non-object is itself diagnostic evidence).
|
|
231
|
+
const hit = value === null || typeof value !== 'object'
|
|
232
|
+
? { ...record, available: false, reason: 'probe_empty' }
|
|
233
|
+
: { ...record, ...value }
|
|
234
|
+
appendToLedger(`${JSON.stringify(hit)}\n`)
|
|
235
|
+
return Object.freeze(hit)
|
|
236
|
+
} catch (error) {
|
|
237
|
+
const hit = { ...record, available: false, reason: `probe_failed:${error.message}` }
|
|
238
|
+
appendToLedger(`${JSON.stringify(hit)}\n`)
|
|
239
|
+
return Object.freeze(hit)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return Object.freeze({ capture })
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function defaultLedgerFile(portFile) {
|
|
247
|
+
if (!portFile || typeof portFile !== 'string') return null
|
|
248
|
+
try {
|
|
249
|
+
return path.join(path.dirname(portFile), 'hit-ledger.jsonl')
|
|
250
|
+
} catch (_) {
|
|
251
|
+
return null
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
module.exports = {
|
|
256
|
+
buildHitTestScript,
|
|
257
|
+
createPixiHitLedger,
|
|
258
|
+
defaultLedgerFile,
|
|
259
|
+
}
|
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')
|
|
@@ -19,6 +20,10 @@ const {
|
|
|
19
20
|
speedFromRaw,
|
|
20
21
|
speedMeaning,
|
|
21
22
|
} = require('./fleet-metrics')
|
|
23
|
+
const {
|
|
24
|
+
createPixiHitLedger,
|
|
25
|
+
defaultLedgerFile,
|
|
26
|
+
} = require('./pixi-hit-ledger')
|
|
22
27
|
|
|
23
28
|
const DEFAULT_PORT = 17777
|
|
24
29
|
const DEFAULT_PORT_FILE = path.join(os.homedir(), '.poi-mcp', 'port')
|
|
@@ -107,6 +112,14 @@ function createPoiDataBridge(options = {}) {
|
|
|
107
112
|
inputEnabled ? loadOrCreateInputToken(options.inputTokenFile) : null
|
|
108
113
|
)
|
|
109
114
|
const inputLease = options.inputLease || createPoiInputLease(options.inputLeaseOptions)
|
|
115
|
+
// 2026-09-12 Phase C: daemon-delegated writer tokens gate the INPUT
|
|
116
|
+
// surface (clicks, lease takes, debug-eval). Module-level singleton so
|
|
117
|
+
// settings-triggered bridge re-creation keeps the registered set; a hot
|
|
118
|
+
// reload purges the module, and the daemon's periodic re-registration
|
|
119
|
+
// self-heals within one idle cycle.
|
|
120
|
+
const writerTokens = options.writerTokens || getWriterTokenRegistry()
|
|
121
|
+
const isWriterTokenEnforced = options.isWriterTokenEnforced ||
|
|
122
|
+
(() => options.writerTokenEnforced === true)
|
|
110
123
|
let captureScreenshot = options.captureScreenshot || null
|
|
111
124
|
let performInput = options.performInput || null
|
|
112
125
|
let dataQuery = options.dataQuery || null
|
|
@@ -116,6 +129,33 @@ function createPoiDataBridge(options = {}) {
|
|
|
116
129
|
let inputPending = Promise.resolve()
|
|
117
130
|
const pendingActionEventWaits = new Set()
|
|
118
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Writer-token gate for input-capable endpoints. Callers have already
|
|
134
|
+
* validated the static bearer (transport auth). Three outcomes:
|
|
135
|
+
* registered token -> pass; missing/rejected token + enforcement -> 403
|
|
136
|
+
* with a reason a stale process can act on; missing token in observation
|
|
137
|
+
* mode -> pass and count (legacyAuth), which is the bridge-side
|
|
138
|
+
* foreign-writer signal.
|
|
139
|
+
*/
|
|
140
|
+
function authorizeWriterInput(req) {
|
|
141
|
+
const headerValue = req.headers['x-writer-token']
|
|
142
|
+
if (hasValidWriterToken(headerValue, writerTokens)) {
|
|
143
|
+
return { ok: true }
|
|
144
|
+
}
|
|
145
|
+
if (isWriterTokenEnforced()) {
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
status: 403,
|
|
149
|
+
error:
|
|
150
|
+
'Writer token rejected: no registered X-Writer-Token matches. ' +
|
|
151
|
+
'Stale process from a previous daemon generation? Re-submit the work ' +
|
|
152
|
+
'through the daemon (kc daemon submit) or renew the token.',
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
writerTokens.noteLegacyAuth(Date.now(), `${req.method} ${req.url}`)
|
|
156
|
+
return { ok: true, legacy: true }
|
|
157
|
+
}
|
|
158
|
+
|
|
119
159
|
function readStore() {
|
|
120
160
|
const store = getStore()
|
|
121
161
|
if (!store || !store.info) {
|
|
@@ -160,6 +200,19 @@ function createPoiDataBridge(options = {}) {
|
|
|
160
200
|
return dataQuery
|
|
161
201
|
}
|
|
162
202
|
|
|
203
|
+
let hitLedger = null
|
|
204
|
+
function currentHitLedger() {
|
|
205
|
+
if (!hitLedger) {
|
|
206
|
+
hitLedger = createPixiHitLedger({
|
|
207
|
+
evaluate: (request) => currentDataQuery().runtime.evaluate(request),
|
|
208
|
+
listFrames: () => currentDataQuery().runtime.listFrames(),
|
|
209
|
+
ledgerFile: defaultLedgerFile(portFile),
|
|
210
|
+
logger,
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
return hitLedger
|
|
214
|
+
}
|
|
215
|
+
|
|
163
216
|
async function handleDataRequest(req, res, endpoint) {
|
|
164
217
|
if (req.method !== 'POST') {
|
|
165
218
|
drainRequest(req)
|
|
@@ -176,6 +229,17 @@ function createPoiDataBridge(options = {}) {
|
|
|
176
229
|
)
|
|
177
230
|
return
|
|
178
231
|
}
|
|
232
|
+
// /debug/evaluate executes arbitrary JS in the game WebView — it is a
|
|
233
|
+
// write-capable channel and takes the writer-token gate. /query is a
|
|
234
|
+
// read and stays static-bearer (diagnostics keep working).
|
|
235
|
+
if (endpoint === '/debug/evaluate') {
|
|
236
|
+
const writer = authorizeWriterInput(req)
|
|
237
|
+
if (!writer.ok) {
|
|
238
|
+
drainRequest(req)
|
|
239
|
+
sendInputJson(res, writer.status, { error: writer.error })
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
}
|
|
179
243
|
try {
|
|
180
244
|
const body = await readRequestBody(
|
|
181
245
|
req,
|
|
@@ -218,10 +282,28 @@ function createPoiDataBridge(options = {}) {
|
|
|
218
282
|
if (!performInput) {
|
|
219
283
|
performInput = createPoiInputProvider({ getStore })
|
|
220
284
|
}
|
|
285
|
+
// 0913 per-click hit ledger (admiral ruling): snapshot what the live
|
|
286
|
+
// PIXI tree shows at the pointer before dispatch, gated by the same
|
|
287
|
+
// debugEvalEnabled setting that owns WebView evaluation. Evidence
|
|
288
|
+
// only — the response field and ~/.poi-mcp/hit-ledger.jsonl never
|
|
289
|
+
// gate the dispatch itself (capture is fail-soft and wall-bounded).
|
|
290
|
+
let hit
|
|
291
|
+
if (
|
|
292
|
+
debugEvalEnabled === true &&
|
|
293
|
+
(operation.operation === 'click' || operation.operation === 'drag')
|
|
294
|
+
) {
|
|
295
|
+
hit = await currentHitLedger().capture(operation, {
|
|
296
|
+
leaseId: claim.leaseId,
|
|
297
|
+
ownerSessionId: claim.ownerSessionId,
|
|
298
|
+
runId: claim.runId,
|
|
299
|
+
action: claim.action,
|
|
300
|
+
})
|
|
301
|
+
}
|
|
221
302
|
const operationName = await performInput(operation)
|
|
222
303
|
return {
|
|
223
304
|
ok: true,
|
|
224
305
|
operation: operationName,
|
|
306
|
+
...(hit === undefined ? {} : { hit }),
|
|
225
307
|
sequence: claim.sequence,
|
|
226
308
|
leaseId: claim.leaseId,
|
|
227
309
|
ownerSessionId: claim.ownerSessionId,
|
|
@@ -261,6 +343,12 @@ function createPoiDataBridge(options = {}) {
|
|
|
261
343
|
sendInputJson(res, 403, { error: 'WebView input is disabled.' })
|
|
262
344
|
return
|
|
263
345
|
}
|
|
346
|
+
const writer = authorizeWriterInput(req)
|
|
347
|
+
if (!writer.ok) {
|
|
348
|
+
drainRequest(req)
|
|
349
|
+
sendInputJson(res, writer.status, { error: writer.error })
|
|
350
|
+
return
|
|
351
|
+
}
|
|
264
352
|
|
|
265
353
|
try {
|
|
266
354
|
const body = await readRequestBody(
|
|
@@ -295,6 +383,24 @@ function createPoiDataBridge(options = {}) {
|
|
|
295
383
|
)
|
|
296
384
|
return
|
|
297
385
|
}
|
|
386
|
+
// Lease MUTATIONS (acquire/renew/release/revoke) are input-adjacent —
|
|
387
|
+
// they gate who may click — so the writer-token applies. The plain
|
|
388
|
+
// GET /input/lease status read stays static-bearer (live 0912: the
|
|
389
|
+
// dashboard's takeover-button poll flooded the legacy counter and would
|
|
390
|
+
// 403 under enforcement; reads must not take the writer gate).
|
|
391
|
+
const leaseMutation =
|
|
392
|
+
endpoint === '/input/lease/acquire' ||
|
|
393
|
+
endpoint === '/input/lease/renew' ||
|
|
394
|
+
endpoint === '/input/lease/release' ||
|
|
395
|
+
endpoint === '/input/lease/revoke'
|
|
396
|
+
if (leaseMutation) {
|
|
397
|
+
const leaseWriter = authorizeWriterInput(req)
|
|
398
|
+
if (!leaseWriter.ok) {
|
|
399
|
+
drainRequest(req)
|
|
400
|
+
sendInputJson(res, leaseWriter.status, { error: leaseWriter.error })
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
}
|
|
298
404
|
if (endpoint === '/input/lease') {
|
|
299
405
|
if (req.method !== 'GET') {
|
|
300
406
|
drainRequest(req)
|
|
@@ -527,6 +633,79 @@ function createPoiDataBridge(options = {}) {
|
|
|
527
633
|
return
|
|
528
634
|
}
|
|
529
635
|
|
|
636
|
+
// 2026-09-12 Phase C: writer-token administration. The static bearer is
|
|
637
|
+
// the ADMIN credential here — exactly one caller (the daemon) uses it
|
|
638
|
+
// to delegate; input endpoints no longer accept it as a writer.
|
|
639
|
+
if (endpoint === '/writer-tokens') {
|
|
640
|
+
if (!hasValidBearerToken(req.headers.authorization, inputToken)) {
|
|
641
|
+
drainRequest(req)
|
|
642
|
+
sendInputJson(res, 401, { error: 'A valid Bearer token is required.' })
|
|
643
|
+
return
|
|
644
|
+
}
|
|
645
|
+
if (req.method === 'GET') {
|
|
646
|
+
sendInputJson(res, 200, {
|
|
647
|
+
enforced: isWriterTokenEnforced(),
|
|
648
|
+
...writerTokens.status(),
|
|
649
|
+
})
|
|
650
|
+
return
|
|
651
|
+
}
|
|
652
|
+
if (req.method !== 'PUT') {
|
|
653
|
+
drainRequest(req)
|
|
654
|
+
sendInputJson(res, 405, { error: '/writer-tokens accepts GET or PUT.' })
|
|
655
|
+
return
|
|
656
|
+
}
|
|
657
|
+
try {
|
|
658
|
+
const body = await readRequestBody(
|
|
659
|
+
req,
|
|
660
|
+
MAX_QUERY_BODY_BYTES,
|
|
661
|
+
'Writer token request body exceeds 64KB.',
|
|
662
|
+
)
|
|
663
|
+
const payload = JSON.parse(body || '{}')
|
|
664
|
+
const outcome = writerTokens.register(payload)
|
|
665
|
+
sendInputJson(res, 200, { ok: true, ...outcome, ...writerTokens.status() })
|
|
666
|
+
} catch (error) {
|
|
667
|
+
sendInputJson(res, 400, { error: error.message })
|
|
668
|
+
}
|
|
669
|
+
return
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// 2026-09-12 hot reload: rebuild the HTTP/input layer from the on-disk
|
|
673
|
+
// lib/ code without restarting poi. Telemetry state and the recorder
|
|
674
|
+
// session survive (the controller re-injects the same getters); open
|
|
675
|
+
// connections drop and clients reconnect.
|
|
676
|
+
if (endpoint === '/admin/reload') {
|
|
677
|
+
if (!hasValidBearerToken(req.headers.authorization, inputToken)) {
|
|
678
|
+
drainRequest(req)
|
|
679
|
+
sendInputJson(res, 401, { error: 'A valid Bearer token is required.' })
|
|
680
|
+
return
|
|
681
|
+
}
|
|
682
|
+
if (req.method !== 'POST') {
|
|
683
|
+
drainRequest(req)
|
|
684
|
+
sendInputJson(res, 405, { error: '/admin/reload only accepts POST.' })
|
|
685
|
+
return
|
|
686
|
+
}
|
|
687
|
+
drainRequest(req)
|
|
688
|
+
if (typeof options.reloadModules !== 'function') {
|
|
689
|
+
sendInputJson(res, 501, { error: 'reloadModules is not wired by this controller build' })
|
|
690
|
+
return
|
|
691
|
+
}
|
|
692
|
+
try {
|
|
693
|
+
const outcome = await options.reloadModules()
|
|
694
|
+
// After a successful reload THIS bridge instance is stopped and the
|
|
695
|
+
// fresh one owns the port — report the outcome's port, not the
|
|
696
|
+
// stale local getPort().
|
|
697
|
+
sendInputJson(res, 200, {
|
|
698
|
+
ok: true,
|
|
699
|
+
reloaded: true,
|
|
700
|
+
port: (outcome && outcome.port) || getPort(),
|
|
701
|
+
...(outcome || {}),
|
|
702
|
+
})
|
|
703
|
+
} catch (error) {
|
|
704
|
+
sendInputJson(res, 500, { ok: false, error: error.message })
|
|
705
|
+
}
|
|
706
|
+
return
|
|
707
|
+
}
|
|
708
|
+
|
|
530
709
|
if (endpoint === '/screenshot') {
|
|
531
710
|
if (req.method !== 'GET') {
|
|
532
711
|
sendJson(
|
|
@@ -769,6 +948,15 @@ function createPoiDataBridge(options = {}) {
|
|
|
769
948
|
actualPort = 0
|
|
770
949
|
resolve()
|
|
771
950
|
})
|
|
951
|
+
// Keep-alive clients (the daemon holds persistent connections) never
|
|
952
|
+
// let close() finish on their own — reap them, but only after the
|
|
953
|
+
// aborted long-poll responses had a turn to flush (live 0912: an
|
|
954
|
+
// immediate reap raced the wait-abort writes into ECONNRESET).
|
|
955
|
+
setImmediate(() => {
|
|
956
|
+
if (typeof closingServer.closeAllConnections === 'function') {
|
|
957
|
+
closingServer.closeAllConnections()
|
|
958
|
+
}
|
|
959
|
+
})
|
|
772
960
|
})
|
|
773
961
|
}
|
|
774
962
|
|
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,133 @@
|
|
|
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
|
+
let lastLegacyAuthDetail = null
|
|
29
|
+
|
|
30
|
+
function sweep(now = Date.now()) {
|
|
31
|
+
for (const [token, entry] of entries) {
|
|
32
|
+
if (entry.expiresAt <= now) entries.delete(token)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
register(payload, now = Date.now()) {
|
|
38
|
+
const mode = payload && (payload.mode === 'add' || payload.mode === 'remove') ? payload.mode : 'replace'
|
|
39
|
+
const list = payload && Array.isArray(payload.tokens) ? payload.tokens : []
|
|
40
|
+
if (mode === 'replace') entries.clear()
|
|
41
|
+
let accepted = 0
|
|
42
|
+
let rejected = []
|
|
43
|
+
for (const item of list) {
|
|
44
|
+
const token = item && typeof item.token === 'string' ? item.token : null
|
|
45
|
+
if (!TOKEN_PATTERN.test(token || '')) {
|
|
46
|
+
rejected.push('invalid token shape')
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
const ttlMs = Number.isFinite(item.ttlMs) && item.ttlMs > 0 && item.ttlMs <= 7 * DEFAULT_TTL_MS
|
|
50
|
+
? Math.trunc(item.ttlMs)
|
|
51
|
+
: DEFAULT_TTL_MS
|
|
52
|
+
entries.set(token, {
|
|
53
|
+
label: typeof item.label === 'string' && item.label !== '' ? item.label.slice(0, 128) : 'unlabeled',
|
|
54
|
+
registeredAt: now,
|
|
55
|
+
expiresAt: now + ttlMs,
|
|
56
|
+
})
|
|
57
|
+
accepted += 1
|
|
58
|
+
}
|
|
59
|
+
if (mode === 'remove') {
|
|
60
|
+
for (const item of list) {
|
|
61
|
+
if (item && typeof item.label === 'string') {
|
|
62
|
+
for (const [token, entry] of entries) {
|
|
63
|
+
if (entry.label === item.label) entries.delete(token)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
sweep(now)
|
|
69
|
+
return { accepted, rejected: rejected.length }
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
validate(token, now = Date.now()) {
|
|
73
|
+
if (typeof token !== 'string' || !TOKEN_PATTERN.test(token)) return false
|
|
74
|
+
const entry = entries.get(token)
|
|
75
|
+
if (!entry) return false
|
|
76
|
+
if (entry.expiresAt <= now) {
|
|
77
|
+
entries.delete(token)
|
|
78
|
+
return false
|
|
79
|
+
}
|
|
80
|
+
return true
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
noteLegacyAuth(now = Date.now(), detail = null) {
|
|
84
|
+
legacyAuthCount += 1
|
|
85
|
+
lastLegacyAuthAt = new Date(now).toISOString()
|
|
86
|
+
lastLegacyAuthDetail = typeof detail === 'string' && detail !== '' ? detail.slice(0, 160) : null
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
status(now = Date.now()) {
|
|
90
|
+
sweep(now)
|
|
91
|
+
return {
|
|
92
|
+
registered: entries.size,
|
|
93
|
+
tokens: [...entries.values()].map((entry) => ({
|
|
94
|
+
label: entry.label,
|
|
95
|
+
registeredAt: new Date(entry.registeredAt).toISOString(),
|
|
96
|
+
expiresAt: new Date(entry.expiresAt).toISOString(),
|
|
97
|
+
})),
|
|
98
|
+
legacyAuthCount,
|
|
99
|
+
lastLegacyAuthAt,
|
|
100
|
+
lastLegacyAuthDetail,
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let singleton = null
|
|
107
|
+
|
|
108
|
+
function getWriterTokenRegistry() {
|
|
109
|
+
if (!singleton) singleton = createWriterTokenRegistry()
|
|
110
|
+
return singleton
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function mintWriterToken() {
|
|
114
|
+
return crypto.randomBytes(32).toString('hex')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function hasValidWriterToken(headerValue, registry, now = Date.now()) {
|
|
118
|
+
if (typeof headerValue !== 'string' || headerValue === '') return false
|
|
119
|
+
// In-process registry lookup: the timing-safe compare discipline applies to
|
|
120
|
+
// the STATIC bearer token (a file secret); membership here is an in-memory
|
|
121
|
+
// Map hit against an unregistered attacker-chosen value with no secret to
|
|
122
|
+
// leak.
|
|
123
|
+
return registry.validate(headerValue, now)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = {
|
|
127
|
+
TOKEN_PATTERN,
|
|
128
|
+
DEFAULT_TTL_MS,
|
|
129
|
+
createWriterTokenRegistry,
|
|
130
|
+
getWriterTokenRegistry,
|
|
131
|
+
mintWriterToken,
|
|
132
|
+
hasValidWriterToken,
|
|
133
|
+
}
|