teamshare-bridge 0.26.15 → 0.26.17
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/dist/bridge/daemon.js +13 -3
- package/dist/bridge/daemon.js.map +1 -1
- package/dist/bridge/local-server.d.ts +52 -8
- package/dist/bridge/local-server.js +449 -50
- package/dist/bridge/local-server.js.map +1 -1
- package/dist/cli/commands/blog.js +3 -0
- package/dist/cli/commands/blog.js.map +1 -1
- package/dist/cli/commands/harness.d.ts +1 -1
- package/dist/cli/commands/harness.js +2 -1
- package/dist/cli/commands/harness.js.map +1 -1
- package/dist/cli/commands/research.js +23 -1
- package/dist/cli/commands/research.js.map +1 -1
- package/dist/lib/agent-scope.d.ts +21 -0
- package/dist/lib/agent-scope.js +35 -0
- package/dist/lib/agent-scope.js.map +1 -0
- package/dist/lib/brief.js +4 -0
- package/dist/lib/brief.js.map +1 -1
- package/dist/lib/browser/network-log.d.ts +33 -0
- package/dist/lib/browser/network-log.js +58 -11
- package/dist/lib/browser/network-log.js.map +1 -1
- package/dist/lib/chat/board-reply.js +2 -0
- package/dist/lib/chat/board-reply.js.map +1 -1
- package/dist/lib/chat/chat-reply.js +2 -0
- package/dist/lib/chat/chat-reply.js.map +1 -1
- package/dist/lib/chat/doc-reply.js +2 -0
- package/dist/lib/chat/doc-reply.js.map +1 -1
- package/dist/lib/chat/workflow-author-reply.js +2 -0
- package/dist/lib/chat/workflow-author-reply.js.map +1 -1
- package/dist/lib/diagnostics.d.ts +2 -0
- package/dist/lib/diagnostics.js +9 -6
- package/dist/lib/diagnostics.js.map +1 -1
- package/dist/lib/harness/harness-adapter.d.ts +15 -8
- package/dist/lib/harness/harness-adapter.js +33 -7
- package/dist/lib/harness/harness-adapter.js.map +1 -1
- package/dist/lib/harness/relay-key.d.ts +23 -0
- package/dist/lib/harness/relay-key.js +36 -0
- package/dist/lib/harness/relay-key.js.map +1 -0
- package/dist/lib/harness/spawn.d.ts +23 -5
- package/dist/lib/harness/spawn.js +153 -22
- package/dist/lib/harness/spawn.js.map +1 -1
- package/dist/lib/scope-guard.d.ts +54 -0
- package/dist/lib/scope-guard.js +246 -0
- package/dist/lib/scope-guard.js.map +1 -0
- package/package.json +1 -1
|
@@ -6,14 +6,19 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.MCP_REQUIRED_ACCEPT = void 0;
|
|
7
7
|
exports.getProbeToken = getProbeToken;
|
|
8
8
|
exports.startProbeServer = startProbeServer;
|
|
9
|
+
exports.enableTokenStatePersistence = enableTokenStatePersistence;
|
|
10
|
+
exports.tokenStateSnapshot = tokenStateSnapshot;
|
|
9
11
|
exports.registerSessionToken = registerSessionToken;
|
|
10
12
|
exports.revokeSessionToken = revokeSessionToken;
|
|
11
13
|
exports.revokeSessionTokensForApiKey = revokeSessionTokensForApiKey;
|
|
12
14
|
exports.revokeSessionTokensForLabel = revokeSessionTokensForLabel;
|
|
15
|
+
exports.revokeSessionTokensForSession = revokeSessionTokensForSession;
|
|
13
16
|
exports.noteSessionLive = noteSessionLive;
|
|
14
17
|
exports.resolveRelayFallbackAgentId = resolveRelayFallbackAgentId;
|
|
15
18
|
exports.relayRequestHeaders = relayRequestHeaders;
|
|
16
19
|
exports.relayResponseHeaders = relayResponseHeaders;
|
|
20
|
+
exports.isUnknownMcpSession = isUnknownMcpSession;
|
|
21
|
+
exports.isReplayableMcpRequest = isReplayableMcpRequest;
|
|
17
22
|
exports.probeErrorMessage = probeErrorMessage;
|
|
18
23
|
/**
|
|
19
24
|
* Localhost probe server - the piece the TeamShare website detects.
|
|
@@ -376,6 +381,11 @@ async function startProbeServer(preferredPort, getStatus, onReload, onPushState,
|
|
|
376
381
|
throw new Error(`no free probe port found (tried ${tried.join(', ')})`);
|
|
377
382
|
}
|
|
378
383
|
function listen(port, getStatus, onReload, onPushState, sessionCallbacks, onRelease, onStopAllSessions) {
|
|
384
|
+
// Restore the relay tokens a previous daemon instance minted for sessions
|
|
385
|
+
// that may still be running (a restart must not 401 a live run). Reads the
|
|
386
|
+
// agent ids from the state file and re-resolves each key from the secret
|
|
387
|
+
// store - no API key is ever written there.
|
|
388
|
+
enableTokenStatePersistence();
|
|
379
389
|
return new Promise((resolve, reject) => {
|
|
380
390
|
const server = (0, node_http_1.createServer)((req, res) => handle(req, res, getStatus, onReload, onPushState, sessionCallbacks, onRelease, onStopAllSessions));
|
|
381
391
|
// WebSocket upgrade handler for the CDP relay.
|
|
@@ -591,12 +601,15 @@ function handle(req, res, getStatus, onReload, onPushState, sessionCallbacks, on
|
|
|
591
601
|
});
|
|
592
602
|
return;
|
|
593
603
|
}
|
|
594
|
-
// Mint a per-session MCP relay token (2026-10-15). The CLI knows
|
|
595
|
-
// agent key and the probe token (from runtime.json), so it can arm the
|
|
596
|
-
// for its harness WITHOUT the key ever being written to a config file:
|
|
597
|
-
// POST /internal/mcp-token { apiKey, label? } -> { token }
|
|
598
|
-
// The token is single-purpose (this daemon,
|
|
599
|
-
//
|
|
604
|
+
// Mint (or REUSE) a per-session MCP relay token (2026-10-15). The CLI knows
|
|
605
|
+
// its own agent key and the probe token (from runtime.json), so it can arm the
|
|
606
|
+
// relay for its harness WITHOUT the key ever being written to a config file:
|
|
607
|
+
// POST /internal/mcp-token { apiKey, label?, sessionId? } -> { token }
|
|
608
|
+
// The token is single-purpose (this daemon, loopback only). With a sessionId
|
|
609
|
+
// the SAME token is returned for the life of that bridge session, because the
|
|
610
|
+
// OpenCode service keeps the MCP connection it opened with the first one
|
|
611
|
+
// (2026-10-25 - see `RelaySession.sessionId`). It never expires while in use;
|
|
612
|
+
// it dies after TOKEN_TTL_MS of idleness, on an explicit run end, or on stop.
|
|
600
613
|
if (req.method === 'POST' && url.pathname === '/internal/mcp-token') {
|
|
601
614
|
void (async () => {
|
|
602
615
|
try {
|
|
@@ -610,7 +623,9 @@ function handle(req, res, getStatus, onReload, onPushState, sessionCallbacks, on
|
|
|
610
623
|
res.end(JSON.stringify({ ok: false, error: 'apiKey (ts_...) is required' }));
|
|
611
624
|
return;
|
|
612
625
|
}
|
|
613
|
-
const token = registerSessionToken(apiKey, typeof parsed.label === 'string' ? parsed.label : undefined
|
|
626
|
+
const token = registerSessionToken(apiKey, typeof parsed.label === 'string' ? parsed.label : undefined, typeof parsed.sessionId === 'string' && parsed.sessionId
|
|
627
|
+
? parsed.sessionId
|
|
628
|
+
: undefined);
|
|
614
629
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
615
630
|
res.end(JSON.stringify({ ok: true, token }));
|
|
616
631
|
}
|
|
@@ -621,10 +636,12 @@ function handle(req, res, getStatus, onReload, onPushState, sessionCallbacks, on
|
|
|
621
636
|
})();
|
|
622
637
|
return;
|
|
623
638
|
}
|
|
624
|
-
// Retire a
|
|
625
|
-
// POST /internal/mcp-token/revoke { token } -> { ok }
|
|
626
|
-
//
|
|
627
|
-
//
|
|
639
|
+
// Retire a relay token when its run is TRULY over (2026-10-15):
|
|
640
|
+
// POST /internal/mcp-token/revoke { token } | { sessionId } -> { ok }
|
|
641
|
+
// Deliberately NOT called from the harness's own exit path: a resumed run
|
|
642
|
+
// keeps the OpenCode service's MCP connection, and revoking its token would
|
|
643
|
+
// leave that live connection authenticating with a dead credential
|
|
644
|
+
// (2026-10-25). Stop and a completed run call it instead.
|
|
628
645
|
if (req.method === 'POST' && url.pathname === '/internal/mcp-token/revoke') {
|
|
629
646
|
void (async () => {
|
|
630
647
|
try {
|
|
@@ -632,11 +649,16 @@ function handle(req, res, getStatus, onReload, onPushState, sessionCallbacks, on
|
|
|
632
649
|
for await (const chunk of req)
|
|
633
650
|
chunks.push(chunk);
|
|
634
651
|
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
652
|
+
let revoked = 0;
|
|
635
653
|
if (typeof parsed.token === 'string' && parsed.token) {
|
|
636
654
|
revokeSessionToken(parsed.token);
|
|
655
|
+
revoked += 1;
|
|
656
|
+
}
|
|
657
|
+
if (typeof parsed.sessionId === 'string' && parsed.sessionId) {
|
|
658
|
+
revoked += revokeSessionTokensForSession(parsed.sessionId);
|
|
637
659
|
}
|
|
638
660
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
639
|
-
res.end(JSON.stringify({ ok: true }));
|
|
661
|
+
res.end(JSON.stringify({ ok: true, revoked }));
|
|
640
662
|
}
|
|
641
663
|
catch (err) {
|
|
642
664
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
@@ -730,43 +752,213 @@ function handle(req, res, getStatus, onReload, onPushState, sessionCallbacks, on
|
|
|
730
752
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
731
753
|
res.end(JSON.stringify({ ok: false, error: 'not found' }));
|
|
732
754
|
}
|
|
733
|
-
|
|
755
|
+
/**
|
|
756
|
+
* IDLE lifetime of a relay token, refreshed on EVERY authenticated relay call
|
|
757
|
+
* (sliding expiry). A run that keeps using its tools never expires.
|
|
758
|
+
*
|
|
759
|
+
* WHY 24 HOURS AND NOT 1 HOUR (2026-10-25): a paused run waits for a PERSON.
|
|
760
|
+
* `ask_human` can be answered after a night's sleep, and the resumed run still
|
|
761
|
+
* authenticates with the token its MCP connection was opened with - a 1 h idle
|
|
762
|
+
* clock killed exactly that case, which is the failure the session-scoped token
|
|
763
|
+
* exists to prevent. Exposure stays bounded: the token is revoked on stop, on a
|
|
764
|
+
* scope violation, and 24 h after its last relayed request; it carries no more
|
|
765
|
+
* authority than the agent key the daemon injects, and it never leaves the
|
|
766
|
+
* machine.
|
|
767
|
+
*/
|
|
768
|
+
const TOKEN_TTL_MS = 24 * 60 * 60 * 1000; // 24 h idle
|
|
734
769
|
const sessionTokens = new Map();
|
|
770
|
+
/** sessionId -> token, so a resume of the same session reuses its identity. */
|
|
771
|
+
const tokenBySession = new Map();
|
|
772
|
+
/**
|
|
773
|
+
* DURABILITY (2026-10-25): the map is in memory, so a daemon restart (manual,
|
|
774
|
+
* upgrade, watchdog replace) used to invalidate every live run's token - and
|
|
775
|
+
* the OpenCode service cannot recover from that 401. Session-backed tokens are
|
|
776
|
+
* persisted WITHOUT their API key (the agent id is enough to re-resolve the key
|
|
777
|
+
* from the secret store at boot), so a restart keeps live runs working without
|
|
778
|
+
* writing a credential to disk.
|
|
779
|
+
*
|
|
780
|
+
* `TEAMSHARE_STATE_DIR` exists so a test can round-trip the file without
|
|
781
|
+
* touching the operator's real home directory.
|
|
782
|
+
*/
|
|
783
|
+
const TOKEN_STATE_FILE = (0, node_path_1.join)(process.env.TEAMSHARE_STATE_DIR?.trim() ||
|
|
784
|
+
(0, node_path_1.join)((0, node_os_1.homedir)(), '.teamshare', 'state'), 'mcp-tokens.json');
|
|
785
|
+
let tokenStateEnabled = false;
|
|
786
|
+
let lastTokenStateSave = 0;
|
|
787
|
+
function saveTokenState(force = false) {
|
|
788
|
+
if (!tokenStateEnabled)
|
|
789
|
+
return;
|
|
790
|
+
const now = Date.now();
|
|
791
|
+
if (!force && now - lastTokenStateSave < 5 * 60_000)
|
|
792
|
+
return;
|
|
793
|
+
lastTokenStateSave = now;
|
|
794
|
+
try {
|
|
795
|
+
const rows = [];
|
|
796
|
+
for (const [token, entry] of sessionTokens) {
|
|
797
|
+
// Only session-backed tokens are worth surviving: a token with no session
|
|
798
|
+
// id cannot be reused by anything (and the relay log calls that a bug).
|
|
799
|
+
if (!entry.label || !entry.sessionId)
|
|
800
|
+
continue;
|
|
801
|
+
rows.push({
|
|
802
|
+
token,
|
|
803
|
+
label: entry.label,
|
|
804
|
+
sessionId: entry.sessionId,
|
|
805
|
+
createdAt: entry.createdAt,
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(TOKEN_STATE_FILE), { recursive: true });
|
|
809
|
+
(0, node_fs_1.writeFileSync)(TOKEN_STATE_FILE, JSON.stringify(rows), { mode: 0o600 });
|
|
810
|
+
}
|
|
811
|
+
catch {
|
|
812
|
+
/* best-effort: a read-only state dir must never break a run */
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Load the tokens a previous daemon instance minted, re-resolving each agent's
|
|
817
|
+
* key from the secret store (never from this file). Called when the probe
|
|
818
|
+
* server starts, so tests that import this module never touch disk.
|
|
819
|
+
*/
|
|
820
|
+
function enableTokenStatePersistence(resolveKey = agentKeyById) {
|
|
821
|
+
tokenStateEnabled = true;
|
|
822
|
+
try {
|
|
823
|
+
const raw = (0, node_fs_1.readFileSync)(TOKEN_STATE_FILE, 'utf8');
|
|
824
|
+
const parsed = JSON.parse(raw);
|
|
825
|
+
const rows = Array.isArray(parsed) ? parsed : [];
|
|
826
|
+
const now = Date.now();
|
|
827
|
+
for (const row of rows) {
|
|
828
|
+
if (!row ||
|
|
829
|
+
typeof row.token !== 'string' ||
|
|
830
|
+
!row.token ||
|
|
831
|
+
typeof row.label !== 'string' ||
|
|
832
|
+
!row.label ||
|
|
833
|
+
typeof row.sessionId !== 'string' ||
|
|
834
|
+
!row.sessionId) {
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
const createdAt = Number(row.createdAt);
|
|
838
|
+
if (!Number.isFinite(createdAt) || now - createdAt > TOKEN_TTL_MS)
|
|
839
|
+
continue;
|
|
840
|
+
// Resolve the key now when the secret store is readable, but NEVER drop
|
|
841
|
+
// the row when it is not: a transient failure at boot must not 401 a
|
|
842
|
+
// live run. An empty key is adopted on the first relayed request or on
|
|
843
|
+
// the session's own re-registration.
|
|
844
|
+
const apiKey = resolveKey(row.label) ?? '';
|
|
845
|
+
sessionTokens.set(row.token, {
|
|
846
|
+
apiKey,
|
|
847
|
+
label: row.label,
|
|
848
|
+
sessionId: row.sessionId,
|
|
849
|
+
createdAt,
|
|
850
|
+
});
|
|
851
|
+
tokenBySession.set(row.sessionId, row.token);
|
|
852
|
+
}
|
|
853
|
+
lastTokenStateSave = now;
|
|
854
|
+
}
|
|
855
|
+
catch {
|
|
856
|
+
/* first boot, or a corrupt file - nothing to restore */
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* The persisted rows a restart would restore, for tests and diagnostics.
|
|
861
|
+
* Deliberately contains NO apiKey field - that is the invariant this file
|
|
862
|
+
* exists to keep.
|
|
863
|
+
*/
|
|
864
|
+
function tokenStateSnapshot() {
|
|
865
|
+
const rows = [];
|
|
866
|
+
for (const [token, entry] of sessionTokens) {
|
|
867
|
+
if (!entry.label || !entry.sessionId)
|
|
868
|
+
continue;
|
|
869
|
+
rows.push({
|
|
870
|
+
token,
|
|
871
|
+
label: entry.label,
|
|
872
|
+
sessionId: entry.sessionId,
|
|
873
|
+
createdAt: entry.createdAt,
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
return rows;
|
|
877
|
+
}
|
|
735
878
|
/** Agents with a session the daemon currently considers live (agentId -> last ping). */
|
|
736
879
|
const liveSessionAgents = new Map();
|
|
737
880
|
const LIVE_SESSION_MAX_MS = 12 * 60 * 60 * 1000;
|
|
738
881
|
/** agentId -> { key, at } so the fallback does not read the secret store per call. */
|
|
739
882
|
const keyCache = new Map();
|
|
740
883
|
const KEY_CACHE_MS = 60_000;
|
|
741
|
-
/**
|
|
742
|
-
|
|
884
|
+
/**
|
|
885
|
+
* Register a relay token for an agent key.
|
|
886
|
+
*
|
|
887
|
+
* With a `sessionId`, an existing token for that session is REUSED (and its
|
|
888
|
+
* idle clock refreshed). That is the load-bearing part: the OpenCode service
|
|
889
|
+
* keeps the first MCP connection for the run's project directory, so handing a
|
|
890
|
+
* resumed/continued run a NEW token would leave the live connection
|
|
891
|
+
* authenticating with the OLD one - and 401ing forever.
|
|
892
|
+
*/
|
|
893
|
+
function registerSessionToken(apiKey, label, sessionId) {
|
|
894
|
+
if (sessionId) {
|
|
895
|
+
const existing = tokenBySession.get(sessionId);
|
|
896
|
+
const entry = existing ? sessionTokens.get(existing) : undefined;
|
|
897
|
+
if (existing && entry) {
|
|
898
|
+
// Restored from disk without a key (the secret store was unavailable at
|
|
899
|
+
// boot): adopt the identity that is re-registering, keeping the token the
|
|
900
|
+
// live MCP connection already holds.
|
|
901
|
+
if (!entry.apiKey) {
|
|
902
|
+
entry.apiKey = apiKey;
|
|
903
|
+
if (label)
|
|
904
|
+
entry.label = label;
|
|
905
|
+
entry.createdAt = Date.now();
|
|
906
|
+
saveTokenState(true);
|
|
907
|
+
return existing;
|
|
908
|
+
}
|
|
909
|
+
if (entry.apiKey === apiKey) {
|
|
910
|
+
if (label)
|
|
911
|
+
entry.label = label;
|
|
912
|
+
entry.createdAt = Date.now();
|
|
913
|
+
return existing;
|
|
914
|
+
}
|
|
915
|
+
// A different key for the same session must never inherit the token.
|
|
916
|
+
dropToken(existing);
|
|
917
|
+
}
|
|
918
|
+
tokenBySession.delete(sessionId);
|
|
919
|
+
}
|
|
743
920
|
const token = (0, node_crypto_1.randomBytes)(24).toString('hex');
|
|
744
|
-
sessionTokens.set(token, { apiKey, label, createdAt: Date.now() });
|
|
921
|
+
sessionTokens.set(token, { apiKey, label, sessionId, createdAt: Date.now() });
|
|
922
|
+
if (sessionId)
|
|
923
|
+
tokenBySession.set(sessionId, token);
|
|
924
|
+
saveTokenState(true);
|
|
745
925
|
return token;
|
|
746
926
|
}
|
|
747
|
-
/**
|
|
748
|
-
function
|
|
927
|
+
/** Delete a token AND its session/agent indexes (one way in, one way out). */
|
|
928
|
+
function dropToken(token) {
|
|
929
|
+
const entry = sessionTokens.get(token);
|
|
930
|
+
if (!entry)
|
|
931
|
+
return false;
|
|
749
932
|
sessionTokens.delete(token);
|
|
933
|
+
if (entry.sessionId && tokenBySession.get(entry.sessionId) === token) {
|
|
934
|
+
tokenBySession.delete(entry.sessionId);
|
|
935
|
+
}
|
|
936
|
+
return true;
|
|
937
|
+
}
|
|
938
|
+
/** Revoke a session token (explicit run end / stop). */
|
|
939
|
+
function revokeSessionToken(token) {
|
|
940
|
+
if (dropToken(token))
|
|
941
|
+
saveTokenState(true);
|
|
750
942
|
}
|
|
751
943
|
/**
|
|
752
944
|
* Revoke every relay token minted for an agent's API key (2026-10).
|
|
753
945
|
*
|
|
754
|
-
* THE FORCED-STOP HOLE: the CLI
|
|
755
|
-
*
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
*
|
|
946
|
+
* THE FORCED-STOP HOLE: a forced stop kills the CLI, so its own cleanup never
|
|
947
|
+
* runs. The token would otherwise stay valid and a surviving OpenCode V2
|
|
948
|
+
* service session would keep calling MCP tools as the agent. The daemon knows
|
|
949
|
+
* the agent's key, so it revokes every token for that key at stop time.
|
|
950
|
+
* Returns how many were revoked.
|
|
759
951
|
*/
|
|
760
952
|
function revokeSessionTokensForApiKey(apiKey) {
|
|
761
953
|
if (!apiKey)
|
|
762
954
|
return 0;
|
|
763
955
|
let revoked = 0;
|
|
764
956
|
for (const [token, session] of sessionTokens) {
|
|
765
|
-
if (session.apiKey === apiKey)
|
|
766
|
-
sessionTokens.delete(token);
|
|
957
|
+
if (session.apiKey === apiKey && dropToken(token))
|
|
767
958
|
revoked++;
|
|
768
|
-
}
|
|
769
959
|
}
|
|
960
|
+
if (revoked > 0)
|
|
961
|
+
saveTokenState(true);
|
|
770
962
|
return revoked;
|
|
771
963
|
}
|
|
772
964
|
/** Revoke every relay token whose debug label is `label` (the agent id). */
|
|
@@ -775,11 +967,30 @@ function revokeSessionTokensForLabel(label) {
|
|
|
775
967
|
return 0;
|
|
776
968
|
let revoked = 0;
|
|
777
969
|
for (const [token, session] of sessionTokens) {
|
|
778
|
-
if (session.label === label)
|
|
779
|
-
sessionTokens.delete(token);
|
|
970
|
+
if (session.label === label && dropToken(token))
|
|
780
971
|
revoked++;
|
|
781
|
-
}
|
|
782
972
|
}
|
|
973
|
+
if (revoked > 0)
|
|
974
|
+
saveTokenState(true);
|
|
975
|
+
return revoked;
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Revoke the token(s) of ONE bridge session. Used when the session is truly
|
|
979
|
+
* finished (never on a harness exit - a resume keeps using the same token,
|
|
980
|
+
* because the OpenCode service still holds the connection it opened with it).
|
|
981
|
+
*/
|
|
982
|
+
function revokeSessionTokensForSession(sessionId) {
|
|
983
|
+
if (!sessionId)
|
|
984
|
+
return 0;
|
|
985
|
+
let revoked = 0;
|
|
986
|
+
for (const [token, session] of sessionTokens) {
|
|
987
|
+
if (session.sessionId === sessionId && dropToken(token))
|
|
988
|
+
revoked++;
|
|
989
|
+
}
|
|
990
|
+
if (tokenBySession.get(sessionId))
|
|
991
|
+
tokenBySession.delete(sessionId);
|
|
992
|
+
if (revoked > 0)
|
|
993
|
+
saveTokenState(true);
|
|
783
994
|
return revoked;
|
|
784
995
|
}
|
|
785
996
|
/**
|
|
@@ -848,13 +1059,20 @@ function agentKeyById(agentId) {
|
|
|
848
1059
|
keyCache.set(agentId, { key, at: Date.now() });
|
|
849
1060
|
return key;
|
|
850
1061
|
}
|
|
851
|
-
/**
|
|
1062
|
+
/**
|
|
1063
|
+
* Expire tokens that have been IDLE longer than the TTL (sliding clock).
|
|
1064
|
+
* `handleMcpRelay` refreshes `createdAt` on every authenticated call, so this
|
|
1065
|
+
* only reaps sessions that stopped using their tools.
|
|
1066
|
+
*/
|
|
852
1067
|
function expireTokens() {
|
|
853
1068
|
const now = Date.now();
|
|
1069
|
+
let dropped = 0;
|
|
854
1070
|
for (const [token, entry] of sessionTokens) {
|
|
855
|
-
if (now - entry.createdAt > TOKEN_TTL_MS)
|
|
856
|
-
|
|
1071
|
+
if (now - entry.createdAt > TOKEN_TTL_MS && dropToken(token))
|
|
1072
|
+
dropped++;
|
|
857
1073
|
}
|
|
1074
|
+
if (dropped > 0)
|
|
1075
|
+
saveTokenState(true);
|
|
858
1076
|
}
|
|
859
1077
|
// Expire every 5 minutes.
|
|
860
1078
|
setInterval(expireTokens, 5 * 60_000).unref?.();
|
|
@@ -942,13 +1160,136 @@ function relayResponseHeaders(upstream) {
|
|
|
942
1160
|
headers['mcp-session-id'] = sessionId;
|
|
943
1161
|
return headers;
|
|
944
1162
|
}
|
|
1163
|
+
/**
|
|
1164
|
+
* Buddy mapping for a re-initialized upstream MCP session (2026-10-25).
|
|
1165
|
+
*
|
|
1166
|
+
* When the hub forgets a session (`404 Session not found or expired`) the relay
|
|
1167
|
+
* opens a fresh one and replays the request. Clients (the OpenCode SDK) only
|
|
1168
|
+
* adopt a new `mcp-session-id` reliably on `initialize`, so the OLD id is
|
|
1169
|
+
* aliased to the new one here and rewritten on the way in. Bounded: an alias is
|
|
1170
|
+
* worthless after the client re-initializes on its own.
|
|
1171
|
+
*/
|
|
1172
|
+
const SESSION_ALIAS_MAX = 500;
|
|
1173
|
+
const sessionAlias = new Map();
|
|
1174
|
+
function aliasUpstreamSession(oldId, newId) {
|
|
1175
|
+
if (!oldId || !newId || oldId === newId)
|
|
1176
|
+
return;
|
|
1177
|
+
sessionAlias.set(oldId, newId);
|
|
1178
|
+
while (sessionAlias.size > SESSION_ALIAS_MAX) {
|
|
1179
|
+
const oldest = sessionAlias.keys().next().value;
|
|
1180
|
+
if (typeof oldest !== 'string')
|
|
1181
|
+
break;
|
|
1182
|
+
sessionAlias.delete(oldest);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
/** Does this upstream body mean "that MCP session no longer exists"? */
|
|
1186
|
+
function isUnknownMcpSession(body) {
|
|
1187
|
+
return /session (?:not found|expired|unknown)|no such session|invalid session/i.test(body);
|
|
1188
|
+
}
|
|
1189
|
+
/**
|
|
1190
|
+
* Is this request safe to REPLAY after a hub session expired?
|
|
1191
|
+
*
|
|
1192
|
+
* Only metadata requests. A `tools/call` may have reached the tool even when
|
|
1193
|
+
* the response looks like a session error (a proxy, or a hub restart mid-call),
|
|
1194
|
+
* and replaying `create_document` / `submit_qa_report` would duplicate it. The
|
|
1195
|
+
* cost of not replaying a tool call is one clear error the agent can retry; the
|
|
1196
|
+
* cost of replaying a mutation is a duplicate write.
|
|
1197
|
+
*/
|
|
1198
|
+
function isReplayableMcpRequest(body) {
|
|
1199
|
+
const raw = typeof body === 'string' ? body : body.toString('utf8');
|
|
1200
|
+
let parsed;
|
|
1201
|
+
try {
|
|
1202
|
+
parsed = JSON.parse(raw);
|
|
1203
|
+
}
|
|
1204
|
+
catch {
|
|
1205
|
+
return false;
|
|
1206
|
+
}
|
|
1207
|
+
const messages = Array.isArray(parsed) ? parsed : [parsed];
|
|
1208
|
+
if (messages.length === 0)
|
|
1209
|
+
return false;
|
|
1210
|
+
for (const message of messages) {
|
|
1211
|
+
if (!message || typeof message !== 'object')
|
|
1212
|
+
return false;
|
|
1213
|
+
const method = message.method;
|
|
1214
|
+
if (typeof method !== 'string' || !method)
|
|
1215
|
+
return false;
|
|
1216
|
+
// Anything not a tools/call is a read/metadata request. Be explicit about
|
|
1217
|
+
// the mutating one rather than allow-listing every future read method.
|
|
1218
|
+
if (method === 'tools/call')
|
|
1219
|
+
return false;
|
|
1220
|
+
}
|
|
1221
|
+
return true;
|
|
1222
|
+
}
|
|
1223
|
+
/** Open a fresh MCP session upstream and complete the initialize handshake. */
|
|
1224
|
+
async function initializeUpstreamSession(apiBase, incoming, apiKey) {
|
|
1225
|
+
const headers = relayRequestHeaders(incoming, apiKey);
|
|
1226
|
+
delete headers['mcp-session-id'];
|
|
1227
|
+
const initBody = JSON.stringify({
|
|
1228
|
+
jsonrpc: '2.0',
|
|
1229
|
+
id: `relay-init-${(0, node_crypto_1.randomBytes)(6).toString('hex')}`,
|
|
1230
|
+
method: 'initialize',
|
|
1231
|
+
params: {
|
|
1232
|
+
protocolVersion: headerValue(incoming['mcp-protocol-version']) ?? '2025-06-18',
|
|
1233
|
+
capabilities: {},
|
|
1234
|
+
clientInfo: { name: 'teamshare-bridge-relay', version: VERSION },
|
|
1235
|
+
},
|
|
1236
|
+
});
|
|
1237
|
+
const init = await fetch(`${apiBase}/mcp`, {
|
|
1238
|
+
method: 'POST',
|
|
1239
|
+
headers,
|
|
1240
|
+
body: initBody,
|
|
1241
|
+
});
|
|
1242
|
+
const sessionId = init.headers.get('mcp-session-id');
|
|
1243
|
+
// Drain either shape - an SSE initialize answers with a stream.
|
|
1244
|
+
await init.text().catch(() => '');
|
|
1245
|
+
if (!sessionId)
|
|
1246
|
+
return null;
|
|
1247
|
+
await fetch(`${apiBase}/mcp`, {
|
|
1248
|
+
method: 'POST',
|
|
1249
|
+
headers: { ...headers, 'mcp-session-id': sessionId },
|
|
1250
|
+
body: JSON.stringify({
|
|
1251
|
+
jsonrpc: '2.0',
|
|
1252
|
+
method: 'notifications/initialized',
|
|
1253
|
+
}),
|
|
1254
|
+
})
|
|
1255
|
+
.then((r) => r.text())
|
|
1256
|
+
.catch(() => '');
|
|
1257
|
+
return sessionId;
|
|
1258
|
+
}
|
|
1259
|
+
/** Pipe an upstream response to the local client (SSE included). */
|
|
1260
|
+
async function pumpUpstream(res, upstream, extraHeaders = {}) {
|
|
1261
|
+
res.writeHead(upstream.status, {
|
|
1262
|
+
...relayResponseHeaders(upstream.headers),
|
|
1263
|
+
...extraHeaders,
|
|
1264
|
+
});
|
|
1265
|
+
if (!upstream.body) {
|
|
1266
|
+
res.end();
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
const reader = upstream.body.getReader();
|
|
1270
|
+
while (true) {
|
|
1271
|
+
const { done, value } = await reader.read();
|
|
1272
|
+
if (done)
|
|
1273
|
+
break;
|
|
1274
|
+
res.write(value);
|
|
1275
|
+
}
|
|
1276
|
+
res.end();
|
|
1277
|
+
}
|
|
945
1278
|
async function handleMcpRelay(req, res) {
|
|
946
1279
|
const rawToken = req.headers['x-teamshare-session-token'];
|
|
947
1280
|
const token = typeof rawToken === 'string' ? rawToken : undefined;
|
|
948
1281
|
let apiKey = null;
|
|
1282
|
+
let relaySession = null;
|
|
949
1283
|
if (token) {
|
|
950
1284
|
const session = sessionTokens.get(token);
|
|
951
1285
|
if (!session) {
|
|
1286
|
+
// A 401 here kills EVERY tool call of the run, and the OpenCode SDK does
|
|
1287
|
+
// not re-initialize on it - it reports "requires a login before it can
|
|
1288
|
+
// register a client" forever. Never let that pass in silence.
|
|
1289
|
+
console.warn(`[mcp-relay] rejected session token ${token.slice(0, 8)}… ` +
|
|
1290
|
+
`(unknown or revoked; active tokens: ${sessionTokens.size}) - ` +
|
|
1291
|
+
'the run lost its tools. A token is revoked on stop or after ' +
|
|
1292
|
+
'1 h of idleness; a live run should never see this.');
|
|
952
1293
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
953
1294
|
res.end(JSON.stringify({
|
|
954
1295
|
ok: false,
|
|
@@ -956,6 +1297,28 @@ async function handleMcpRelay(req, res) {
|
|
|
956
1297
|
}));
|
|
957
1298
|
return;
|
|
958
1299
|
}
|
|
1300
|
+
// SLIDING EXPIRY: a session that keeps calling tools never expires. The
|
|
1301
|
+
// persisted copy is refreshed at most every 5 minutes (saveTokenState), so
|
|
1302
|
+
// a daemon restart restores the token a live run is still using.
|
|
1303
|
+
session.createdAt = Date.now();
|
|
1304
|
+
saveTokenState();
|
|
1305
|
+
// A token restored from disk without its key (the secret store was
|
|
1306
|
+
// unavailable at boot) resolves lazily here, on the first real request.
|
|
1307
|
+
if (!session.apiKey && session.label) {
|
|
1308
|
+
session.apiKey = agentKeyById(session.label) ?? '';
|
|
1309
|
+
}
|
|
1310
|
+
if (!session.apiKey) {
|
|
1311
|
+
console.warn(`[mcp-relay] session token ${token.slice(0, 8)}… has no resolvable agent key ` +
|
|
1312
|
+
`(label: ${session.label ?? 'none'}) - the run cannot authenticate.`);
|
|
1313
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1314
|
+
res.end(JSON.stringify({
|
|
1315
|
+
ok: false,
|
|
1316
|
+
error: 'mcp-relay could not resolve this session’s agent key. ' +
|
|
1317
|
+
'Reconnect the agent, then restart the run.',
|
|
1318
|
+
}));
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
relaySession = session;
|
|
959
1322
|
apiKey = session.apiKey;
|
|
960
1323
|
}
|
|
961
1324
|
else {
|
|
@@ -1005,30 +1368,66 @@ async function handleMcpRelay(req, res) {
|
|
|
1005
1368
|
// request is rejected as session-less. See `relayRequestHeaders` /
|
|
1006
1369
|
// `relayResponseHeaders`.
|
|
1007
1370
|
const apiBase = (process.env.TEAMSHARE_API_URL ?? 'https://api.teamshare.name.ng').replace(/\/$/, '');
|
|
1371
|
+
const incomingSessionId = headerValue(req.headers['mcp-session-id']);
|
|
1372
|
+
const upstreamSessionId = incomingSessionId
|
|
1373
|
+
? (sessionAlias.get(incomingSessionId) ?? incomingSessionId)
|
|
1374
|
+
: undefined;
|
|
1375
|
+
const upstreamHeaders = relayRequestHeaders(req.headers, apiKey);
|
|
1376
|
+
if (upstreamSessionId)
|
|
1377
|
+
upstreamHeaders['mcp-session-id'] = upstreamSessionId;
|
|
1378
|
+
else
|
|
1379
|
+
delete upstreamHeaders['mcp-session-id'];
|
|
1008
1380
|
try {
|
|
1009
|
-
|
|
1381
|
+
let apiRes = await fetch(`${apiBase}/mcp`, {
|
|
1010
1382
|
method: 'POST',
|
|
1011
|
-
headers:
|
|
1383
|
+
headers: upstreamHeaders,
|
|
1012
1384
|
body,
|
|
1013
1385
|
});
|
|
1014
|
-
//
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1386
|
+
// HUB SESSION EXPIRED (2026-10-25): a deploy/restart drops every MCP
|
|
1387
|
+
// session, and the hub answers 404 "Session not found or expired" - a
|
|
1388
|
+
// request the hub never dispatched (it failed before tool execution), so
|
|
1389
|
+
// replaying it is safe. Open a fresh session and replay once instead of
|
|
1390
|
+
// handing the failure to a client that gives up after two SSE retries.
|
|
1391
|
+
if (apiRes.status === 404 && upstreamSessionId) {
|
|
1392
|
+
const errorText = await apiRes.text().catch(() => '');
|
|
1393
|
+
// A mutation is never replayed (a duplicate create is worse than an
|
|
1394
|
+
// error the agent can retry); the fresh session is still established so
|
|
1395
|
+
// the NEXT request works.
|
|
1396
|
+
const replayable = isReplayableMcpRequest(body);
|
|
1397
|
+
if (isUnknownMcpSession(errorText)) {
|
|
1398
|
+
const fresh = await initializeUpstreamSession(apiBase, req.headers, apiKey);
|
|
1399
|
+
if (fresh) {
|
|
1400
|
+
aliasUpstreamSession(upstreamSessionId, fresh);
|
|
1401
|
+
if (incomingSessionId && incomingSessionId !== upstreamSessionId) {
|
|
1402
|
+
aliasUpstreamSession(incomingSessionId, fresh);
|
|
1403
|
+
}
|
|
1404
|
+
if (replayable) {
|
|
1405
|
+
console.warn(`[mcp-relay] hub session expired${relaySession?.label ? ` for agent ${relaySession.label}` : ''} - re-initialized and replayed the request`);
|
|
1406
|
+
apiRes = await fetch(`${apiBase}/mcp`, {
|
|
1407
|
+
method: 'POST',
|
|
1408
|
+
headers: { ...upstreamHeaders, 'mcp-session-id': fresh },
|
|
1409
|
+
body,
|
|
1410
|
+
});
|
|
1411
|
+
await pumpUpstream(res, apiRes, {
|
|
1412
|
+
'mcp-session-id': apiRes.headers.get('mcp-session-id') ?? fresh,
|
|
1413
|
+
});
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
console.warn(`[mcp-relay] hub session expired${relaySession?.label ? ` for agent ${relaySession.label}` : ''} - re-initialized; the tools/call was NOT replayed (mutations are never replayed)`);
|
|
1024
1417
|
}
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
res.
|
|
1418
|
+
else {
|
|
1419
|
+
console.warn('[mcp-relay] hub session expired and re-initialize failed - forwarding the 404');
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
// Not a session problem (or re-init failed): forward what the hub said.
|
|
1423
|
+
res.writeHead(404, {
|
|
1424
|
+
'Content-Type': apiRes.headers.get('content-type') ?? 'application/json',
|
|
1425
|
+
});
|
|
1426
|
+
res.end(errorText);
|
|
1427
|
+
return;
|
|
1031
1428
|
}
|
|
1429
|
+
// Stream the response back (SSE included - the body is piped verbatim).
|
|
1430
|
+
await pumpUpstream(res, apiRes);
|
|
1032
1431
|
}
|
|
1033
1432
|
catch (err) {
|
|
1034
1433
|
res.writeHead(502, { 'Content-Type': 'application/json' });
|