@twinsearth/w2m-dsh-plugin 1.1.18 → 1.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +30 -0
- package/README.md +8 -8
- package/codex/mcp-servers.toml +1 -1
- package/lib/client.js +179 -0
- package/lib/client.js.bak2 +640 -0
- package/package.json +1 -1
- package/plugin.json +2 -2
- package/src/plugin/fleet-chat-route.mjs +120 -0
- package/src/plugin/tools.mjs +24 -3
- package/src/plugin/tools.mjs.bak-c7 +0 -7029
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/api/w2m/chat` — the browser-facing half of fleet chat (v1.1.17 shipped the tool only; this
|
|
3
|
+
* module is the missing host route the composer's chat panel calls).
|
|
4
|
+
*
|
|
5
|
+
* The route proxies the relay's device-gated `/v1/fleet/messages` endpoints with this machine's
|
|
6
|
+
* device token. The browser never sees the token: it calls this same-origin route, which is inside
|
|
7
|
+
* the host's authentication fence, exactly like `/api/w2m/target`.
|
|
8
|
+
*
|
|
9
|
+
* Wire:
|
|
10
|
+
* GET /api/w2m/chat?session_id=…&since_ms=…&limit=… -> relay list shape passed through
|
|
11
|
+
* POST /api/w2m/chat { session_id, text } -> relay send shape passed through
|
|
12
|
+
*
|
|
13
|
+
* Failure is a value here: `{ok:false, error, reason_code, detail}` with a non-2xx status, so the
|
|
14
|
+
* panel can print the host's own words. A relay that predates the fleet-messages routes answers
|
|
15
|
+
* 404, which is reported as such rather than as an empty conversation.
|
|
16
|
+
*/
|
|
17
|
+
export const CHAT_ROUTE_PATH = '/api/w2m/chat';
|
|
18
|
+
export const MAX_SESSION_ID_LENGTH = 200;
|
|
19
|
+
|
|
20
|
+
const NO_STORE = { 'cache-control': 'no-store' };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Attach the route to one host's fetch registry, recording the outcome on `status`.
|
|
24
|
+
*
|
|
25
|
+
* @param {object} ctx the plugin context (a Cordis fiber)
|
|
26
|
+
* @param {{cfg: () => object, request: Function, resolveToken: () => Promise<string|null>}} deps
|
|
27
|
+
* @returns {{mounted: boolean, reason: string|null}}
|
|
28
|
+
*/
|
|
29
|
+
export function registerChatRoutes(ctx, deps) {
|
|
30
|
+
const status = { mounted: false, reason: 'the host has no Connection service' };
|
|
31
|
+
if (typeof ctx?.inject !== 'function') return status;
|
|
32
|
+
ctx.inject(['connection'], (webCtx) => {
|
|
33
|
+
const registry = webCtx?.connection?.fetch;
|
|
34
|
+
if (registry === undefined || registry === null || typeof registry.register !== 'function') {
|
|
35
|
+
status.reason = 'the connection service carries no fetch registry';
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
registry.register({
|
|
40
|
+
path: CHAT_ROUTE_PATH,
|
|
41
|
+
methods: ['GET', 'POST'],
|
|
42
|
+
requestBody: 'buffered',
|
|
43
|
+
fetch: async (request) => {
|
|
44
|
+
const refuse = (httpStatus, reasonCode, error, detail) =>
|
|
45
|
+
Response.json({ ok: false, error, reason_code: reasonCode, detail }, { status: httpStatus, headers: NO_STORE });
|
|
46
|
+
|
|
47
|
+
if (request.method !== 'GET' && request.method !== 'POST') {
|
|
48
|
+
return refuse(405, 'CHAT_METHOD_NOT_ALLOWED', `${request.method} is not answered here`, 'GET lists a session; POST sends a message');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const url = new URL(request.url);
|
|
52
|
+
const sessionIdOf = (value) => (typeof value === 'string' ? value.trim() : '');
|
|
53
|
+
let sessionId = '';
|
|
54
|
+
let text = null;
|
|
55
|
+
if (request.method === 'GET') {
|
|
56
|
+
sessionId = sessionIdOf(url.searchParams.get('session_id'));
|
|
57
|
+
} else {
|
|
58
|
+
let body;
|
|
59
|
+
try {
|
|
60
|
+
body = await request.json();
|
|
61
|
+
} catch {
|
|
62
|
+
return refuse(400, 'CHAT_BODY_NOT_JSON', 'body is not JSON', 'send {"session_id":"…","text":"…"}');
|
|
63
|
+
}
|
|
64
|
+
sessionId = sessionIdOf(body?.session_id);
|
|
65
|
+
text = typeof body?.text === 'string' ? body.text : null;
|
|
66
|
+
}
|
|
67
|
+
if (sessionId === '') return refuse(400, 'CHAT_SESSION_ID_REQUIRED', '`session_id` is required', 'chat is scoped to one conversation');
|
|
68
|
+
if (sessionId.length > MAX_SESSION_ID_LENGTH) {
|
|
69
|
+
return refuse(400, 'CHAT_SESSION_ID_TOO_LONG', `\`session_id\` must be at most ${MAX_SESSION_ID_LENGTH} characters`, 'send the id the host gave this session');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const cfg = deps.cfg();
|
|
73
|
+
const rabbitUrl = cfg?.rabbitUrl ?? null;
|
|
74
|
+
if (rabbitUrl === null) return refuse(503, 'CHAT_NO_RELAY', 'no relay is configured', 'set rabbitUrl in the profile patch');
|
|
75
|
+
let token;
|
|
76
|
+
try {
|
|
77
|
+
token = await deps.resolveToken();
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return refuse(503, 'CHAT_UNPAIRED', `this machine has no device token: ${error instanceof Error ? error.message : String(error)}`, 'pair the machine first (autoStartAgent or a pairing code)');
|
|
80
|
+
}
|
|
81
|
+
if (typeof token !== 'string' || token === '') {
|
|
82
|
+
return refuse(503, 'CHAT_UNPAIRED', 'this machine has no device token', 'pair the machine first (autoStartAgent or a pairing code)');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
if (request.method === 'GET') {
|
|
87
|
+
const query = { session_id: sessionId };
|
|
88
|
+
const sinceMs = url.searchParams.get('since_ms');
|
|
89
|
+
const limit = url.searchParams.get('limit');
|
|
90
|
+
if (sinceMs !== null && /^\d+$/.test(sinceMs)) query.since_ms = sinceMs;
|
|
91
|
+
if (limit !== null && /^\d+$/.test(limit)) query.limit = limit;
|
|
92
|
+
const listed = await deps.request({ rabbitUrl, token, method: 'GET', pathname: '/v1/fleet/messages', query, signal: request.signal });
|
|
93
|
+
if (listed?.ok !== true) {
|
|
94
|
+
const code = listed?.json?.error?.code ?? listed?.error?.code ?? 'CHAT_LIST_REFUSED';
|
|
95
|
+
const message = listed?.json?.error?.message ?? listed?.error?.message ?? `the relay answered HTTP ${listed?.status ?? 0}`;
|
|
96
|
+
return refuse(listed?.status === 404 ? 502 : 502, code, message, 'the relay must run a version with /v1/fleet/messages (>= 1.1.17) and be restarted');
|
|
97
|
+
}
|
|
98
|
+
return Response.json({ ...listed.json, ok: true }, { headers: NO_STORE });
|
|
99
|
+
}
|
|
100
|
+
const posted = await deps.request({ rabbitUrl, token, method: 'POST', pathname: '/v1/fleet/messages', body: { session_id: sessionId, text: text ?? '' }, signal: request.signal });
|
|
101
|
+
if (posted?.ok !== true) {
|
|
102
|
+
const code = posted?.json?.error?.code ?? posted?.error?.code ?? 'CHAT_SEND_REFUSED';
|
|
103
|
+
const message = posted?.json?.error?.message ?? posted?.error?.message ?? `the relay answered HTTP ${posted?.status ?? 0}`;
|
|
104
|
+
return refuse(posted?.status === 404 ? 502 : 400, code, message, 'quota, size and non-text are each refused by name; 404 means the relay predates fleet chat');
|
|
105
|
+
}
|
|
106
|
+
return Response.json({ ...posted.json, ok: true }, { headers: NO_STORE });
|
|
107
|
+
} catch (error) {
|
|
108
|
+
return refuse(502, 'CHAT_RELAY_ERROR', `the relay call failed: ${error instanceof Error ? error.message : String(error)}`, 'check the relay and this machine token');
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
status.mounted = true;
|
|
113
|
+
status.reason = null;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
status.reason = error instanceof Error ? error.message : String(error);
|
|
116
|
+
ctx?.logger?.warn?.(`w2m: the fleet chat route is inactive — ${status.reason}`);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
return status;
|
|
120
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -180,6 +180,9 @@ import {
|
|
|
180
180
|
sessionKeyFor,
|
|
181
181
|
TARGET_ROUTE_PATH,
|
|
182
182
|
} from './fleet-target.mjs';
|
|
183
|
+
// v1.1.17 chat: the host route behind the composer's fleet-chat panel. The relay side and the
|
|
184
|
+
// `w2m_chat` tool shipped first; this is the browser-facing half that was still missing.
|
|
185
|
+
import { registerChatRoutes } from './fleet-chat-route.mjs';
|
|
183
186
|
// v0.7.0: the LAN hint layer. Imported here and used in exactly one place (`w2m_devices`); see the
|
|
184
187
|
// module header for why a discovered device is a candidate to *ask* and never an authorisation.
|
|
185
188
|
import { createDiscoverySource } from './mdns-discover.mjs';
|
|
@@ -217,7 +220,7 @@ const MAX_STAGES = 16;
|
|
|
217
220
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
218
221
|
* overrides it at runtime for tests and packagers.
|
|
219
222
|
*/
|
|
220
|
-
const PLUGIN_VERSION = '1.1.
|
|
223
|
+
const PLUGIN_VERSION = '1.1.19';
|
|
221
224
|
|
|
222
225
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
223
226
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -3401,6 +3404,20 @@ export async function apply(ctx, config = {}) {
|
|
|
3401
3404
|
resolveToken,
|
|
3402
3405
|
});
|
|
3403
3406
|
|
|
3407
|
+
// The composer's fleet-chat panel: `GET/POST /api/w2m/chat`, proxied to the relay's device-gated
|
|
3408
|
+
// `/v1/fleet/messages`. Optional like the target routes — a host without Connection keeps every
|
|
3409
|
+
// tool and the panel is simply not mounted.
|
|
3410
|
+
const chatRoutes = registerChatRoutes(ctx, {
|
|
3411
|
+
cfg: () => cfg,
|
|
3412
|
+
request,
|
|
3413
|
+
resolveToken: () => resolveToken(cfg),
|
|
3414
|
+
});
|
|
3415
|
+
if (chatRoutes.mounted !== true) {
|
|
3416
|
+
// Named rather than silent: a composition without the fetch registry loses the chat panel while every tool still
|
|
3417
|
+
// works, and "the panel is missing" with no line anywhere is the shape this project keeps paying for.
|
|
3418
|
+
ctx.logger?.warn?.('w2m: the fleet-chat composer route is not mounted (' + (chatRoutes.reason ?? 'unknown reason') + ')');
|
|
3419
|
+
}
|
|
3420
|
+
|
|
3404
3421
|
// The LAN discovery source (v0.7.0). Constructing it binds nothing: a socket is opened only when a
|
|
3405
3422
|
// scan is asked for, and only `w2m_devices` asks. A network where multicast is unavailable produces
|
|
3406
3423
|
// one log line and an `available: false` block in the answer - never a failure of the tool.
|
|
@@ -6320,10 +6337,14 @@ export async function apply(ctx, config = {}) {
|
|
|
6320
6337
|
const action = typeof args?.action === 'string' ? args.action : 'list';
|
|
6321
6338
|
const sessionId = typeof args?.session_id === 'string' ? args.session_id.trim() : '';
|
|
6322
6339
|
if (sessionId === '') throw new W2MError('W2M_CHAT_SESSION', 'W2M_CHAT_SESSION: session_id is required', { hint: 'chat is scoped to a conversation' });
|
|
6323
|
-
const device = await readDevice(cfg);
|
|
6324
6340
|
const rabbitUrl = cfg.rabbitUrl ?? null;
|
|
6325
6341
|
if (rabbitUrl === null) throw new W2MError('W2M_NO_RELAY', 'W2M_NO_RELAY: no relay is configured, so chat cannot be reached', { hint: 'set rabbitUrl in the profile patch' });
|
|
6326
|
-
|
|
6342
|
+
// resolveToken, not readDevice: readDevice returns an empty identity whenever `stateDir` is
|
|
6343
|
+
// unset, even though this machine's device.json exists at the default DSH_HOME path — every
|
|
6344
|
+
// other device-authenticated tool goes through resolveToken for exactly that reason, and this
|
|
6345
|
+
// one reported W2M_NO_TOKEN on a machine that was paired and answering every other call.
|
|
6346
|
+
const token = await resolveToken(cfg);
|
|
6347
|
+
if (token === null) throw new W2MError('W2M_NO_TOKEN', 'W2M_NO_TOKEN: this machine has no device token, so chat cannot be authenticated', { hint: 'pair this machine first (autoStartAgent or a pairing code), then retry' });
|
|
6327
6348
|
if (action === 'send') {
|
|
6328
6349
|
const posted = await request({ rabbitUrl, token, signal: exec?.signal, method: 'POST', pathname: '/v1/fleet/messages', body: { session_id: sessionId, text: typeof args?.text === 'string' ? args.text : '' } });
|
|
6329
6350
|
if (posted?.ok !== true) {
|