teamshare-bridge 0.26.14 → 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.
Files changed (82) hide show
  1. package/dist/bridge/daemon.js +13 -3
  2. package/dist/bridge/daemon.js.map +1 -1
  3. package/dist/bridge/local-server.d.ts +52 -8
  4. package/dist/bridge/local-server.js +449 -50
  5. package/dist/bridge/local-server.js.map +1 -1
  6. package/dist/cli/commands/blog.js +3 -0
  7. package/dist/cli/commands/blog.js.map +1 -1
  8. package/dist/cli/commands/harness.d.ts +1 -1
  9. package/dist/cli/commands/harness.js +2 -1
  10. package/dist/cli/commands/harness.js.map +1 -1
  11. package/dist/cli/commands/research.js +33 -3
  12. package/dist/cli/commands/research.js.map +1 -1
  13. package/dist/cli/commands/run.js +0 -21
  14. package/dist/cli/commands/run.js.map +1 -1
  15. package/dist/lib/agent-scope.d.ts +21 -0
  16. package/dist/lib/agent-scope.js +35 -0
  17. package/dist/lib/agent-scope.js.map +1 -0
  18. package/dist/lib/brief.js +4 -0
  19. package/dist/lib/brief.js.map +1 -1
  20. package/dist/lib/browser/batch.d.ts +62 -1
  21. package/dist/lib/browser/batch.js +109 -3
  22. package/dist/lib/browser/batch.js.map +1 -1
  23. package/dist/lib/browser/cdp.d.ts +52 -2
  24. package/dist/lib/browser/cdp.js +207 -14
  25. package/dist/lib/browser/cdp.js.map +1 -1
  26. package/dist/lib/browser/index.d.ts +2 -2
  27. package/dist/lib/browser/index.js +4 -1
  28. package/dist/lib/browser/index.js.map +1 -1
  29. package/dist/lib/browser/login.d.ts +20 -2
  30. package/dist/lib/browser/login.js +125 -88
  31. package/dist/lib/browser/login.js.map +1 -1
  32. package/dist/lib/browser/network-log.d.ts +33 -0
  33. package/dist/lib/browser/network-log.js +58 -11
  34. package/dist/lib/browser/network-log.js.map +1 -1
  35. package/dist/lib/browser/page-context.d.ts +40 -1
  36. package/dist/lib/browser/page-context.js +117 -1
  37. package/dist/lib/browser/page-context.js.map +1 -1
  38. package/dist/lib/browser/refs.d.ts +104 -0
  39. package/dist/lib/browser/refs.js +430 -0
  40. package/dist/lib/browser/refs.js.map +1 -0
  41. package/dist/lib/browser/session.d.ts +129 -4
  42. package/dist/lib/browser/session.js +262 -3
  43. package/dist/lib/browser/session.js.map +1 -1
  44. package/dist/lib/chat/board-reply.js +2 -0
  45. package/dist/lib/chat/board-reply.js.map +1 -1
  46. package/dist/lib/chat/chat-reply.js +2 -0
  47. package/dist/lib/chat/chat-reply.js.map +1 -1
  48. package/dist/lib/chat/doc-reply.js +2 -0
  49. package/dist/lib/chat/doc-reply.js.map +1 -1
  50. package/dist/lib/chat/workflow-author-reply.js +2 -0
  51. package/dist/lib/chat/workflow-author-reply.js.map +1 -1
  52. package/dist/lib/diagnostics.d.ts +2 -0
  53. package/dist/lib/diagnostics.js +9 -6
  54. package/dist/lib/diagnostics.js.map +1 -1
  55. package/dist/lib/git/git-finalize.js +17 -2
  56. package/dist/lib/git/git-finalize.js.map +1 -1
  57. package/dist/lib/harness/harness-adapter.d.ts +17 -13
  58. package/dist/lib/harness/harness-adapter.js +33 -7
  59. package/dist/lib/harness/harness-adapter.js.map +1 -1
  60. package/dist/lib/harness/opencode-control.d.ts +73 -0
  61. package/dist/lib/harness/opencode-control.js +318 -0
  62. package/dist/lib/harness/opencode-control.js.map +1 -1
  63. package/dist/lib/harness/opencode-outcome.d.ts +16 -2
  64. package/dist/lib/harness/opencode-outcome.js +36 -7
  65. package/dist/lib/harness/opencode-outcome.js.map +1 -1
  66. package/dist/lib/harness/relay-key.d.ts +23 -0
  67. package/dist/lib/harness/relay-key.js +36 -0
  68. package/dist/lib/harness/relay-key.js.map +1 -0
  69. package/dist/lib/harness/spawn.d.ts +23 -5
  70. package/dist/lib/harness/spawn.js +264 -69
  71. package/dist/lib/harness/spawn.js.map +1 -1
  72. package/dist/lib/orchestrator/handlers/browser.js +375 -30
  73. package/dist/lib/orchestrator/handlers/browser.js.map +1 -1
  74. package/dist/lib/scope-guard.d.ts +54 -0
  75. package/dist/lib/scope-guard.js +246 -0
  76. package/dist/lib/scope-guard.js.map +1 -0
  77. package/dist/lib/tools/browser-tool.js +24 -3
  78. package/dist/lib/tools/browser-tool.js.map +1 -1
  79. package/dist/lib/workspace/repo-lock.d.ts +5 -1
  80. package/dist/lib/workspace/repo-lock.js +5 -3
  81. package/dist/lib/workspace/repo-lock.js.map +1 -1
  82. 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 its own
595
- // agent key and the probe token (from runtime.json), so it can arm the relay
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, 1 h) and revoked when the harness
599
- // exits; it identifies the run to `/mcp-relay`.
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 per-run MCP relay token the moment its run is over (2026-10-15):
625
- // POST /internal/mcp-token/revoke { token } -> { ok }
626
- // Without this, a reused run directory could keep acting as the previous
627
- // run's agent until the 1 h TTL expired.
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
- const TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
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
- /** Register a new session token for the given agent key. Returns the token. */
742
- function registerSessionToken(apiKey, label) {
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
- /** Revoke a session token (harness exit). */
748
- function revokeSessionToken(token) {
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 revokes its own token in `cleanMcp`, but a
755
- * forced stop kills the CLI, so that code never runs. The token then stayed
756
- * valid for an hour and a surviving OpenCode V2 service session kept calling
757
- * MCP tools as the agent. The daemon knows the agent's key, so it can revoke
758
- * every token for that key at stop time. Returns how many were revoked.
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
- /** Expire tokens older than TTL (called periodically). */
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
- sessionTokens.delete(token);
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
- const apiRes = await fetch(`${apiBase}/mcp`, {
1381
+ let apiRes = await fetch(`${apiBase}/mcp`, {
1010
1382
  method: 'POST',
1011
- headers: relayRequestHeaders(req.headers, apiKey),
1383
+ headers: upstreamHeaders,
1012
1384
  body,
1013
1385
  });
1014
- // Stream the response back (SSE included - the body is piped verbatim).
1015
- res.writeHead(apiRes.status, relayResponseHeaders(apiRes.headers));
1016
- if (apiRes.body) {
1017
- const reader = apiRes.body.getReader();
1018
- const pump = async () => {
1019
- while (true) {
1020
- const { done, value } = await reader.read();
1021
- if (done)
1022
- break;
1023
- res.write(value);
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
- res.end();
1026
- };
1027
- await pump();
1028
- }
1029
- else {
1030
- res.end();
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' });