tandem-editor 0.6.3 → 0.7.1
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 +389 -32
- package/dist/channel/index.js +41 -7
- package/dist/channel/index.js.map +1 -1
- package/dist/cli/index.js +382 -40
- package/dist/cli/index.js.map +1 -1
- package/dist/client/assets/index-DLTxaDBk.js +351 -0
- package/dist/client/index.html +1 -1
- package/dist/monitor/index.js +23 -2
- package/dist/monitor/index.js.map +1 -1
- package/dist/server/index.js +835 -395
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
- package/dist/client/assets/index-B1Cd5UGT.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,
|
|
@@ -49,20 +51,40 @@ __export(setup_exports, {
|
|
|
49
51
|
validateChannelShimPrereq: () => validateChannelShimPrereq
|
|
50
52
|
});
|
|
51
53
|
import { randomUUID } from "crypto";
|
|
52
|
-
import { existsSync, readFileSync as readFileSync2 } from "fs";
|
|
54
|
+
import { existsSync, readdirSync, readFileSync as readFileSync2 } from "fs";
|
|
53
55
|
import { copyFile, mkdir, rename, unlink, writeFile } from "fs/promises";
|
|
54
56
|
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 isDesktop = opts.targetKind === "claude-desktop";
|
|
61
|
+
let tandemEntry;
|
|
62
|
+
if (isDesktop) {
|
|
63
|
+
const env = { TANDEM_URL: MCP_URL };
|
|
64
|
+
if (opts.token) {
|
|
65
|
+
env.TANDEM_AUTH_TOKEN = opts.token;
|
|
66
|
+
}
|
|
67
|
+
tandemEntry = {
|
|
68
|
+
command: "npx",
|
|
69
|
+
args: ["-y", "tandem-editor", "mcp-stdio"],
|
|
70
|
+
env
|
|
71
|
+
};
|
|
72
|
+
} else {
|
|
73
|
+
tandemEntry = { type: "http", url: `${MCP_URL}/mcp` };
|
|
74
|
+
if (opts.token) {
|
|
75
|
+
tandemEntry.headers = { Authorization: `Bearer ${opts.token}` };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const entries = { tandem: tandemEntry };
|
|
61
79
|
if (opts.withChannelShim) {
|
|
80
|
+
const shimEnv = { TANDEM_URL: MCP_URL };
|
|
81
|
+
if (opts.token) {
|
|
82
|
+
shimEnv.TANDEM_AUTH_TOKEN = opts.token;
|
|
83
|
+
}
|
|
62
84
|
entries["tandem-channel"] = {
|
|
63
85
|
command: opts.nodeBinary ?? "node",
|
|
64
86
|
args: [channelPath],
|
|
65
|
-
env:
|
|
87
|
+
env: shimEnv
|
|
66
88
|
};
|
|
67
89
|
}
|
|
68
90
|
return entries;
|
|
@@ -73,7 +95,7 @@ function detectTargets(opts = {}) {
|
|
|
73
95
|
const claudeCodeConfig = join(home, ".claude.json");
|
|
74
96
|
const claudeCodeDir = join(home, ".claude");
|
|
75
97
|
if (opts.force || existsSync(claudeCodeConfig) || existsSync(claudeCodeDir)) {
|
|
76
|
-
targets.push({ label: "Claude Code", configPath: claudeCodeConfig });
|
|
98
|
+
targets.push({ label: "Claude Code", configPath: claudeCodeConfig, kind: "claude-code" });
|
|
77
99
|
}
|
|
78
100
|
let desktopConfig = null;
|
|
79
101
|
if (process.platform === "win32") {
|
|
@@ -91,7 +113,33 @@ function detectTargets(opts = {}) {
|
|
|
91
113
|
desktopConfig = join(home, ".config", "claude", "claude_desktop_config.json");
|
|
92
114
|
}
|
|
93
115
|
if (desktopConfig && (opts.force || existsSync(desktopConfig))) {
|
|
94
|
-
targets.push({ label: "Claude Desktop", configPath: desktopConfig });
|
|
116
|
+
targets.push({ label: "Claude Desktop", configPath: desktopConfig, kind: "claude-desktop" });
|
|
117
|
+
}
|
|
118
|
+
if (process.platform === "win32") {
|
|
119
|
+
const localAppData = opts.localAppDataOverride ?? process.env.LOCALAPPDATA ?? join(home, "AppData", "Local");
|
|
120
|
+
const packagesDir = join(localAppData, "Packages");
|
|
121
|
+
try {
|
|
122
|
+
const entries = readdirSync(packagesDir);
|
|
123
|
+
for (const pkg of entries.filter((n) => n.startsWith("Claude_"))) {
|
|
124
|
+
const msixConfig = join(
|
|
125
|
+
packagesDir,
|
|
126
|
+
pkg,
|
|
127
|
+
"LocalCache",
|
|
128
|
+
"Roaming",
|
|
129
|
+
"Claude",
|
|
130
|
+
"claude_desktop_config.json"
|
|
131
|
+
);
|
|
132
|
+
if (opts.force || existsSync(msixConfig)) {
|
|
133
|
+
const suffix = entries.filter((n) => n.startsWith("Claude_")).length > 1 ? ` (${pkg.slice(0, 12)}\u2026)` : "";
|
|
134
|
+
targets.push({
|
|
135
|
+
label: `Claude Desktop MSIX${suffix}`,
|
|
136
|
+
configPath: msixConfig,
|
|
137
|
+
kind: "claude-desktop"
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
}
|
|
95
143
|
}
|
|
96
144
|
return targets;
|
|
97
145
|
}
|
|
@@ -157,6 +205,25 @@ async function installSkill(opts = {}) {
|
|
|
157
205
|
function validateChannelShimPrereq(channelPath) {
|
|
158
206
|
return existsSync(channelPath);
|
|
159
207
|
}
|
|
208
|
+
async function applyConfigWithToken(token, opts = {}) {
|
|
209
|
+
const targets = detectTargets({ force: opts.force });
|
|
210
|
+
let updated = 0;
|
|
211
|
+
const errors = [];
|
|
212
|
+
for (const t of targets) {
|
|
213
|
+
const entries = buildMcpEntries(CHANNEL_DIST, {
|
|
214
|
+
withChannelShim: opts.withChannelShim,
|
|
215
|
+
token: token ?? void 0,
|
|
216
|
+
targetKind: t.kind
|
|
217
|
+
});
|
|
218
|
+
try {
|
|
219
|
+
await applyConfig(t.configPath, entries);
|
|
220
|
+
updated++;
|
|
221
|
+
} catch (err) {
|
|
222
|
+
errors.push(`${t.label}: ${err instanceof Error ? err.message : String(err)}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return { updated, errors };
|
|
226
|
+
}
|
|
160
227
|
async function runSetup(opts = {}) {
|
|
161
228
|
console.error("\nTandem Setup\n");
|
|
162
229
|
if (opts.withChannelShim && !validateChannelShimPrereq(CHANNEL_DIST)) {
|
|
@@ -178,9 +245,12 @@ Run 'npm run build' first, or drop --with-channel-shim to use the plugin monitor
|
|
|
178
245
|
console.error(` Found: ${t.label} (${t.configPath})`);
|
|
179
246
|
}
|
|
180
247
|
console.error("\nWriting MCP configuration...");
|
|
181
|
-
const entries = buildMcpEntries(CHANNEL_DIST, { withChannelShim: opts.withChannelShim });
|
|
182
248
|
let failures = 0;
|
|
183
249
|
for (const t of targets) {
|
|
250
|
+
const entries = buildMcpEntries(CHANNEL_DIST, {
|
|
251
|
+
withChannelShim: opts.withChannelShim,
|
|
252
|
+
targetKind: t.kind
|
|
253
|
+
});
|
|
184
254
|
try {
|
|
185
255
|
await applyConfig(t.configPath, entries);
|
|
186
256
|
console.error(` \x1B[32m\u2713\x1B[0m ${t.label}`);
|
|
@@ -249,10 +319,30 @@ function resolveTandemUrl(override) {
|
|
|
249
319
|
const raw = override ?? process.env.TANDEM_URL ?? `http://localhost:${DEFAULT_MCP_PORT}`;
|
|
250
320
|
return raw.replace(/\/$/, "");
|
|
251
321
|
}
|
|
322
|
+
async function authFetch(url, init) {
|
|
323
|
+
const token = process.env.TANDEM_AUTH_TOKEN;
|
|
324
|
+
if (token !== void 0 && token.trim() !== "") {
|
|
325
|
+
if (VALID_TOKEN_RE.test(token.trim())) {
|
|
326
|
+
const headers = new Headers(init?.headers);
|
|
327
|
+
headers.set("Authorization", `Bearer ${token.trim()}`);
|
|
328
|
+
return fetch(url, { ...init, headers });
|
|
329
|
+
}
|
|
330
|
+
if (!_warnedInvalidToken) {
|
|
331
|
+
_warnedInvalidToken = true;
|
|
332
|
+
console.error(
|
|
333
|
+
"[tandem] authFetch: TANDEM_AUTH_TOKEN is set but invalid (must be 32+ alphanumeric chars [A-Za-z0-9_-]); sending without Authorization header"
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return fetch(url, init);
|
|
338
|
+
}
|
|
339
|
+
var VALID_TOKEN_RE, _warnedInvalidToken;
|
|
252
340
|
var init_cli_runtime = __esm({
|
|
253
341
|
"src/shared/cli-runtime.ts"() {
|
|
254
342
|
"use strict";
|
|
255
343
|
init_constants();
|
|
344
|
+
VALID_TOKEN_RE = /^[A-Za-z0-9_\-]{32,}$/;
|
|
345
|
+
_warnedInvalidToken = false;
|
|
256
346
|
}
|
|
257
347
|
});
|
|
258
348
|
|
|
@@ -310,15 +400,52 @@ var mcp_stdio_exports = {};
|
|
|
310
400
|
__export(mcp_stdio_exports, {
|
|
311
401
|
getRequestId: () => getRequestId,
|
|
312
402
|
getResponseId: () => getResponseId,
|
|
403
|
+
parseTimeoutMs: () => parseTimeoutMs,
|
|
404
|
+
readAndValidateAuthToken: () => readAndValidateAuthToken,
|
|
313
405
|
runMcpStdio: () => runMcpStdio
|
|
314
406
|
});
|
|
315
407
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
316
408
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
409
|
+
function parseTimeoutMs(raw) {
|
|
410
|
+
if (raw !== void 0) {
|
|
411
|
+
const parsed = parseInt(raw, 10);
|
|
412
|
+
if (Number.isFinite(parsed) && parsed > 0 && parsed <= MAX_TIMEOUT_MS) {
|
|
413
|
+
return parsed;
|
|
414
|
+
}
|
|
415
|
+
process.stderr.write(
|
|
416
|
+
`[tandem mcp-stdio] TANDEM_REQUEST_TIMEOUT_MS must be a positive integer \u2264 ${MAX_TIMEOUT_MS}; ignoring "${raw}", using 30000ms default
|
|
417
|
+
`
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
return 3e4;
|
|
421
|
+
}
|
|
422
|
+
function readAndValidateAuthToken() {
|
|
423
|
+
const raw = process.env.TANDEM_AUTH_TOKEN;
|
|
424
|
+
if (raw === void 0) return null;
|
|
425
|
+
const trimmed = raw.trim();
|
|
426
|
+
if (trimmed === "") return null;
|
|
427
|
+
if (trimmed.startsWith("Bearer ")) {
|
|
428
|
+
process.stderr.write(
|
|
429
|
+
"[tandem mcp-stdio] TANDEM_AUTH_TOKEN is invalid (double-prefix: do not include 'Bearer ' prefix \u2014 supply the raw token only)\n"
|
|
430
|
+
);
|
|
431
|
+
process.exit(1);
|
|
432
|
+
}
|
|
433
|
+
if (!VALID_TOKEN_RE2.test(trimmed)) {
|
|
434
|
+
process.stderr.write(
|
|
435
|
+
"[tandem mcp-stdio] TANDEM_AUTH_TOKEN is malformed (must be 32+ URL-safe characters: [A-Za-z0-9_-])\n"
|
|
436
|
+
);
|
|
437
|
+
process.exit(1);
|
|
438
|
+
}
|
|
439
|
+
return trimmed;
|
|
440
|
+
}
|
|
317
441
|
async function runMcpStdio() {
|
|
318
442
|
const baseUrl = resolveTandemUrl();
|
|
319
|
-
const
|
|
443
|
+
const authToken = readAndValidateAuthToken();
|
|
444
|
+
const http = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`), {
|
|
445
|
+
requestInit: authToken ? { headers: { Authorization: `Bearer ${authToken}` } } : void 0
|
|
446
|
+
});
|
|
320
447
|
const stdio = new StdioServerTransport();
|
|
321
|
-
const
|
|
448
|
+
const pendingRequests = /* @__PURE__ */ new Map();
|
|
322
449
|
const preReadyBuffer = [];
|
|
323
450
|
let shuttingDown = false;
|
|
324
451
|
let httpReady = false;
|
|
@@ -346,14 +473,32 @@ async function runMcpStdio() {
|
|
|
346
473
|
}
|
|
347
474
|
}
|
|
348
475
|
function forwardToUpstream(msg) {
|
|
476
|
+
if (shuttingDown) return;
|
|
349
477
|
const requestId = getRequestId(msg);
|
|
350
|
-
if (requestId !== void 0)
|
|
478
|
+
if (requestId !== void 0) {
|
|
479
|
+
const existing = pendingRequests.get(requestId);
|
|
480
|
+
if (existing) clearTimeout(existing);
|
|
481
|
+
const timeoutHandle = setTimeout(() => {
|
|
482
|
+
if (!pendingRequests.delete(requestId)) return;
|
|
483
|
+
void sendErrorResponse(
|
|
484
|
+
requestId,
|
|
485
|
+
"Tandem HTTP upstream not responding (half-open)",
|
|
486
|
+
`No response after ${STDIO_REQUEST_TIMEOUT_MS}ms`
|
|
487
|
+
);
|
|
488
|
+
}, STDIO_REQUEST_TIMEOUT_MS);
|
|
489
|
+
pendingRequests.set(requestId, timeoutHandle);
|
|
490
|
+
}
|
|
351
491
|
http.send(msg).catch((err) => {
|
|
352
492
|
const detail = err instanceof Error ? err.message : String(err);
|
|
353
493
|
process.stderr.write(`[tandem mcp-stdio] upstream send failed: ${detail}
|
|
354
494
|
`);
|
|
355
|
-
if (requestId !== void 0
|
|
356
|
-
|
|
495
|
+
if (requestId !== void 0) {
|
|
496
|
+
const handle = pendingRequests.get(requestId);
|
|
497
|
+
if (handle !== void 0) {
|
|
498
|
+
pendingRequests.delete(requestId);
|
|
499
|
+
clearTimeout(handle);
|
|
500
|
+
void sendErrorResponse(requestId, "Tandem HTTP upstream unreachable", detail);
|
|
501
|
+
}
|
|
357
502
|
}
|
|
358
503
|
});
|
|
359
504
|
}
|
|
@@ -365,14 +510,17 @@ async function runMcpStdio() {
|
|
|
365
510
|
}
|
|
366
511
|
}
|
|
367
512
|
async function synthesizePending(message, detail) {
|
|
368
|
-
if (
|
|
369
|
-
const ids = [...
|
|
370
|
-
|
|
513
|
+
if (pendingRequests.size === 0) return;
|
|
514
|
+
const ids = [...pendingRequests.keys()];
|
|
515
|
+
for (const handle of pendingRequests.values()) clearTimeout(handle);
|
|
516
|
+
pendingRequests.clear();
|
|
371
517
|
await Promise.all(ids.map((id) => sendErrorResponse(id, message, detail)));
|
|
372
518
|
}
|
|
373
519
|
const shutdown = async (code = 0, synth) => {
|
|
374
520
|
if (!shuttingDown) {
|
|
375
521
|
shuttingDown = true;
|
|
522
|
+
setTimeout(() => process.exit(code), 2e3).unref();
|
|
523
|
+
for (const handle of pendingRequests.values()) clearTimeout(handle);
|
|
376
524
|
if (synth) {
|
|
377
525
|
await synthesizeBuffered(synth.message, synth.detail);
|
|
378
526
|
await synthesizePending(synth.message, synth.detail);
|
|
@@ -401,23 +549,34 @@ async function runMcpStdio() {
|
|
|
401
549
|
forwardToUpstream(msg);
|
|
402
550
|
};
|
|
403
551
|
http.onmessage = (msg) => {
|
|
552
|
+
if (shuttingDown) return;
|
|
404
553
|
const responseId = getResponseId(msg);
|
|
405
|
-
|
|
406
|
-
()
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
554
|
+
if (responseId !== void 0) {
|
|
555
|
+
const handle = pendingRequests.get(responseId);
|
|
556
|
+
if (handle !== void 0) {
|
|
557
|
+
clearTimeout(handle);
|
|
558
|
+
pendingRequests.delete(responseId);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
const sendHandler = (err) => {
|
|
562
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
563
|
+
process.stderr.write(
|
|
564
|
+
`[tandem mcp-stdio] stdio write failed for id ${responseId ?? "<notification>"}: ${detail}
|
|
413
565
|
`
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
detail
|
|
418
|
-
});
|
|
566
|
+
);
|
|
567
|
+
if (responseId !== void 0) {
|
|
568
|
+
void sendErrorResponse(responseId, "Tandem stdio write failed", detail);
|
|
419
569
|
}
|
|
420
|
-
|
|
570
|
+
void shutdown(1, {
|
|
571
|
+
message: "Tandem stdio write failed",
|
|
572
|
+
detail
|
|
573
|
+
});
|
|
574
|
+
};
|
|
575
|
+
try {
|
|
576
|
+
stdio.send(msg).catch(sendHandler);
|
|
577
|
+
} catch (err) {
|
|
578
|
+
sendHandler(err);
|
|
579
|
+
}
|
|
421
580
|
};
|
|
422
581
|
stdio.onerror = (err) => {
|
|
423
582
|
process.stderr.write(`[tandem mcp-stdio] stdio error: ${err.message}
|
|
@@ -444,6 +603,9 @@ cause: ${cause}` : ""}
|
|
|
444
603
|
});
|
|
445
604
|
};
|
|
446
605
|
await stdio.start();
|
|
606
|
+
process.stdin.once("end", () => {
|
|
607
|
+
void shutdown(0);
|
|
608
|
+
});
|
|
447
609
|
const probe = await probeTandemServer({ url: baseUrl });
|
|
448
610
|
if (!probe.ok) {
|
|
449
611
|
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.";
|
|
@@ -481,7 +643,7 @@ function getResponseId(msg) {
|
|
|
481
643
|
if (typeof m.id === "string" || typeof m.id === "number") return m.id;
|
|
482
644
|
return void 0;
|
|
483
645
|
}
|
|
484
|
-
var PREFLIGHT_GRACE_MS;
|
|
646
|
+
var PREFLIGHT_GRACE_MS, MAX_TIMEOUT_MS, STDIO_REQUEST_TIMEOUT_MS, VALID_TOKEN_RE2;
|
|
485
647
|
var init_mcp_stdio = __esm({
|
|
486
648
|
"src/cli/mcp-stdio.ts"() {
|
|
487
649
|
"use strict";
|
|
@@ -489,6 +651,8 @@ var init_mcp_stdio = __esm({
|
|
|
489
651
|
init_preflight();
|
|
490
652
|
redirectConsoleToStderr();
|
|
491
653
|
PREFLIGHT_GRACE_MS = 1500;
|
|
654
|
+
MAX_TIMEOUT_MS = 2147483647;
|
|
655
|
+
STDIO_REQUEST_TIMEOUT_MS = parseTimeoutMs(process.env.TANDEM_REQUEST_TIMEOUT_MS);
|
|
492
656
|
process.once("uncaughtException", (err) => {
|
|
493
657
|
process.stderr.write(
|
|
494
658
|
`[tandem mcp-stdio] uncaughtException: ${err.message}
|
|
@@ -503,6 +667,7 @@ ${err.stack ?? ""}
|
|
|
503
667
|
`);
|
|
504
668
|
process.exit(1);
|
|
505
669
|
});
|
|
670
|
+
VALID_TOKEN_RE2 = /^[A-Za-z0-9_\-]{32,}$/;
|
|
506
671
|
}
|
|
507
672
|
});
|
|
508
673
|
|
|
@@ -634,7 +799,7 @@ async function startEventBridge(mcp, tandemUrl) {
|
|
|
634
799
|
if (retries >= CHANNEL_MAX_RETRIES) {
|
|
635
800
|
console.error("[Channel] SSE connection exhausted, reporting error and exiting");
|
|
636
801
|
try {
|
|
637
|
-
await
|
|
802
|
+
await authFetch(`${tandemUrl}/api/channel-error`, {
|
|
638
803
|
method: "POST",
|
|
639
804
|
headers: { "Content-Type": "application/json" },
|
|
640
805
|
body: JSON.stringify({
|
|
@@ -657,7 +822,7 @@ async function startEventBridge(mcp, tandemUrl) {
|
|
|
657
822
|
async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
|
|
658
823
|
const headers = { Accept: "text/event-stream" };
|
|
659
824
|
if (lastEventId) headers["Last-Event-ID"] = lastEventId;
|
|
660
|
-
const res = await
|
|
825
|
+
const res = await authFetch(`${tandemUrl}/api/events`, { headers });
|
|
661
826
|
if (!res.ok) throw new Error(`SSE endpoint returned ${res.status}`);
|
|
662
827
|
if (!res.body) throw new Error("SSE endpoint returned no body");
|
|
663
828
|
const reader = res.body.getReader();
|
|
@@ -668,7 +833,7 @@ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
|
|
|
668
833
|
let pendingAwareness = null;
|
|
669
834
|
const AWARENESS_CLEAR_MS = 3e3;
|
|
670
835
|
function clearAwareness(documentId) {
|
|
671
|
-
|
|
836
|
+
authFetch(`${tandemUrl}/api/channel-awareness`, {
|
|
672
837
|
method: "POST",
|
|
673
838
|
headers: { "Content-Type": "application/json" },
|
|
674
839
|
body: JSON.stringify({
|
|
@@ -683,7 +848,7 @@ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
|
|
|
683
848
|
if (!pendingAwareness) return;
|
|
684
849
|
const event = pendingAwareness;
|
|
685
850
|
pendingAwareness = null;
|
|
686
|
-
|
|
851
|
+
authFetch(`${tandemUrl}/api/channel-awareness`, {
|
|
687
852
|
method: "POST",
|
|
688
853
|
headers: { "Content-Type": "application/json" },
|
|
689
854
|
body: JSON.stringify({
|
|
@@ -758,7 +923,7 @@ async function getCachedMode(tandemUrl) {
|
|
|
758
923
|
const now = Date.now();
|
|
759
924
|
if (now - cachedModeAt < MODE_CACHE_TTL_MS) return cachedMode;
|
|
760
925
|
try {
|
|
761
|
-
const res = await
|
|
926
|
+
const res = await authFetch(`${tandemUrl}/api/mode`);
|
|
762
927
|
if (res.ok) {
|
|
763
928
|
const { mode } = await res.json();
|
|
764
929
|
cachedMode = mode;
|
|
@@ -780,6 +945,7 @@ var init_event_bridge = __esm({
|
|
|
780
945
|
"src/channel/event-bridge.ts"() {
|
|
781
946
|
"use strict";
|
|
782
947
|
init_types();
|
|
948
|
+
init_cli_runtime();
|
|
783
949
|
init_constants();
|
|
784
950
|
AWARENESS_DEBOUNCE_MS = 500;
|
|
785
951
|
MODE_CACHE_TTL_MS = 2e3;
|
|
@@ -847,7 +1013,7 @@ async function runChannel(opts = {}) {
|
|
|
847
1013
|
if (req.params.name === "tandem_reply") {
|
|
848
1014
|
const args2 = req.params.arguments;
|
|
849
1015
|
try {
|
|
850
|
-
const res = await
|
|
1016
|
+
const res = await authFetch(`${tandemUrl}/api/channel-reply`, {
|
|
851
1017
|
method: "POST",
|
|
852
1018
|
headers: { "Content-Type": "application/json" },
|
|
853
1019
|
body: JSON.stringify(args2)
|
|
@@ -895,7 +1061,7 @@ async function runChannel(opts = {}) {
|
|
|
895
1061
|
});
|
|
896
1062
|
mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
|
|
897
1063
|
try {
|
|
898
|
-
const res = await
|
|
1064
|
+
const res = await authFetch(`${tandemUrl}/api/channel-permission`, {
|
|
899
1065
|
method: "POST",
|
|
900
1066
|
headers: { "Content-Type": "application/json" },
|
|
901
1067
|
body: JSON.stringify({
|
|
@@ -984,6 +1150,163 @@ var init_channel = __esm({
|
|
|
984
1150
|
}
|
|
985
1151
|
});
|
|
986
1152
|
|
|
1153
|
+
// src/server/auth/token-store.ts
|
|
1154
|
+
import envPaths from "env-paths";
|
|
1155
|
+
import fs from "fs";
|
|
1156
|
+
import path from "path";
|
|
1157
|
+
function getTokenFilePath() {
|
|
1158
|
+
return path.join(envPaths("tandem", { suffix: "" }).data, TOKEN_FILE_NAME);
|
|
1159
|
+
}
|
|
1160
|
+
async function readTokenFromFile() {
|
|
1161
|
+
const filePath = getTokenFilePath();
|
|
1162
|
+
try {
|
|
1163
|
+
const content = await fs.promises.readFile(filePath, "utf8");
|
|
1164
|
+
if (process.platform !== "win32") {
|
|
1165
|
+
try {
|
|
1166
|
+
const stat = await fs.promises.stat(filePath);
|
|
1167
|
+
if ((stat.mode & 63) !== 0) {
|
|
1168
|
+
console.error("[tandem] auth token file has insecure permissions; attempting chmod 0600");
|
|
1169
|
+
await fs.promises.chmod(filePath, 384);
|
|
1170
|
+
}
|
|
1171
|
+
} catch {
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
const trimmed = content.trim();
|
|
1175
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
1176
|
+
} catch (err) {
|
|
1177
|
+
if (err.code === "ENOENT") return null;
|
|
1178
|
+
throw err;
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
var init_token_store = __esm({
|
|
1182
|
+
"src/server/auth/token-store.ts"() {
|
|
1183
|
+
"use strict";
|
|
1184
|
+
init_constants();
|
|
1185
|
+
}
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
// src/cli/rotate-token.ts
|
|
1189
|
+
var rotate_token_exports = {};
|
|
1190
|
+
__export(rotate_token_exports, {
|
|
1191
|
+
rotateToken: () => rotateToken
|
|
1192
|
+
});
|
|
1193
|
+
import { createHash, randomBytes } from "crypto";
|
|
1194
|
+
import { promises as fsPromises } from "fs";
|
|
1195
|
+
import path2 from "path";
|
|
1196
|
+
function fingerprint(token) {
|
|
1197
|
+
return createHash("sha256").update(token, "utf8").digest("hex").slice(0, 8);
|
|
1198
|
+
}
|
|
1199
|
+
function generateToken() {
|
|
1200
|
+
return randomBytes(32).toString("base64url");
|
|
1201
|
+
}
|
|
1202
|
+
async function rotateToken() {
|
|
1203
|
+
console.error("\n[tandem] Rotating auth token...\n");
|
|
1204
|
+
if (process.env.TANDEM_AUTH_TOKEN) {
|
|
1205
|
+
console.error(
|
|
1206
|
+
"[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."
|
|
1207
|
+
);
|
|
1208
|
+
process.exit(1);
|
|
1209
|
+
}
|
|
1210
|
+
const oldToken = await readTokenFromFile();
|
|
1211
|
+
if (!oldToken) {
|
|
1212
|
+
console.error(
|
|
1213
|
+
"[tandem] Error: no token file found. Run `tandem setup` first to initialize the token."
|
|
1214
|
+
);
|
|
1215
|
+
process.exit(1);
|
|
1216
|
+
}
|
|
1217
|
+
const newToken = generateToken();
|
|
1218
|
+
const tokenPath = getTokenFilePath();
|
|
1219
|
+
const dir = path2.dirname(tokenPath);
|
|
1220
|
+
const tmpPath = path2.join(dir, `.auth-token-tmp-${randomBytes(4).toString("hex")}`);
|
|
1221
|
+
try {
|
|
1222
|
+
await fsPromises.writeFile(tmpPath, newToken, { encoding: "utf8", mode: 384 });
|
|
1223
|
+
await fsPromises.rename(tmpPath, tokenPath);
|
|
1224
|
+
} catch (err) {
|
|
1225
|
+
await fsPromises.unlink(tmpPath).catch(() => {
|
|
1226
|
+
});
|
|
1227
|
+
throw err;
|
|
1228
|
+
}
|
|
1229
|
+
const serverUrl = `http://localhost:${DEFAULT_MCP_PORT}`;
|
|
1230
|
+
let graceWindowActive = false;
|
|
1231
|
+
let serverRejected = false;
|
|
1232
|
+
let serverRejectedStatus = 0;
|
|
1233
|
+
try {
|
|
1234
|
+
const resp = await fetch(`${serverUrl}/api/rotate-token`, {
|
|
1235
|
+
method: "POST",
|
|
1236
|
+
headers: {
|
|
1237
|
+
"Content-Type": "application/json",
|
|
1238
|
+
Authorization: `Bearer ${oldToken}`
|
|
1239
|
+
},
|
|
1240
|
+
body: JSON.stringify({}),
|
|
1241
|
+
signal: AbortSignal.timeout(5e3)
|
|
1242
|
+
});
|
|
1243
|
+
if (resp.ok) {
|
|
1244
|
+
graceWindowActive = true;
|
|
1245
|
+
} else {
|
|
1246
|
+
serverRejected = true;
|
|
1247
|
+
serverRejectedStatus = resp.status;
|
|
1248
|
+
}
|
|
1249
|
+
} catch {
|
|
1250
|
+
console.error(
|
|
1251
|
+
"[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."
|
|
1252
|
+
);
|
|
1253
|
+
}
|
|
1254
|
+
let updatedCount = 0;
|
|
1255
|
+
let configErrors = [];
|
|
1256
|
+
try {
|
|
1257
|
+
const result = await applyConfigWithToken(newToken);
|
|
1258
|
+
updatedCount = result.updated;
|
|
1259
|
+
configErrors = result.errors;
|
|
1260
|
+
} catch (err) {
|
|
1261
|
+
console.error(
|
|
1262
|
+
`[tandem] Warning: failed to update MCP configs: ${err instanceof Error ? err.message : String(err)}`
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
if (serverRejected) {
|
|
1266
|
+
console.error(
|
|
1267
|
+
`[tandem] WARNING: server rejected the rotation request (status: ${serverRejectedStatus}).`
|
|
1268
|
+
);
|
|
1269
|
+
if (updatedCount > 0) {
|
|
1270
|
+
console.error(
|
|
1271
|
+
` ${updatedCount} config file(s) updated to the new token, but the server still
|
|
1272
|
+
holds the old token. Restart the server to complete rotation.`
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
console.error(` Old fingerprint: ${fingerprint(oldToken)}`);
|
|
1276
|
+
console.error(` New fingerprint: ${fingerprint(newToken)}`);
|
|
1277
|
+
for (const e of configErrors) {
|
|
1278
|
+
console.error(` Warning: could not update config \u2014 ${e}`);
|
|
1279
|
+
}
|
|
1280
|
+
console.error("");
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
console.error("[tandem] Rotated auth token.");
|
|
1284
|
+
console.error(` Old fingerprint: ${fingerprint(oldToken)}`);
|
|
1285
|
+
console.error(` New fingerprint: ${fingerprint(newToken)}`);
|
|
1286
|
+
console.error(` Updated ${updatedCount} config file(s).`);
|
|
1287
|
+
for (const e of configErrors) {
|
|
1288
|
+
console.error(` Warning: could not update config \u2014 ${e}`);
|
|
1289
|
+
}
|
|
1290
|
+
if (graceWindowActive) {
|
|
1291
|
+
console.error(
|
|
1292
|
+
" Old token remains valid for 60 seconds; reconnect Claude Code within that window."
|
|
1293
|
+
);
|
|
1294
|
+
} else {
|
|
1295
|
+
console.error(
|
|
1296
|
+
" Server was not running \u2014 start it with `tandem` and reconnect Claude Code with the new token."
|
|
1297
|
+
);
|
|
1298
|
+
}
|
|
1299
|
+
console.error("");
|
|
1300
|
+
}
|
|
1301
|
+
var init_rotate_token = __esm({
|
|
1302
|
+
"src/cli/rotate-token.ts"() {
|
|
1303
|
+
"use strict";
|
|
1304
|
+
init_token_store();
|
|
1305
|
+
init_constants();
|
|
1306
|
+
init_setup();
|
|
1307
|
+
}
|
|
1308
|
+
});
|
|
1309
|
+
|
|
987
1310
|
// src/cli/start.ts
|
|
988
1311
|
var start_exports = {};
|
|
989
1312
|
__export(start_exports, {
|
|
@@ -1026,7 +1349,22 @@ var init_start = __esm({
|
|
|
1026
1349
|
|
|
1027
1350
|
// src/cli/index.ts
|
|
1028
1351
|
import updateNotifier from "update-notifier";
|
|
1029
|
-
|
|
1352
|
+
process.once("uncaughtException", (err) => {
|
|
1353
|
+
const msg = err instanceof Error ? err.stack ?? err.message : String(err);
|
|
1354
|
+
try {
|
|
1355
|
+
process.stderr.write(`[tandem cli] uncaughtException: ${msg}
|
|
1356
|
+
`);
|
|
1357
|
+
} catch {
|
|
1358
|
+
}
|
|
1359
|
+
process.exit(1);
|
|
1360
|
+
});
|
|
1361
|
+
process.once("unhandledRejection", (reason) => {
|
|
1362
|
+
const detail = reason instanceof Error ? reason.message : String(reason);
|
|
1363
|
+
process.stderr.write(`[tandem cli] unhandledRejection: ${detail}
|
|
1364
|
+
`);
|
|
1365
|
+
process.exit(1);
|
|
1366
|
+
});
|
|
1367
|
+
var version = true ? "0.7.1" : "0.0.0-dev";
|
|
1030
1368
|
var args = process.argv.slice(2);
|
|
1031
1369
|
var isStdioMode = args[0] === "mcp-stdio" || args[0] === "channel";
|
|
1032
1370
|
if (!isStdioMode) {
|
|
@@ -1040,6 +1378,7 @@ Usage:
|
|
|
1040
1378
|
tandem setup Register MCP tools with Claude Code / Claude Desktop
|
|
1041
1379
|
tandem setup --force Register to default paths regardless of detection
|
|
1042
1380
|
tandem setup --with-channel-shim Also register the stdio channel shim (legacy opt-in)
|
|
1381
|
+
tandem rotate-token Rotate the auth token with a 60-second grace window
|
|
1043
1382
|
tandem mcp-stdio Run as a stdio MCP server proxying to local HTTP
|
|
1044
1383
|
(used by the plugin's Cowork bridge; requires
|
|
1045
1384
|
tandem server running on the host)
|
|
@@ -1067,6 +1406,9 @@ try {
|
|
|
1067
1406
|
} else if (args[0] === "channel") {
|
|
1068
1407
|
const { runChannelCli: runChannelCli2 } = await Promise.resolve().then(() => (init_channel(), channel_exports));
|
|
1069
1408
|
await runChannelCli2();
|
|
1409
|
+
} else if (args[0] === "rotate-token") {
|
|
1410
|
+
const { rotateToken: rotateToken2 } = await Promise.resolve().then(() => (init_rotate_token(), rotate_token_exports));
|
|
1411
|
+
await rotateToken2();
|
|
1070
1412
|
} else if (!args[0] || args[0] === "start") {
|
|
1071
1413
|
const { runStart: runStart2 } = await Promise.resolve().then(() => (init_start(), start_exports));
|
|
1072
1414
|
runStart2();
|