tandem-editor 0.6.2 → 0.7.0
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/CHANGELOG.md +407 -28
- package/dist/channel/index.js +41 -7
- package/dist/channel/index.js.map +1 -1
- package/dist/cli/index.js +477 -58
- package/dist/cli/index.js.map +1 -1
- package/dist/client/assets/index-B1Cd5UGT.js +349 -0
- package/dist/client/index.html +48 -1
- package/dist/monitor/index.js +24 -2
- package/dist/monitor/index.js.map +1 -1
- package/dist/server/index.js +61109 -59854
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
- package/dist/client/assets/index-mo5ZOPfU.js +0 -349
package/dist/cli/index.js
CHANGED
|
@@ -10,7 +10,7 @@ var __export = (target, all) => {
|
|
|
10
10
|
};
|
|
11
11
|
|
|
12
12
|
// src/shared/constants.ts
|
|
13
|
-
var DEFAULT_MCP_PORT, MAX_FILE_SIZE, MAX_WS_PAYLOAD, IDLE_TIMEOUT, SESSION_MAX_AGE, CHANNEL_MAX_RETRIES, CHANNEL_RETRY_DELAY_MS;
|
|
13
|
+
var DEFAULT_MCP_PORT, MAX_FILE_SIZE, MAX_WS_PAYLOAD, IDLE_TIMEOUT, SESSION_MAX_AGE, CHANNEL_MAX_RETRIES, CHANNEL_RETRY_DELAY_MS, TOKEN_FILE_NAME;
|
|
14
14
|
var init_constants = __esm({
|
|
15
15
|
"src/shared/constants.ts"() {
|
|
16
16
|
"use strict";
|
|
@@ -21,6 +21,7 @@ var init_constants = __esm({
|
|
|
21
21
|
SESSION_MAX_AGE = 30 * 24 * 60 * 60 * 1e3;
|
|
22
22
|
CHANNEL_MAX_RETRIES = 5;
|
|
23
23
|
CHANNEL_RETRY_DELAY_MS = 2e3;
|
|
24
|
+
TOKEN_FILE_NAME = "auth-token";
|
|
24
25
|
}
|
|
25
26
|
});
|
|
26
27
|
|
|
@@ -42,6 +43,7 @@ var init_skill_content = __esm({
|
|
|
42
43
|
var setup_exports = {};
|
|
43
44
|
__export(setup_exports, {
|
|
44
45
|
applyConfig: () => applyConfig,
|
|
46
|
+
applyConfigWithToken: () => applyConfigWithToken,
|
|
45
47
|
buildMcpEntries: () => buildMcpEntries,
|
|
46
48
|
detectTargets: () => detectTargets,
|
|
47
49
|
installSkill: () => installSkill,
|
|
@@ -55,14 +57,20 @@ import { homedir } from "os";
|
|
|
55
57
|
import { basename, dirname as dirname2, join, resolve as resolve2 } from "path";
|
|
56
58
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
57
59
|
function buildMcpEntries(channelPath, opts = {}) {
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
const tandemEntry = { type: "http", url: `${MCP_URL}/mcp` };
|
|
61
|
+
if (opts.token) {
|
|
62
|
+
tandemEntry.headers = { Authorization: `Bearer ${opts.token}` };
|
|
63
|
+
}
|
|
64
|
+
const entries = { tandem: tandemEntry };
|
|
61
65
|
if (opts.withChannelShim) {
|
|
66
|
+
const shimEnv = { TANDEM_URL: MCP_URL };
|
|
67
|
+
if (opts.token) {
|
|
68
|
+
shimEnv.TANDEM_AUTH_TOKEN = opts.token;
|
|
69
|
+
}
|
|
62
70
|
entries["tandem-channel"] = {
|
|
63
71
|
command: opts.nodeBinary ?? "node",
|
|
64
72
|
args: [channelPath],
|
|
65
|
-
env:
|
|
73
|
+
env: shimEnv
|
|
66
74
|
};
|
|
67
75
|
}
|
|
68
76
|
return entries;
|
|
@@ -157,6 +165,24 @@ async function installSkill(opts = {}) {
|
|
|
157
165
|
function validateChannelShimPrereq(channelPath) {
|
|
158
166
|
return existsSync(channelPath);
|
|
159
167
|
}
|
|
168
|
+
async function applyConfigWithToken(token, opts = {}) {
|
|
169
|
+
const targets = detectTargets({ force: opts.force });
|
|
170
|
+
const entries = buildMcpEntries(CHANNEL_DIST, {
|
|
171
|
+
withChannelShim: opts.withChannelShim,
|
|
172
|
+
token: token ?? void 0
|
|
173
|
+
});
|
|
174
|
+
let updated = 0;
|
|
175
|
+
const errors = [];
|
|
176
|
+
for (const t of targets) {
|
|
177
|
+
try {
|
|
178
|
+
await applyConfig(t.configPath, entries);
|
|
179
|
+
updated++;
|
|
180
|
+
} catch (err) {
|
|
181
|
+
errors.push(`${t.label}: ${err instanceof Error ? err.message : String(err)}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { updated, errors };
|
|
185
|
+
}
|
|
160
186
|
async function runSetup(opts = {}) {
|
|
161
187
|
console.error("\nTandem Setup\n");
|
|
162
188
|
if (opts.withChannelShim && !validateChannelShimPrereq(CHANNEL_DIST)) {
|
|
@@ -249,15 +275,35 @@ function resolveTandemUrl(override) {
|
|
|
249
275
|
const raw = override ?? process.env.TANDEM_URL ?? `http://localhost:${DEFAULT_MCP_PORT}`;
|
|
250
276
|
return raw.replace(/\/$/, "");
|
|
251
277
|
}
|
|
278
|
+
async function authFetch(url, init) {
|
|
279
|
+
const token = process.env.TANDEM_AUTH_TOKEN;
|
|
280
|
+
if (token !== void 0 && token.trim() !== "") {
|
|
281
|
+
if (VALID_TOKEN_RE.test(token.trim())) {
|
|
282
|
+
const headers = new Headers(init?.headers);
|
|
283
|
+
headers.set("Authorization", `Bearer ${token.trim()}`);
|
|
284
|
+
return fetch(url, { ...init, headers });
|
|
285
|
+
}
|
|
286
|
+
if (!_warnedInvalidToken) {
|
|
287
|
+
_warnedInvalidToken = true;
|
|
288
|
+
console.error(
|
|
289
|
+
"[tandem] authFetch: TANDEM_AUTH_TOKEN is set but invalid (must be 32+ alphanumeric chars [A-Za-z0-9_-]); sending without Authorization header"
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return fetch(url, init);
|
|
294
|
+
}
|
|
295
|
+
var VALID_TOKEN_RE, _warnedInvalidToken;
|
|
252
296
|
var init_cli_runtime = __esm({
|
|
253
297
|
"src/shared/cli-runtime.ts"() {
|
|
254
298
|
"use strict";
|
|
255
299
|
init_constants();
|
|
300
|
+
VALID_TOKEN_RE = /^[A-Za-z0-9_\-]{32,}$/;
|
|
301
|
+
_warnedInvalidToken = false;
|
|
256
302
|
}
|
|
257
303
|
});
|
|
258
304
|
|
|
259
305
|
// src/cli/preflight.ts
|
|
260
|
-
async function
|
|
306
|
+
async function probeTandemServer(opts = {}) {
|
|
261
307
|
const url = resolveTandemUrl(opts.url);
|
|
262
308
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
263
309
|
const controller = new AbortController();
|
|
@@ -265,22 +311,36 @@ async function ensureTandemServer(opts = {}) {
|
|
|
265
311
|
try {
|
|
266
312
|
const res = await fetch(`${url}/health`, { signal: controller.signal });
|
|
267
313
|
if (!res.ok) {
|
|
268
|
-
|
|
314
|
+
return {
|
|
315
|
+
ok: false,
|
|
316
|
+
url,
|
|
317
|
+
reason: `health endpoint returned HTTP ${res.status}`,
|
|
318
|
+
kind: "unhealthy"
|
|
319
|
+
};
|
|
269
320
|
}
|
|
321
|
+
return { ok: true };
|
|
270
322
|
} catch (err) {
|
|
271
|
-
|
|
272
|
-
|
|
323
|
+
return {
|
|
324
|
+
ok: false,
|
|
325
|
+
url,
|
|
326
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
327
|
+
kind: "unreachable"
|
|
328
|
+
};
|
|
273
329
|
} finally {
|
|
274
330
|
clearTimeout(timer);
|
|
275
331
|
}
|
|
276
332
|
}
|
|
277
|
-
function
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
333
|
+
async function ensureTandemServer(opts = {}) {
|
|
334
|
+
const probe = await probeTandemServer(opts);
|
|
335
|
+
if (!probe.ok) {
|
|
336
|
+
const guidance = probe.kind === "unreachable" ? "Start the Tauri app or run `tandem start` on the host, then retry." : "The Tandem server is running but unhealthy \u2014 check the host logs.";
|
|
337
|
+
process.stderr.write(
|
|
338
|
+
`[tandem] Tandem server preflight failed at ${probe.url} (${probe.reason}).
|
|
339
|
+
[tandem] ${guidance}
|
|
281
340
|
`
|
|
282
|
-
|
|
283
|
-
|
|
341
|
+
);
|
|
342
|
+
process.exit(1);
|
|
343
|
+
}
|
|
284
344
|
}
|
|
285
345
|
var DEFAULT_TIMEOUT_MS;
|
|
286
346
|
var init_preflight = __esm({
|
|
@@ -295,80 +355,237 @@ var init_preflight = __esm({
|
|
|
295
355
|
var mcp_stdio_exports = {};
|
|
296
356
|
__export(mcp_stdio_exports, {
|
|
297
357
|
getRequestId: () => getRequestId,
|
|
358
|
+
getResponseId: () => getResponseId,
|
|
359
|
+
parseTimeoutMs: () => parseTimeoutMs,
|
|
360
|
+
readAndValidateAuthToken: () => readAndValidateAuthToken,
|
|
298
361
|
runMcpStdio: () => runMcpStdio
|
|
299
362
|
});
|
|
300
363
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
301
364
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
365
|
+
function parseTimeoutMs(raw) {
|
|
366
|
+
if (raw !== void 0) {
|
|
367
|
+
const parsed = parseInt(raw, 10);
|
|
368
|
+
if (Number.isFinite(parsed) && parsed > 0 && parsed <= MAX_TIMEOUT_MS) {
|
|
369
|
+
return parsed;
|
|
370
|
+
}
|
|
371
|
+
process.stderr.write(
|
|
372
|
+
`[tandem mcp-stdio] TANDEM_REQUEST_TIMEOUT_MS must be a positive integer \u2264 ${MAX_TIMEOUT_MS}; ignoring "${raw}", using 30000ms default
|
|
373
|
+
`
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
return 3e4;
|
|
377
|
+
}
|
|
378
|
+
function readAndValidateAuthToken() {
|
|
379
|
+
const raw = process.env.TANDEM_AUTH_TOKEN;
|
|
380
|
+
if (raw === void 0) return null;
|
|
381
|
+
const trimmed = raw.trim();
|
|
382
|
+
if (trimmed === "") return null;
|
|
383
|
+
if (trimmed.startsWith("Bearer ")) {
|
|
384
|
+
process.stderr.write(
|
|
385
|
+
"[tandem mcp-stdio] TANDEM_AUTH_TOKEN is invalid (double-prefix: do not include 'Bearer ' prefix \u2014 supply the raw token only)\n"
|
|
386
|
+
);
|
|
387
|
+
process.exit(1);
|
|
388
|
+
}
|
|
389
|
+
if (!VALID_TOKEN_RE2.test(trimmed)) {
|
|
390
|
+
process.stderr.write(
|
|
391
|
+
"[tandem mcp-stdio] TANDEM_AUTH_TOKEN is malformed (must be 32+ URL-safe characters: [A-Za-z0-9_-])\n"
|
|
392
|
+
);
|
|
393
|
+
process.exit(1);
|
|
394
|
+
}
|
|
395
|
+
return trimmed;
|
|
396
|
+
}
|
|
302
397
|
async function runMcpStdio() {
|
|
303
398
|
const baseUrl = resolveTandemUrl();
|
|
304
|
-
|
|
305
|
-
const http = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`)
|
|
399
|
+
const authToken = readAndValidateAuthToken();
|
|
400
|
+
const http = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`), {
|
|
401
|
+
requestInit: authToken ? { headers: { Authorization: `Bearer ${authToken}` } } : void 0
|
|
402
|
+
});
|
|
306
403
|
const stdio = new StdioServerTransport();
|
|
404
|
+
const pendingRequests = /* @__PURE__ */ new Map();
|
|
405
|
+
const preReadyBuffer = [];
|
|
307
406
|
let shuttingDown = false;
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
407
|
+
let httpReady = false;
|
|
408
|
+
async function sendErrorResponse(id, message, detail) {
|
|
409
|
+
const errorResponse = {
|
|
410
|
+
jsonrpc: "2.0",
|
|
411
|
+
id,
|
|
412
|
+
error: {
|
|
413
|
+
// -32000 is the implementation-defined server error range per
|
|
414
|
+
// JSON-RPC 2.0 §5.1 — upstream unavailability is an application-
|
|
415
|
+
// level condition, not a generic Internal Error.
|
|
416
|
+
code: -32e3,
|
|
417
|
+
message,
|
|
418
|
+
...detail !== void 0 ? { data: { detail } } : {}
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
try {
|
|
422
|
+
await stdio.send(errorResponse);
|
|
423
|
+
} catch (err) {
|
|
424
|
+
const detail2 = err instanceof Error ? err.message : String(err);
|
|
425
|
+
process.stderr.write(
|
|
426
|
+
`[tandem mcp-stdio] failed to send synthesized error for id ${id}: ${detail2}
|
|
427
|
+
`
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
function forwardToUpstream(msg) {
|
|
432
|
+
if (shuttingDown) return;
|
|
433
|
+
const requestId = getRequestId(msg);
|
|
434
|
+
if (requestId !== void 0) {
|
|
435
|
+
const existing = pendingRequests.get(requestId);
|
|
436
|
+
if (existing) clearTimeout(existing);
|
|
437
|
+
const timeoutHandle = setTimeout(() => {
|
|
438
|
+
if (!pendingRequests.delete(requestId)) return;
|
|
439
|
+
void sendErrorResponse(
|
|
440
|
+
requestId,
|
|
441
|
+
"Tandem HTTP upstream not responding (half-open)",
|
|
442
|
+
`No response after ${STDIO_REQUEST_TIMEOUT_MS}ms`
|
|
443
|
+
);
|
|
444
|
+
}, STDIO_REQUEST_TIMEOUT_MS);
|
|
445
|
+
pendingRequests.set(requestId, timeoutHandle);
|
|
315
446
|
}
|
|
316
|
-
process.exit(code);
|
|
317
|
-
};
|
|
318
|
-
stdio.onmessage = (msg) => {
|
|
319
447
|
http.send(msg).catch((err) => {
|
|
320
448
|
const detail = err instanceof Error ? err.message : String(err);
|
|
321
449
|
process.stderr.write(`[tandem mcp-stdio] upstream send failed: ${detail}
|
|
322
450
|
`);
|
|
323
|
-
const requestId = getRequestId(msg);
|
|
324
451
|
if (requestId !== void 0) {
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
// application-level condition, not a generic Internal Error.
|
|
332
|
-
code: -32e3,
|
|
333
|
-
message: "Tandem HTTP upstream unreachable",
|
|
334
|
-
data: { detail }
|
|
335
|
-
}
|
|
336
|
-
};
|
|
337
|
-
stdio.send(errorResponse).catch(() => {
|
|
338
|
-
});
|
|
452
|
+
const handle = pendingRequests.get(requestId);
|
|
453
|
+
if (handle !== void 0) {
|
|
454
|
+
pendingRequests.delete(requestId);
|
|
455
|
+
clearTimeout(handle);
|
|
456
|
+
void sendErrorResponse(requestId, "Tandem HTTP upstream unreachable", detail);
|
|
457
|
+
}
|
|
339
458
|
}
|
|
340
459
|
});
|
|
460
|
+
}
|
|
461
|
+
async function synthesizeBuffered(message, detail) {
|
|
462
|
+
const buffered2 = preReadyBuffer.splice(0);
|
|
463
|
+
const ids = buffered2.map((msg) => getRequestId(msg)).filter((id) => id !== void 0);
|
|
464
|
+
for (const id of ids) {
|
|
465
|
+
await sendErrorResponse(id, message, detail);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
async function synthesizePending(message, detail) {
|
|
469
|
+
if (pendingRequests.size === 0) return;
|
|
470
|
+
const ids = [...pendingRequests.keys()];
|
|
471
|
+
for (const handle of pendingRequests.values()) clearTimeout(handle);
|
|
472
|
+
pendingRequests.clear();
|
|
473
|
+
await Promise.all(ids.map((id) => sendErrorResponse(id, message, detail)));
|
|
474
|
+
}
|
|
475
|
+
const shutdown = async (code = 0, synth) => {
|
|
476
|
+
if (!shuttingDown) {
|
|
477
|
+
shuttingDown = true;
|
|
478
|
+
setTimeout(() => process.exit(code), 2e3).unref();
|
|
479
|
+
for (const handle of pendingRequests.values()) clearTimeout(handle);
|
|
480
|
+
if (synth) {
|
|
481
|
+
await synthesizeBuffered(synth.message, synth.detail);
|
|
482
|
+
await synthesizePending(synth.message, synth.detail);
|
|
483
|
+
}
|
|
484
|
+
await http.close().catch((err) => {
|
|
485
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
486
|
+
process.stderr.write(`[tandem mcp-stdio] http.close failed: ${detail}
|
|
487
|
+
`);
|
|
488
|
+
});
|
|
489
|
+
await stdio.close().catch((err) => {
|
|
490
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
491
|
+
process.stderr.write(`[tandem mcp-stdio] stdio.close failed: ${detail}
|
|
492
|
+
`);
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
process.exit(code);
|
|
496
|
+
};
|
|
497
|
+
function deferredShutdown(synth) {
|
|
498
|
+
setTimeout(() => void shutdown(1, synth), PREFLIGHT_GRACE_MS);
|
|
499
|
+
}
|
|
500
|
+
stdio.onmessage = (msg) => {
|
|
501
|
+
if (!httpReady) {
|
|
502
|
+
preReadyBuffer.push(msg);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
forwardToUpstream(msg);
|
|
341
506
|
};
|
|
342
507
|
http.onmessage = (msg) => {
|
|
343
|
-
|
|
508
|
+
if (shuttingDown) return;
|
|
509
|
+
const responseId = getResponseId(msg);
|
|
510
|
+
if (responseId !== void 0) {
|
|
511
|
+
const handle = pendingRequests.get(responseId);
|
|
512
|
+
if (handle !== void 0) {
|
|
513
|
+
clearTimeout(handle);
|
|
514
|
+
pendingRequests.delete(responseId);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
const sendHandler = (err) => {
|
|
344
518
|
const detail = err instanceof Error ? err.message : String(err);
|
|
345
|
-
process.stderr.write(
|
|
346
|
-
`
|
|
347
|
-
|
|
519
|
+
process.stderr.write(
|
|
520
|
+
`[tandem mcp-stdio] stdio write failed for id ${responseId ?? "<notification>"}: ${detail}
|
|
521
|
+
`
|
|
522
|
+
);
|
|
523
|
+
if (responseId !== void 0) {
|
|
524
|
+
void sendErrorResponse(responseId, "Tandem stdio write failed", detail);
|
|
525
|
+
}
|
|
526
|
+
void shutdown(1, {
|
|
527
|
+
message: "Tandem stdio write failed",
|
|
528
|
+
detail
|
|
529
|
+
});
|
|
530
|
+
};
|
|
531
|
+
try {
|
|
532
|
+
stdio.send(msg).catch(sendHandler);
|
|
533
|
+
} catch (err) {
|
|
534
|
+
sendHandler(err);
|
|
535
|
+
}
|
|
348
536
|
};
|
|
349
537
|
stdio.onerror = (err) => {
|
|
350
538
|
process.stderr.write(`[tandem mcp-stdio] stdio error: ${err.message}
|
|
539
|
+
${err.stack ?? ""}
|
|
351
540
|
`);
|
|
352
541
|
};
|
|
353
542
|
http.onerror = (err) => {
|
|
354
|
-
|
|
355
|
-
|
|
543
|
+
const cause = err.cause;
|
|
544
|
+
process.stderr.write(
|
|
545
|
+
`[tandem mcp-stdio] http error: ${err.message}
|
|
546
|
+
${err.stack ?? ""}${cause !== void 0 ? `
|
|
547
|
+
cause: ${cause}` : ""}
|
|
548
|
+
`
|
|
549
|
+
);
|
|
356
550
|
};
|
|
357
551
|
stdio.onclose = () => {
|
|
358
552
|
void shutdown(0);
|
|
359
553
|
};
|
|
360
554
|
http.onclose = () => {
|
|
361
|
-
|
|
555
|
+
if (shuttingDown) return;
|
|
556
|
+
void shutdown(1, {
|
|
557
|
+
message: "Tandem HTTP upstream closed unexpectedly",
|
|
558
|
+
detail: "upstream connection dropped mid-session"
|
|
559
|
+
});
|
|
362
560
|
};
|
|
363
561
|
await stdio.start();
|
|
562
|
+
process.stdin.once("end", () => {
|
|
563
|
+
void shutdown(0);
|
|
564
|
+
});
|
|
565
|
+
const probe = await probeTandemServer({ url: baseUrl });
|
|
566
|
+
if (!probe.ok) {
|
|
567
|
+
const guidance = probe.kind === "unreachable" ? "Start the Tauri app or run `tandem start` on the host, then retry." : "The Tandem server is running but unhealthy \u2014 check the host logs.";
|
|
568
|
+
process.stderr.write(
|
|
569
|
+
`[tandem mcp-stdio] Tandem server preflight failed at ${probe.url} (${probe.reason}).
|
|
570
|
+
[tandem mcp-stdio] ${guidance}
|
|
571
|
+
`
|
|
572
|
+
);
|
|
573
|
+
const synthMessage = probe.kind === "unreachable" ? "Tandem server not running. Start the Tauri app or run `tandem start`." : "Tandem server unhealthy (check host logs).";
|
|
574
|
+
deferredShutdown({ message: synthMessage, detail: probe.reason });
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
364
577
|
try {
|
|
365
578
|
await http.start();
|
|
366
579
|
} catch (err) {
|
|
367
580
|
const detail = err instanceof Error ? err.message : String(err);
|
|
368
581
|
process.stderr.write(`[tandem mcp-stdio] upstream http start failed: ${detail}
|
|
369
582
|
`);
|
|
370
|
-
|
|
583
|
+
deferredShutdown({ message: "Tandem HTTP upstream failed to start", detail });
|
|
584
|
+
return;
|
|
371
585
|
}
|
|
586
|
+
httpReady = true;
|
|
587
|
+
const buffered = preReadyBuffer.splice(0);
|
|
588
|
+
for (const msg of buffered) forwardToUpstream(msg);
|
|
372
589
|
}
|
|
373
590
|
function getRequestId(msg) {
|
|
374
591
|
const m = msg;
|
|
@@ -376,12 +593,37 @@ function getRequestId(msg) {
|
|
|
376
593
|
if (typeof m.id === "string" || typeof m.id === "number") return m.id;
|
|
377
594
|
return void 0;
|
|
378
595
|
}
|
|
596
|
+
function getResponseId(msg) {
|
|
597
|
+
const m = msg;
|
|
598
|
+
if (typeof m.method === "string") return void 0;
|
|
599
|
+
if (typeof m.id === "string" || typeof m.id === "number") return m.id;
|
|
600
|
+
return void 0;
|
|
601
|
+
}
|
|
602
|
+
var PREFLIGHT_GRACE_MS, MAX_TIMEOUT_MS, STDIO_REQUEST_TIMEOUT_MS, VALID_TOKEN_RE2;
|
|
379
603
|
var init_mcp_stdio = __esm({
|
|
380
604
|
"src/cli/mcp-stdio.ts"() {
|
|
381
605
|
"use strict";
|
|
382
606
|
init_cli_runtime();
|
|
383
607
|
init_preflight();
|
|
384
608
|
redirectConsoleToStderr();
|
|
609
|
+
PREFLIGHT_GRACE_MS = 1500;
|
|
610
|
+
MAX_TIMEOUT_MS = 2147483647;
|
|
611
|
+
STDIO_REQUEST_TIMEOUT_MS = parseTimeoutMs(process.env.TANDEM_REQUEST_TIMEOUT_MS);
|
|
612
|
+
process.once("uncaughtException", (err) => {
|
|
613
|
+
process.stderr.write(
|
|
614
|
+
`[tandem mcp-stdio] uncaughtException: ${err.message}
|
|
615
|
+
${err.stack ?? ""}
|
|
616
|
+
`
|
|
617
|
+
);
|
|
618
|
+
process.exit(1);
|
|
619
|
+
});
|
|
620
|
+
process.once("unhandledRejection", (reason) => {
|
|
621
|
+
const detail = reason instanceof Error ? reason.message : String(reason);
|
|
622
|
+
process.stderr.write(`[tandem mcp-stdio] unhandledRejection: ${detail}
|
|
623
|
+
`);
|
|
624
|
+
process.exit(1);
|
|
625
|
+
});
|
|
626
|
+
VALID_TOKEN_RE2 = /^[A-Za-z0-9_\-]{32,}$/;
|
|
385
627
|
}
|
|
386
628
|
});
|
|
387
629
|
|
|
@@ -513,7 +755,7 @@ async function startEventBridge(mcp, tandemUrl) {
|
|
|
513
755
|
if (retries >= CHANNEL_MAX_RETRIES) {
|
|
514
756
|
console.error("[Channel] SSE connection exhausted, reporting error and exiting");
|
|
515
757
|
try {
|
|
516
|
-
await
|
|
758
|
+
await authFetch(`${tandemUrl}/api/channel-error`, {
|
|
517
759
|
method: "POST",
|
|
518
760
|
headers: { "Content-Type": "application/json" },
|
|
519
761
|
body: JSON.stringify({
|
|
@@ -536,7 +778,7 @@ async function startEventBridge(mcp, tandemUrl) {
|
|
|
536
778
|
async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
|
|
537
779
|
const headers = { Accept: "text/event-stream" };
|
|
538
780
|
if (lastEventId) headers["Last-Event-ID"] = lastEventId;
|
|
539
|
-
const res = await
|
|
781
|
+
const res = await authFetch(`${tandemUrl}/api/events`, { headers });
|
|
540
782
|
if (!res.ok) throw new Error(`SSE endpoint returned ${res.status}`);
|
|
541
783
|
if (!res.body) throw new Error("SSE endpoint returned no body");
|
|
542
784
|
const reader = res.body.getReader();
|
|
@@ -547,7 +789,7 @@ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
|
|
|
547
789
|
let pendingAwareness = null;
|
|
548
790
|
const AWARENESS_CLEAR_MS = 3e3;
|
|
549
791
|
function clearAwareness(documentId) {
|
|
550
|
-
|
|
792
|
+
authFetch(`${tandemUrl}/api/channel-awareness`, {
|
|
551
793
|
method: "POST",
|
|
552
794
|
headers: { "Content-Type": "application/json" },
|
|
553
795
|
body: JSON.stringify({
|
|
@@ -562,7 +804,7 @@ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
|
|
|
562
804
|
if (!pendingAwareness) return;
|
|
563
805
|
const event = pendingAwareness;
|
|
564
806
|
pendingAwareness = null;
|
|
565
|
-
|
|
807
|
+
authFetch(`${tandemUrl}/api/channel-awareness`, {
|
|
566
808
|
method: "POST",
|
|
567
809
|
headers: { "Content-Type": "application/json" },
|
|
568
810
|
body: JSON.stringify({
|
|
@@ -637,7 +879,7 @@ async function getCachedMode(tandemUrl) {
|
|
|
637
879
|
const now = Date.now();
|
|
638
880
|
if (now - cachedModeAt < MODE_CACHE_TTL_MS) return cachedMode;
|
|
639
881
|
try {
|
|
640
|
-
const res = await
|
|
882
|
+
const res = await authFetch(`${tandemUrl}/api/mode`);
|
|
641
883
|
if (res.ok) {
|
|
642
884
|
const { mode } = await res.json();
|
|
643
885
|
cachedMode = mode;
|
|
@@ -659,6 +901,7 @@ var init_event_bridge = __esm({
|
|
|
659
901
|
"src/channel/event-bridge.ts"() {
|
|
660
902
|
"use strict";
|
|
661
903
|
init_types();
|
|
904
|
+
init_cli_runtime();
|
|
662
905
|
init_constants();
|
|
663
906
|
AWARENESS_DEBOUNCE_MS = 500;
|
|
664
907
|
MODE_CACHE_TTL_MS = 2e3;
|
|
@@ -726,7 +969,7 @@ async function runChannel(opts = {}) {
|
|
|
726
969
|
if (req.params.name === "tandem_reply") {
|
|
727
970
|
const args2 = req.params.arguments;
|
|
728
971
|
try {
|
|
729
|
-
const res = await
|
|
972
|
+
const res = await authFetch(`${tandemUrl}/api/channel-reply`, {
|
|
730
973
|
method: "POST",
|
|
731
974
|
headers: { "Content-Type": "application/json" },
|
|
732
975
|
body: JSON.stringify(args2)
|
|
@@ -774,7 +1017,7 @@ async function runChannel(opts = {}) {
|
|
|
774
1017
|
});
|
|
775
1018
|
mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
|
|
776
1019
|
try {
|
|
777
|
-
const res = await
|
|
1020
|
+
const res = await authFetch(`${tandemUrl}/api/channel-permission`, {
|
|
778
1021
|
method: "POST",
|
|
779
1022
|
headers: { "Content-Type": "application/json" },
|
|
780
1023
|
body: JSON.stringify({
|
|
@@ -863,6 +1106,163 @@ var init_channel = __esm({
|
|
|
863
1106
|
}
|
|
864
1107
|
});
|
|
865
1108
|
|
|
1109
|
+
// src/server/auth/token-store.ts
|
|
1110
|
+
import envPaths from "env-paths";
|
|
1111
|
+
import fs from "fs";
|
|
1112
|
+
import path from "path";
|
|
1113
|
+
function getTokenFilePath() {
|
|
1114
|
+
return path.join(envPaths("tandem", { suffix: "" }).data, TOKEN_FILE_NAME);
|
|
1115
|
+
}
|
|
1116
|
+
async function readTokenFromFile() {
|
|
1117
|
+
const filePath = getTokenFilePath();
|
|
1118
|
+
try {
|
|
1119
|
+
const content = await fs.promises.readFile(filePath, "utf8");
|
|
1120
|
+
if (process.platform !== "win32") {
|
|
1121
|
+
try {
|
|
1122
|
+
const stat = await fs.promises.stat(filePath);
|
|
1123
|
+
if ((stat.mode & 63) !== 0) {
|
|
1124
|
+
console.error("[tandem] auth token file has insecure permissions; attempting chmod 0600");
|
|
1125
|
+
await fs.promises.chmod(filePath, 384);
|
|
1126
|
+
}
|
|
1127
|
+
} catch {
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
const trimmed = content.trim();
|
|
1131
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
1132
|
+
} catch (err) {
|
|
1133
|
+
if (err.code === "ENOENT") return null;
|
|
1134
|
+
throw err;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
var init_token_store = __esm({
|
|
1138
|
+
"src/server/auth/token-store.ts"() {
|
|
1139
|
+
"use strict";
|
|
1140
|
+
init_constants();
|
|
1141
|
+
}
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
// src/cli/rotate-token.ts
|
|
1145
|
+
var rotate_token_exports = {};
|
|
1146
|
+
__export(rotate_token_exports, {
|
|
1147
|
+
rotateToken: () => rotateToken
|
|
1148
|
+
});
|
|
1149
|
+
import { createHash, randomBytes } from "crypto";
|
|
1150
|
+
import { promises as fsPromises } from "fs";
|
|
1151
|
+
import path2 from "path";
|
|
1152
|
+
function fingerprint(token) {
|
|
1153
|
+
return createHash("sha256").update(token, "utf8").digest("hex").slice(0, 8);
|
|
1154
|
+
}
|
|
1155
|
+
function generateToken() {
|
|
1156
|
+
return randomBytes(32).toString("base64url");
|
|
1157
|
+
}
|
|
1158
|
+
async function rotateToken() {
|
|
1159
|
+
console.error("\n[tandem] Rotating auth token...\n");
|
|
1160
|
+
if (process.env.TANDEM_AUTH_TOKEN) {
|
|
1161
|
+
console.error(
|
|
1162
|
+
"[tandem] Error: TANDEM_AUTH_TOKEN is set in the environment.\n Token rotation is not supported in env-token mode (used by Tauri).\n Unset the variable and let Tandem manage the token file, or rotate\n via your Tauri app's token management instead."
|
|
1163
|
+
);
|
|
1164
|
+
process.exit(1);
|
|
1165
|
+
}
|
|
1166
|
+
const oldToken = await readTokenFromFile();
|
|
1167
|
+
if (!oldToken) {
|
|
1168
|
+
console.error(
|
|
1169
|
+
"[tandem] Error: no token file found. Run `tandem setup` first to initialize the token."
|
|
1170
|
+
);
|
|
1171
|
+
process.exit(1);
|
|
1172
|
+
}
|
|
1173
|
+
const newToken = generateToken();
|
|
1174
|
+
const tokenPath = getTokenFilePath();
|
|
1175
|
+
const dir = path2.dirname(tokenPath);
|
|
1176
|
+
const tmpPath = path2.join(dir, `.auth-token-tmp-${randomBytes(4).toString("hex")}`);
|
|
1177
|
+
try {
|
|
1178
|
+
await fsPromises.writeFile(tmpPath, newToken, { encoding: "utf8", mode: 384 });
|
|
1179
|
+
await fsPromises.rename(tmpPath, tokenPath);
|
|
1180
|
+
} catch (err) {
|
|
1181
|
+
await fsPromises.unlink(tmpPath).catch(() => {
|
|
1182
|
+
});
|
|
1183
|
+
throw err;
|
|
1184
|
+
}
|
|
1185
|
+
const serverUrl = `http://localhost:${DEFAULT_MCP_PORT}`;
|
|
1186
|
+
let graceWindowActive = false;
|
|
1187
|
+
let serverRejected = false;
|
|
1188
|
+
let serverRejectedStatus = 0;
|
|
1189
|
+
try {
|
|
1190
|
+
const resp = await fetch(`${serverUrl}/api/rotate-token`, {
|
|
1191
|
+
method: "POST",
|
|
1192
|
+
headers: {
|
|
1193
|
+
"Content-Type": "application/json",
|
|
1194
|
+
Authorization: `Bearer ${oldToken}`
|
|
1195
|
+
},
|
|
1196
|
+
body: JSON.stringify({}),
|
|
1197
|
+
signal: AbortSignal.timeout(5e3)
|
|
1198
|
+
});
|
|
1199
|
+
if (resp.ok) {
|
|
1200
|
+
graceWindowActive = true;
|
|
1201
|
+
} else {
|
|
1202
|
+
serverRejected = true;
|
|
1203
|
+
serverRejectedStatus = resp.status;
|
|
1204
|
+
}
|
|
1205
|
+
} catch {
|
|
1206
|
+
console.error(
|
|
1207
|
+
"[tandem] Warning: server is not reachable. The new token is written to disk.\n Restart the server to activate the grace window; reconnect Claude Code after."
|
|
1208
|
+
);
|
|
1209
|
+
}
|
|
1210
|
+
let updatedCount = 0;
|
|
1211
|
+
let configErrors = [];
|
|
1212
|
+
try {
|
|
1213
|
+
const result = await applyConfigWithToken(newToken);
|
|
1214
|
+
updatedCount = result.updated;
|
|
1215
|
+
configErrors = result.errors;
|
|
1216
|
+
} catch (err) {
|
|
1217
|
+
console.error(
|
|
1218
|
+
`[tandem] Warning: failed to update MCP configs: ${err instanceof Error ? err.message : String(err)}`
|
|
1219
|
+
);
|
|
1220
|
+
}
|
|
1221
|
+
if (serverRejected) {
|
|
1222
|
+
console.error(
|
|
1223
|
+
`[tandem] WARNING: server rejected the rotation request (status: ${serverRejectedStatus}).`
|
|
1224
|
+
);
|
|
1225
|
+
if (updatedCount > 0) {
|
|
1226
|
+
console.error(
|
|
1227
|
+
` ${updatedCount} config file(s) updated to the new token, but the server still
|
|
1228
|
+
holds the old token. Restart the server to complete rotation.`
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1231
|
+
console.error(` Old fingerprint: ${fingerprint(oldToken)}`);
|
|
1232
|
+
console.error(` New fingerprint: ${fingerprint(newToken)}`);
|
|
1233
|
+
for (const e of configErrors) {
|
|
1234
|
+
console.error(` Warning: could not update config \u2014 ${e}`);
|
|
1235
|
+
}
|
|
1236
|
+
console.error("");
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
console.error("[tandem] Rotated auth token.");
|
|
1240
|
+
console.error(` Old fingerprint: ${fingerprint(oldToken)}`);
|
|
1241
|
+
console.error(` New fingerprint: ${fingerprint(newToken)}`);
|
|
1242
|
+
console.error(` Updated ${updatedCount} config file(s).`);
|
|
1243
|
+
for (const e of configErrors) {
|
|
1244
|
+
console.error(` Warning: could not update config \u2014 ${e}`);
|
|
1245
|
+
}
|
|
1246
|
+
if (graceWindowActive) {
|
|
1247
|
+
console.error(
|
|
1248
|
+
" Old token remains valid for 60 seconds; reconnect Claude Code within that window."
|
|
1249
|
+
);
|
|
1250
|
+
} else {
|
|
1251
|
+
console.error(
|
|
1252
|
+
" Server was not running \u2014 start it with `tandem` and reconnect Claude Code with the new token."
|
|
1253
|
+
);
|
|
1254
|
+
}
|
|
1255
|
+
console.error("");
|
|
1256
|
+
}
|
|
1257
|
+
var init_rotate_token = __esm({
|
|
1258
|
+
"src/cli/rotate-token.ts"() {
|
|
1259
|
+
"use strict";
|
|
1260
|
+
init_token_store();
|
|
1261
|
+
init_constants();
|
|
1262
|
+
init_setup();
|
|
1263
|
+
}
|
|
1264
|
+
});
|
|
1265
|
+
|
|
866
1266
|
// src/cli/start.ts
|
|
867
1267
|
var start_exports = {};
|
|
868
1268
|
__export(start_exports, {
|
|
@@ -905,7 +1305,22 @@ var init_start = __esm({
|
|
|
905
1305
|
|
|
906
1306
|
// src/cli/index.ts
|
|
907
1307
|
import updateNotifier from "update-notifier";
|
|
908
|
-
|
|
1308
|
+
process.once("uncaughtException", (err) => {
|
|
1309
|
+
const msg = err instanceof Error ? err.stack ?? err.message : String(err);
|
|
1310
|
+
try {
|
|
1311
|
+
process.stderr.write(`[tandem cli] uncaughtException: ${msg}
|
|
1312
|
+
`);
|
|
1313
|
+
} catch {
|
|
1314
|
+
}
|
|
1315
|
+
process.exit(1);
|
|
1316
|
+
});
|
|
1317
|
+
process.once("unhandledRejection", (reason) => {
|
|
1318
|
+
const detail = reason instanceof Error ? reason.message : String(reason);
|
|
1319
|
+
process.stderr.write(`[tandem cli] unhandledRejection: ${detail}
|
|
1320
|
+
`);
|
|
1321
|
+
process.exit(1);
|
|
1322
|
+
});
|
|
1323
|
+
var version = true ? "0.7.0" : "0.0.0-dev";
|
|
909
1324
|
var args = process.argv.slice(2);
|
|
910
1325
|
var isStdioMode = args[0] === "mcp-stdio" || args[0] === "channel";
|
|
911
1326
|
if (!isStdioMode) {
|
|
@@ -919,6 +1334,7 @@ Usage:
|
|
|
919
1334
|
tandem setup Register MCP tools with Claude Code / Claude Desktop
|
|
920
1335
|
tandem setup --force Register to default paths regardless of detection
|
|
921
1336
|
tandem setup --with-channel-shim Also register the stdio channel shim (legacy opt-in)
|
|
1337
|
+
tandem rotate-token Rotate the auth token with a 60-second grace window
|
|
922
1338
|
tandem mcp-stdio Run as a stdio MCP server proxying to local HTTP
|
|
923
1339
|
(used by the plugin's Cowork bridge; requires
|
|
924
1340
|
tandem server running on the host)
|
|
@@ -946,6 +1362,9 @@ try {
|
|
|
946
1362
|
} else if (args[0] === "channel") {
|
|
947
1363
|
const { runChannelCli: runChannelCli2 } = await Promise.resolve().then(() => (init_channel(), channel_exports));
|
|
948
1364
|
await runChannelCli2();
|
|
1365
|
+
} else if (args[0] === "rotate-token") {
|
|
1366
|
+
const { rotateToken: rotateToken2 } = await Promise.resolve().then(() => (init_rotate_token(), rotate_token_exports));
|
|
1367
|
+
await rotateToken2();
|
|
949
1368
|
} else if (!args[0] || args[0] === "start") {
|
|
950
1369
|
const { runStart: runStart2 } = await Promise.resolve().then(() => (init_start(), start_exports));
|
|
951
1370
|
runStart2();
|