tandem-editor 0.6.3 → 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 +389 -32
- package/dist/channel/index.js +41 -7
- package/dist/channel/index.js.map +1 -1
- package/dist/cli/index.js +334 -36
- package/dist/cli/index.js.map +1 -1
- package/dist/monitor/index.js +23 -2
- package/dist/monitor/index.js.map +1 -1
- package/dist/server/index.js +787 -392
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
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,10 +275,30 @@ 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
|
|
|
@@ -310,15 +356,52 @@ var mcp_stdio_exports = {};
|
|
|
310
356
|
__export(mcp_stdio_exports, {
|
|
311
357
|
getRequestId: () => getRequestId,
|
|
312
358
|
getResponseId: () => getResponseId,
|
|
359
|
+
parseTimeoutMs: () => parseTimeoutMs,
|
|
360
|
+
readAndValidateAuthToken: () => readAndValidateAuthToken,
|
|
313
361
|
runMcpStdio: () => runMcpStdio
|
|
314
362
|
});
|
|
315
363
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
316
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
|
+
}
|
|
317
397
|
async function runMcpStdio() {
|
|
318
398
|
const baseUrl = resolveTandemUrl();
|
|
319
|
-
const
|
|
399
|
+
const authToken = readAndValidateAuthToken();
|
|
400
|
+
const http = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`), {
|
|
401
|
+
requestInit: authToken ? { headers: { Authorization: `Bearer ${authToken}` } } : void 0
|
|
402
|
+
});
|
|
320
403
|
const stdio = new StdioServerTransport();
|
|
321
|
-
const
|
|
404
|
+
const pendingRequests = /* @__PURE__ */ new Map();
|
|
322
405
|
const preReadyBuffer = [];
|
|
323
406
|
let shuttingDown = false;
|
|
324
407
|
let httpReady = false;
|
|
@@ -346,14 +429,32 @@ async function runMcpStdio() {
|
|
|
346
429
|
}
|
|
347
430
|
}
|
|
348
431
|
function forwardToUpstream(msg) {
|
|
432
|
+
if (shuttingDown) return;
|
|
349
433
|
const requestId = getRequestId(msg);
|
|
350
|
-
if (requestId !== void 0)
|
|
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);
|
|
446
|
+
}
|
|
351
447
|
http.send(msg).catch((err) => {
|
|
352
448
|
const detail = err instanceof Error ? err.message : String(err);
|
|
353
449
|
process.stderr.write(`[tandem mcp-stdio] upstream send failed: ${detail}
|
|
354
450
|
`);
|
|
355
|
-
if (requestId !== void 0
|
|
356
|
-
|
|
451
|
+
if (requestId !== void 0) {
|
|
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
|
+
}
|
|
357
458
|
}
|
|
358
459
|
});
|
|
359
460
|
}
|
|
@@ -365,14 +466,17 @@ async function runMcpStdio() {
|
|
|
365
466
|
}
|
|
366
467
|
}
|
|
367
468
|
async function synthesizePending(message, detail) {
|
|
368
|
-
if (
|
|
369
|
-
const ids = [...
|
|
370
|
-
|
|
469
|
+
if (pendingRequests.size === 0) return;
|
|
470
|
+
const ids = [...pendingRequests.keys()];
|
|
471
|
+
for (const handle of pendingRequests.values()) clearTimeout(handle);
|
|
472
|
+
pendingRequests.clear();
|
|
371
473
|
await Promise.all(ids.map((id) => sendErrorResponse(id, message, detail)));
|
|
372
474
|
}
|
|
373
475
|
const shutdown = async (code = 0, synth) => {
|
|
374
476
|
if (!shuttingDown) {
|
|
375
477
|
shuttingDown = true;
|
|
478
|
+
setTimeout(() => process.exit(code), 2e3).unref();
|
|
479
|
+
for (const handle of pendingRequests.values()) clearTimeout(handle);
|
|
376
480
|
if (synth) {
|
|
377
481
|
await synthesizeBuffered(synth.message, synth.detail);
|
|
378
482
|
await synthesizePending(synth.message, synth.detail);
|
|
@@ -401,23 +505,34 @@ async function runMcpStdio() {
|
|
|
401
505
|
forwardToUpstream(msg);
|
|
402
506
|
};
|
|
403
507
|
http.onmessage = (msg) => {
|
|
508
|
+
if (shuttingDown) return;
|
|
404
509
|
const responseId = getResponseId(msg);
|
|
405
|
-
|
|
406
|
-
()
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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) => {
|
|
518
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
519
|
+
process.stderr.write(
|
|
520
|
+
`[tandem mcp-stdio] stdio write failed for id ${responseId ?? "<notification>"}: ${detail}
|
|
413
521
|
`
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
detail
|
|
418
|
-
});
|
|
522
|
+
);
|
|
523
|
+
if (responseId !== void 0) {
|
|
524
|
+
void sendErrorResponse(responseId, "Tandem stdio write failed", detail);
|
|
419
525
|
}
|
|
420
|
-
|
|
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
|
+
}
|
|
421
536
|
};
|
|
422
537
|
stdio.onerror = (err) => {
|
|
423
538
|
process.stderr.write(`[tandem mcp-stdio] stdio error: ${err.message}
|
|
@@ -444,6 +559,9 @@ cause: ${cause}` : ""}
|
|
|
444
559
|
});
|
|
445
560
|
};
|
|
446
561
|
await stdio.start();
|
|
562
|
+
process.stdin.once("end", () => {
|
|
563
|
+
void shutdown(0);
|
|
564
|
+
});
|
|
447
565
|
const probe = await probeTandemServer({ url: baseUrl });
|
|
448
566
|
if (!probe.ok) {
|
|
449
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.";
|
|
@@ -481,7 +599,7 @@ function getResponseId(msg) {
|
|
|
481
599
|
if (typeof m.id === "string" || typeof m.id === "number") return m.id;
|
|
482
600
|
return void 0;
|
|
483
601
|
}
|
|
484
|
-
var PREFLIGHT_GRACE_MS;
|
|
602
|
+
var PREFLIGHT_GRACE_MS, MAX_TIMEOUT_MS, STDIO_REQUEST_TIMEOUT_MS, VALID_TOKEN_RE2;
|
|
485
603
|
var init_mcp_stdio = __esm({
|
|
486
604
|
"src/cli/mcp-stdio.ts"() {
|
|
487
605
|
"use strict";
|
|
@@ -489,6 +607,8 @@ var init_mcp_stdio = __esm({
|
|
|
489
607
|
init_preflight();
|
|
490
608
|
redirectConsoleToStderr();
|
|
491
609
|
PREFLIGHT_GRACE_MS = 1500;
|
|
610
|
+
MAX_TIMEOUT_MS = 2147483647;
|
|
611
|
+
STDIO_REQUEST_TIMEOUT_MS = parseTimeoutMs(process.env.TANDEM_REQUEST_TIMEOUT_MS);
|
|
492
612
|
process.once("uncaughtException", (err) => {
|
|
493
613
|
process.stderr.write(
|
|
494
614
|
`[tandem mcp-stdio] uncaughtException: ${err.message}
|
|
@@ -503,6 +623,7 @@ ${err.stack ?? ""}
|
|
|
503
623
|
`);
|
|
504
624
|
process.exit(1);
|
|
505
625
|
});
|
|
626
|
+
VALID_TOKEN_RE2 = /^[A-Za-z0-9_\-]{32,}$/;
|
|
506
627
|
}
|
|
507
628
|
});
|
|
508
629
|
|
|
@@ -634,7 +755,7 @@ async function startEventBridge(mcp, tandemUrl) {
|
|
|
634
755
|
if (retries >= CHANNEL_MAX_RETRIES) {
|
|
635
756
|
console.error("[Channel] SSE connection exhausted, reporting error and exiting");
|
|
636
757
|
try {
|
|
637
|
-
await
|
|
758
|
+
await authFetch(`${tandemUrl}/api/channel-error`, {
|
|
638
759
|
method: "POST",
|
|
639
760
|
headers: { "Content-Type": "application/json" },
|
|
640
761
|
body: JSON.stringify({
|
|
@@ -657,7 +778,7 @@ async function startEventBridge(mcp, tandemUrl) {
|
|
|
657
778
|
async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
|
|
658
779
|
const headers = { Accept: "text/event-stream" };
|
|
659
780
|
if (lastEventId) headers["Last-Event-ID"] = lastEventId;
|
|
660
|
-
const res = await
|
|
781
|
+
const res = await authFetch(`${tandemUrl}/api/events`, { headers });
|
|
661
782
|
if (!res.ok) throw new Error(`SSE endpoint returned ${res.status}`);
|
|
662
783
|
if (!res.body) throw new Error("SSE endpoint returned no body");
|
|
663
784
|
const reader = res.body.getReader();
|
|
@@ -668,7 +789,7 @@ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
|
|
|
668
789
|
let pendingAwareness = null;
|
|
669
790
|
const AWARENESS_CLEAR_MS = 3e3;
|
|
670
791
|
function clearAwareness(documentId) {
|
|
671
|
-
|
|
792
|
+
authFetch(`${tandemUrl}/api/channel-awareness`, {
|
|
672
793
|
method: "POST",
|
|
673
794
|
headers: { "Content-Type": "application/json" },
|
|
674
795
|
body: JSON.stringify({
|
|
@@ -683,7 +804,7 @@ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
|
|
|
683
804
|
if (!pendingAwareness) return;
|
|
684
805
|
const event = pendingAwareness;
|
|
685
806
|
pendingAwareness = null;
|
|
686
|
-
|
|
807
|
+
authFetch(`${tandemUrl}/api/channel-awareness`, {
|
|
687
808
|
method: "POST",
|
|
688
809
|
headers: { "Content-Type": "application/json" },
|
|
689
810
|
body: JSON.stringify({
|
|
@@ -758,7 +879,7 @@ async function getCachedMode(tandemUrl) {
|
|
|
758
879
|
const now = Date.now();
|
|
759
880
|
if (now - cachedModeAt < MODE_CACHE_TTL_MS) return cachedMode;
|
|
760
881
|
try {
|
|
761
|
-
const res = await
|
|
882
|
+
const res = await authFetch(`${tandemUrl}/api/mode`);
|
|
762
883
|
if (res.ok) {
|
|
763
884
|
const { mode } = await res.json();
|
|
764
885
|
cachedMode = mode;
|
|
@@ -780,6 +901,7 @@ var init_event_bridge = __esm({
|
|
|
780
901
|
"src/channel/event-bridge.ts"() {
|
|
781
902
|
"use strict";
|
|
782
903
|
init_types();
|
|
904
|
+
init_cli_runtime();
|
|
783
905
|
init_constants();
|
|
784
906
|
AWARENESS_DEBOUNCE_MS = 500;
|
|
785
907
|
MODE_CACHE_TTL_MS = 2e3;
|
|
@@ -847,7 +969,7 @@ async function runChannel(opts = {}) {
|
|
|
847
969
|
if (req.params.name === "tandem_reply") {
|
|
848
970
|
const args2 = req.params.arguments;
|
|
849
971
|
try {
|
|
850
|
-
const res = await
|
|
972
|
+
const res = await authFetch(`${tandemUrl}/api/channel-reply`, {
|
|
851
973
|
method: "POST",
|
|
852
974
|
headers: { "Content-Type": "application/json" },
|
|
853
975
|
body: JSON.stringify(args2)
|
|
@@ -895,7 +1017,7 @@ async function runChannel(opts = {}) {
|
|
|
895
1017
|
});
|
|
896
1018
|
mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
|
|
897
1019
|
try {
|
|
898
|
-
const res = await
|
|
1020
|
+
const res = await authFetch(`${tandemUrl}/api/channel-permission`, {
|
|
899
1021
|
method: "POST",
|
|
900
1022
|
headers: { "Content-Type": "application/json" },
|
|
901
1023
|
body: JSON.stringify({
|
|
@@ -984,6 +1106,163 @@ var init_channel = __esm({
|
|
|
984
1106
|
}
|
|
985
1107
|
});
|
|
986
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
|
+
|
|
987
1266
|
// src/cli/start.ts
|
|
988
1267
|
var start_exports = {};
|
|
989
1268
|
__export(start_exports, {
|
|
@@ -1026,7 +1305,22 @@ var init_start = __esm({
|
|
|
1026
1305
|
|
|
1027
1306
|
// src/cli/index.ts
|
|
1028
1307
|
import updateNotifier from "update-notifier";
|
|
1029
|
-
|
|
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";
|
|
1030
1324
|
var args = process.argv.slice(2);
|
|
1031
1325
|
var isStdioMode = args[0] === "mcp-stdio" || args[0] === "channel";
|
|
1032
1326
|
if (!isStdioMode) {
|
|
@@ -1040,6 +1334,7 @@ Usage:
|
|
|
1040
1334
|
tandem setup Register MCP tools with Claude Code / Claude Desktop
|
|
1041
1335
|
tandem setup --force Register to default paths regardless of detection
|
|
1042
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
|
|
1043
1338
|
tandem mcp-stdio Run as a stdio MCP server proxying to local HTTP
|
|
1044
1339
|
(used by the plugin's Cowork bridge; requires
|
|
1045
1340
|
tandem server running on the host)
|
|
@@ -1067,6 +1362,9 @@ try {
|
|
|
1067
1362
|
} else if (args[0] === "channel") {
|
|
1068
1363
|
const { runChannelCli: runChannelCli2 } = await Promise.resolve().then(() => (init_channel(), channel_exports));
|
|
1069
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();
|
|
1070
1368
|
} else if (!args[0] || args[0] === "start") {
|
|
1071
1369
|
const { runStart: runStart2 } = await Promise.resolve().then(() => (init_start(), start_exports));
|
|
1072
1370
|
runStart2();
|