@oar-sh/oar 0.9.16 → 0.9.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 (75) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
  2. package/CHANGELOG.md +220 -0
  3. package/README.md +12 -11
  4. package/package.json +2 -1
  5. package/server/claude-worker/claude-attachments.mjs +7 -4
  6. package/server/claude-worker/claude-sdk-adapter.mjs +167 -9
  7. package/server/claude-worker/claude-session-process.mjs +127 -29
  8. package/server/claude-worker/claude-session-worker-main.mjs +266 -0
  9. package/server/claude-worker/claude-session-worker.mjs +2 -253
  10. package/server/claude-worker/claude-turn-failure.mjs +8 -0
  11. package/server/claude-worker/claude-turn-publisher.mjs +282 -13
  12. package/server/claude-worker/claude-usage-totals.mjs +108 -0
  13. package/server/claude-worker/sdk-message-normalizer.mjs +55 -0
  14. package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
  15. package/server/copilot-worker/copilot-sdk-session-process.mjs +71 -5
  16. package/server/db-schema.mjs +63 -0
  17. package/server/deepseek-worker/deepseek-claude-backend.mjs +433 -0
  18. package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
  19. package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
  20. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
  21. package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
  22. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
  23. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
  24. package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
  25. package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
  26. package/server/public/app/api-client.js +53 -0
  27. package/server/public/app/bootstrap.js +101 -13
  28. package/server/public/app/context-tier-options.mjs +4 -0
  29. package/server/public/app/conversation-provider-indicator.mjs +7 -0
  30. package/server/public/app/conversation-view.js +2 -0
  31. package/server/public/app/deepseek-settings-ui.js +342 -0
  32. package/server/public/app/inbox-ui.js +14 -3
  33. package/server/public/app/journal-view.js +31 -3
  34. package/server/public/app/model-selector-options.mjs +11 -0
  35. package/server/public/app/model-vision-support.mjs +2 -0
  36. package/server/public/app/no-conversation-state.mjs +1 -1
  37. package/server/public/app/plan-usage-view.mjs +165 -13
  38. package/server/public/app/provider-readiness.mjs +4 -1
  39. package/server/public/app/relay-error-ctas.mjs +8 -0
  40. package/server/public/app/settings-modal.js +11 -0
  41. package/server/public/app/settings-tabs.js +1 -1
  42. package/server/public/app/socket-handlers.js +10 -0
  43. package/server/public/index.html +408 -33
  44. package/server/repositories/conversation-group-repository.mjs +13 -6
  45. package/server/routes/conversation-group-routes.mjs +2 -1
  46. package/server/routes/deepseek-usage-import-routes.mjs +72 -0
  47. package/server/routes/messages-routes.mjs +192 -15
  48. package/server/routes/sessions-routes.mjs +364 -55
  49. package/server/server-runtime.mjs +123 -10
  50. package/server/services/about-service.mjs +1 -0
  51. package/server/services/claude-session-leftovers.mjs +226 -0
  52. package/server/services/deepseek-balance-service.mjs +102 -0
  53. package/server/services/deepseek-settings-service.mjs +380 -0
  54. package/server/services/deepseek-usage-import.mjs +471 -0
  55. package/server/services/model-variant-catalog-service.mjs +1 -0
  56. package/server/services/plan-usage-contract.mjs +100 -5
  57. package/server/services/plan-usage-deepseek.mjs +931 -0
  58. package/server/services/plan-usage-service.mjs +431 -0
  59. package/server/services/provider-model-selection.mjs +1 -0
  60. package/server/services/provider-reasoning-effort.mjs +15 -2
  61. package/server/services/remote-relay-dispatcher.mjs +2 -1
  62. package/server/services/session-worker-launch-service.mjs +233 -82
  63. package/server/services/worker-secret-redaction.mjs +318 -0
  64. package/shared/context-window-fallbacks.mjs +3 -0
  65. package/shared/deepseek-api.mjs +137 -0
  66. package/shared/deepseek-models.mjs +114 -0
  67. package/shared/deepseek-pricing.mjs +146 -0
  68. package/shared/host-claude-session-env.mjs +101 -0
  69. package/shared/model-id.mjs +7 -0
  70. package/shared/preview-tool-core.mjs +1 -1
  71. package/shared/provider-routing.mjs +2 -2
  72. package/shared/remote-relay-contract.mjs +1 -1
  73. package/shared/steer-settle-failure.mjs +1 -0
  74. package/shared/worker-runtime/direct-openai-image.mjs +128 -0
  75. package/shared/worker-runtime/worker-log-file.mjs +60 -0
@@ -14,6 +14,11 @@ import {
14
14
  isOarMcpServerDisabled,
15
15
  } from '../mcp/oar-mcp-server.mjs';
16
16
  import { WORKER_LOG_FILE_ENV } from '../../shared/worker-runtime/worker-log-file.mjs';
17
+ import {
18
+ HOST_CLAUDE_SESSION_ENV_VARS,
19
+ hostClaudeSessionUnsetStatement,
20
+ withoutHostClaudeSessionVariables,
21
+ } from '../../shared/host-claude-session-env.mjs';
17
22
  import { missingWorkingDirectoryError } from '../../shared/worker-runtime/working-directory.mjs';
18
23
  import {
19
24
  describeSpawnEnvironment,
@@ -270,6 +275,42 @@ export function applyClaudeCloudProviderEnvironment(env = {}, {
270
275
  return next;
271
276
  }
272
277
 
278
+ /**
279
+ * DeepSeek: the Claude worker's runner against DeepSeek's Anthropic-compatible
280
+ * endpoint (server/deepseek-worker/). The key travels as DEEPSEEK_API_KEY,
281
+ * through the secret file under tmux, and becomes the CLI's bearer token
282
+ * only inside its spawn environment (deepseek-claude-backend.mjs). The worker
283
+ * keeps its Claude Code state in a directory of its own, which holds no
284
+ * Claude login.
285
+ */
286
+ export function applyDeepSeekProviderEnvironment(env = {}, {
287
+ enabled = false,
288
+ model = '',
289
+ apiKey = '',
290
+ baseUrl = '',
291
+ configDir = '',
292
+ } = {}) {
293
+ const next = { ...env };
294
+ if (resolveWorkerKind(next) === 'deepseek') delete next.COPILOT_WEB_RELAY_WORKER_KIND;
295
+ delete next.DEEPSEEK_RELAY_MODEL;
296
+ delete next.DEEPSEEK_API_KEY;
297
+ delete next.DEEPSEEK_BASE_URL;
298
+ delete next.DEEPSEEK_CLAUDE_CONFIG_DIR;
299
+ if (!enabled) return next;
300
+ const normalizedApiKey = normalizeText(apiKey);
301
+ if (!normalizedApiKey) throw new Error('deepseek-api-key-not-configured');
302
+ const normalizedConfigDir = normalizeText(configDir);
303
+ if (!normalizedConfigDir) throw new Error('deepseek-config-dir-not-configured');
304
+ next.COPILOT_WEB_RELAY_WORKER_KIND = 'deepseek';
305
+ next.DEEPSEEK_API_KEY = normalizedApiKey;
306
+ next.DEEPSEEK_CLAUDE_CONFIG_DIR = path.resolve(normalizedConfigDir);
307
+ const normalizedModel = normalizeText(model);
308
+ if (normalizedModel) next.DEEPSEEK_RELAY_MODEL = normalizedModel;
309
+ const normalizedBaseUrl = normalizeText(baseUrl);
310
+ if (normalizedBaseUrl) next.DEEPSEEK_BASE_URL = normalizedBaseUrl;
311
+ return next;
312
+ }
313
+
273
314
  /**
274
315
  * The Copilot SDK engine: the same `github`/`openai` providers the extension
275
316
  * serves, run as a plain Node worker against the CLI's headless runtime.
@@ -359,6 +400,10 @@ export function isClaudeCloudWorkerEnvironment(env = {}) {
359
400
  return resolveWorkerKind(env) === 'claude-cloud';
360
401
  }
361
402
 
403
+ export function isDeepSeekWorkerEnvironment(env = {}) {
404
+ return resolveWorkerKind(env) === 'deepseek';
405
+ }
406
+
362
407
  export function isCopilotSdkWorkerEnvironment(env = {}) {
363
408
  return resolveWorkerKind(env) === 'copilot-sdk';
364
409
  }
@@ -403,6 +448,16 @@ export function resolveClaudeCloudWorkerScriptPath(env = {}) {
403
448
  return path.join(process.cwd(), 'server', 'claude-cloud-worker', 'claude-cloud-session-worker.mjs');
404
449
  }
405
450
 
451
+ export function resolveDeepSeekWorkerScriptPath(env = {}) {
452
+ const explicit = normalizeText(env?.COPILOT_WEB_RELAY_DEEPSEEK_WORKER_PATH);
453
+ if (explicit) return explicit;
454
+ const repoRoot = normalizeText(env?.COPILOT_WEB_RELAY_ROOT);
455
+ if (repoRoot) return path.join(repoRoot, 'server', 'deepseek-worker', 'deepseek-session-worker.mjs');
456
+ const serverDir = normalizeText(env?.COPILOT_WEB_RELAY_SERVER_DIR);
457
+ if (serverDir) return path.join(serverDir, 'deepseek-worker', 'deepseek-session-worker.mjs');
458
+ return path.join(process.cwd(), 'server', 'deepseek-worker', 'deepseek-session-worker.mjs');
459
+ }
460
+
406
461
  export function resolveCopilotSdkWorkerScriptPath(env = {}) {
407
462
  const explicit = normalizeText(env?.COPILOT_WEB_RELAY_COPILOT_SDK_WORKER_PATH);
408
463
  if (explicit) return explicit;
@@ -431,6 +486,7 @@ const NODE_WORKER_DESCRIPTORS = Object.freeze({
431
486
  resolveScriptPath: resolveCopilotSdkWorkerScriptPath,
432
487
  windowsTitle: 'Copilot SDK Worker',
433
488
  }),
489
+ deepseek: Object.freeze({ resolveScriptPath: resolveDeepSeekWorkerScriptPath, windowsTitle: 'DeepSeek Worker' }),
434
490
  });
435
491
 
436
492
  function resolveNodeWorkerDescriptor(env = {}) {
@@ -601,17 +657,42 @@ export function getTmuxPanePid(sessionName, {
601
657
  }
602
658
  }
603
659
 
604
- export const WORKER_SECRET_ENV_VARS = Object.freeze(['COPILOT_PROVIDER_API_KEY', 'CURSOR_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN']);
605
-
606
- // Secrets that are not every worker's. The two API keys are put into the
607
- // launch environment by their own provider's applier and cleared for every
608
- // other one, so they need no entry. The Claude login token is different: it
609
- // sits in the relay's own environment (a relay run on CLAUDE_CODE_OAUTH_TOKEN
610
- // instead of the CLI's credentials file), so it would otherwise travel with
611
- // every launch. Only the Claude Cloud worker gets it: its cloud client reads
612
- // the login from there exactly as the relay's does (credentials.mjs).
660
+ export const WORKER_SECRET_ENV_VARS = Object.freeze([
661
+ 'COPILOT_PROVIDER_API_KEY',
662
+ 'CURSOR_API_KEY',
663
+ 'CLAUDE_CODE_OAUTH_TOKEN',
664
+ 'DEEPSEEK_API_KEY',
665
+ 'ANTHROPIC_API_KEY',
666
+ 'ANTHROPIC_AUTH_TOKEN',
667
+ ]);
668
+
669
+ // Secrets that are not every worker's: each entry names the kinds that get
670
+ // it, and every other kind has it taken out of its launch environment on
671
+ // every path (withoutForeignWorkerSecrets), and out of the tmux shell
672
+ // (buildTmuxWorkerShellCommand). A secret with no entry is every kind's.
673
+ //
674
+ // - The two API keys without an entry are put into the launch environment by
675
+ // their own provider's applier and cleared for every other one.
676
+ // - The Claude login token sits in the relay's own environment (a relay run on
677
+ // CLAUDE_CODE_OAUTH_TOKEN instead of the CLI's credentials file), so it
678
+ // would otherwise travel with every launch. Only the two workers of the
679
+ // host's own Claude account get it: the Claude Cloud worker's client reads
680
+ // the login from there exactly as the relay's does (credentials.mjs), and
681
+ // the Claude worker's CLI prefers it to the credentials file.
682
+ // - The DeepSeek key is put there by its own applier, but the same name could
683
+ // also sit in the relay's environment (a shell that exports it); it is the
684
+ // DeepSeek worker's alone either way.
685
+ // - A host's ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN are no worker's (an
686
+ // empty list): Claude Code prefers either to the Claude login, so a Claude
687
+ // chat would run, and be billed, on a key nobody chose for it, and every
688
+ // other kind has no use for them. A key a provider saves in the relay's
689
+ // settings goes to its own kind under a name of its own, with an entry
690
+ // here naming that kind alone.
613
691
  const WORKER_SECRET_ENV_VAR_KINDS = Object.freeze({
614
- CLAUDE_CODE_OAUTH_TOKEN: Object.freeze(['claude-cloud']),
692
+ CLAUDE_CODE_OAUTH_TOKEN: Object.freeze(['claude', 'claude-cloud']),
693
+ DEEPSEEK_API_KEY: Object.freeze(['deepseek']),
694
+ ANTHROPIC_API_KEY: Object.freeze([]),
695
+ ANTHROPIC_AUTH_TOKEN: Object.freeze([]),
615
696
  });
616
697
 
617
698
  /**
@@ -627,6 +708,29 @@ export function workerSecretEnvVarsFor(env = {}) {
627
708
  });
628
709
  }
629
710
 
711
+ /**
712
+ * The launch environment without the secrets that are another kind's. They
713
+ * would otherwise reach the worker on the detached and Windows paths, which
714
+ * hand the environment over as it is, and under tmux through the environment
715
+ * a tmux server started by the launch keeps as its global one.
716
+ */
717
+ export function withoutForeignWorkerSecrets(env = {}) {
718
+ const own = new Set(workerSecretEnvVarsFor(env));
719
+ const next = { ...env };
720
+ for (const key of WORKER_SECRET_ENV_VARS) {
721
+ if (!own.has(key)) delete next[key];
722
+ }
723
+ return next;
724
+ }
725
+
726
+ // The host Claude Code session's variables (a relay started from inside a
727
+ // Claude Code session) are dropped from every launch path, for every kind:
728
+ // the names and families are in shared/host-claude-session-env.mjs, with
729
+ // what stays and why. Under tmux the worker's command unsets them again, by
730
+ // family too, from whatever the tmux server's environment holds
731
+ // (buildTmuxWorkerShellCommand).
732
+ export { HOST_CLAUDE_SESSION_ENV_VARS, withoutHostClaudeSessionVariables };
733
+
630
734
  export function createWorkerSecretEnvFile(env = {}, {
631
735
  fsImpl = fs,
632
736
  tempRoot = os.tmpdir(),
@@ -697,6 +801,84 @@ export function prepareWorkerLogFile(targetSessionId, launchEnv = {}, {
697
801
  }
698
802
  }
699
803
 
804
+ // What a tmux worker is handed as `export`s in its own shell command. These
805
+ // are the values of one launch (the kind of worker, its model, its folder),
806
+ // so they never go into the environment tmux itself is started with: a tmux
807
+ // server started by a launch keeps that as its global environment, and every
808
+ // later session on the server would inherit the first launch's values where
809
+ // its own command exports none (launchSessionCli takes them out).
810
+ export const TMUX_WORKER_EXPORTED_ENV_VARS = Object.freeze([
811
+ 'COPILOT_ALLOW_ALL',
812
+ 'GITHUB_COPILOT_PROMPT_MODE_EXTENSIONS',
813
+ 'COPILOT_WEB_RELAY_ROOT',
814
+ 'COPILOT_WEB_RELAY_SERVER_DIR',
815
+ 'COPILOT_WEB_RELAY_CONFIG',
816
+ 'COPILOT_WEB_RELAY_TOOLS',
817
+ 'COPILOT_WEB_RELAY_LOG_DIR',
818
+ // The Cursor worker keeps its agent stores in the relay's data dir
819
+ // (applyCursorProviderEnvironment).
820
+ 'COPILOT_WEB_RELAY_DATA_DIR',
821
+ 'COPILOT_WEB_RELAY_CLI_EXECUTABLE',
822
+ 'COPILOT_WEB_RELAY_EXTENSION_BOOTSTRAP_PATH',
823
+ 'COPILOT_SDK_PATH',
824
+ 'EXTENSION_PATH',
825
+ 'COPILOT_PROVIDER_TYPE',
826
+ 'COPILOT_PROVIDER_BASE_URL',
827
+ 'COPILOT_PROVIDER_WIRE_API',
828
+ 'COPILOT_MODEL',
829
+ 'COPILOT_WEB_RELAY_WORKER_KIND',
830
+ // The SDK worker's own model and BYOK context overrides. `COPILOT_MODEL`
831
+ // above is the extension engine's model variable and is read by the CLI's
832
+ // startup layer; the SDK worker takes its default model from
833
+ // `COPILOT_RELAY_MODEL`, matching every other Node worker's
834
+ // `*_RELAY_MODEL`. Without it here the model would silently drop to the
835
+ // runtime default on the tmux path while working on the detached path,
836
+ // which inherits the whole environment.
837
+ 'COPILOT_RELAY_MODEL',
838
+ 'COPILOT_PROVIDER_MAX_PROMPT_TOKENS',
839
+ 'COPILOT_PROVIDER_MAX_OUTPUT_TOKENS',
840
+ // Without this the tmux path would fall back to ~/.claude while the relay
841
+ // (and the detached spawn path, which inherits the full environment) uses
842
+ // the override — worker and relay would read different config roots.
843
+ 'CLAUDE_CONFIG_DIR',
844
+ // Same divergence, one variable over: the relay binds a resolved `claude`
845
+ // binary onto its own environment (cli-install-service.mjs §4.3), and the
846
+ // detached path inherits it for free. Without it here a bound binary would
847
+ // be silently dropped on the tmux path and the worker would fall back to
848
+ // whatever `claude` PATH happens to resolve — exactly the failure mode
849
+ // CLAUDE_CONFIG_DIR above was added for. GROK_CLI_COMMAND, its Grok
850
+ // counterpart, has always been on this list.
851
+ 'CLAUDE_CODE_EXECUTABLE',
852
+ 'CLAUDE_RELAY_MODEL',
853
+ // The Claude Cloud worker's model. Its login is not listed here: the
854
+ // worker reads the CLI's credentials file (CLAUDE_CONFIG_DIR above tells
855
+ // it where), and a relay-level CLAUDE_CODE_OAUTH_TOKEN is a secret
856
+ // (workerSecretEnvVarsFor), so it comes through the secret file.
857
+ 'CLAUDE_CLOUD_RELAY_MODEL',
858
+ // Tests point the relay's cloud client at a fake API on this machine;
859
+ // the worker's client has to follow, or the relay would talk to the fake
860
+ // and its worker to the real API. Not a secret, and harmless as a value:
861
+ // shared/claude-cloud/base-url.mjs ignores everything but a loopback URL.
862
+ 'OAR_CLAUDE_CLOUD_API_BASE_URL',
863
+ 'CURSOR_RELAY_MODEL',
864
+ 'CURSOR_AGENT_STORE_DIR',
865
+ // The DeepSeek worker's model, endpoint and Claude Code state directory.
866
+ // Its key is a secret (workerSecretEnvVarsFor) and comes through the file.
867
+ 'DEEPSEEK_RELAY_MODEL',
868
+ 'DEEPSEEK_BASE_URL',
869
+ 'DEEPSEEK_CLAUDE_CONFIG_DIR',
870
+ 'GROK_RELAY_MODEL',
871
+ 'GROK_CLI_COMMAND',
872
+ 'GROK_ALWAYS_APPROVE',
873
+ // The OAR MCP server kill switch (the Grok worker reads it too), and the
874
+ // extension engine's "it is attached" marker.
875
+ 'OAR_MCP_SERVER',
876
+ 'OAR_MCP_SERVER_ATTACHED',
877
+ 'SESSION_ID',
878
+ 'COPILOT_WORKSPACE_ROOT',
879
+ 'INIT_CWD',
880
+ ]);
881
+
700
882
  export function buildTmuxWorkerShellCommand(targetSessionId, env = {}, {
701
883
  secretEnvFilePath = '',
702
884
  workerLogPath = '',
@@ -715,80 +897,41 @@ export function buildTmuxWorkerShellCommand(targetSessionId, env = {}, {
715
897
  throw new Error('worker-secret-env-file-required');
716
898
  }
717
899
  const exports = [];
718
- for (const key of [
719
- 'COPILOT_ALLOW_ALL',
720
- 'GITHUB_COPILOT_PROMPT_MODE_EXTENSIONS',
721
- 'COPILOT_WEB_RELAY_ROOT',
722
- 'COPILOT_WEB_RELAY_SERVER_DIR',
723
- 'COPILOT_WEB_RELAY_CONFIG',
724
- 'COPILOT_WEB_RELAY_TOOLS',
725
- 'COPILOT_WEB_RELAY_LOG_DIR',
726
- // The Cursor worker keeps its agent stores in the relay's data dir
727
- // (applyCursorProviderEnvironment).
728
- 'COPILOT_WEB_RELAY_DATA_DIR',
729
- 'COPILOT_WEB_RELAY_CLI_EXECUTABLE',
730
- 'COPILOT_WEB_RELAY_EXTENSION_BOOTSTRAP_PATH',
731
- 'COPILOT_SDK_PATH',
732
- 'EXTENSION_PATH',
733
- 'COPILOT_PROVIDER_TYPE',
734
- 'COPILOT_PROVIDER_BASE_URL',
735
- 'COPILOT_PROVIDER_WIRE_API',
736
- 'COPILOT_MODEL',
737
- 'COPILOT_WEB_RELAY_WORKER_KIND',
738
- // The SDK worker's own model and BYOK context overrides. `COPILOT_MODEL`
739
- // above is the extension engine's model variable and is read by the CLI's
740
- // startup layer; the SDK worker takes its default model from
741
- // `COPILOT_RELAY_MODEL`, matching every other Node worker's
742
- // `*_RELAY_MODEL`. Without it here the model would silently drop to the
743
- // runtime default on the tmux path while working on the detached path,
744
- // which inherits the whole environment.
745
- 'COPILOT_RELAY_MODEL',
746
- 'COPILOT_PROVIDER_MAX_PROMPT_TOKENS',
747
- 'COPILOT_PROVIDER_MAX_OUTPUT_TOKENS',
748
- // Without this the tmux path would fall back to ~/.claude while the relay
749
- // (and the detached spawn path, which inherits the full environment) uses
750
- // the override — worker and relay would read different config roots.
751
- 'CLAUDE_CONFIG_DIR',
752
- // Same divergence, one variable over: the relay binds a resolved `claude`
753
- // binary onto its own environment (cli-install-service.mjs §4.3), and the
754
- // detached path inherits it for free. Without it here a bound binary would
755
- // be silently dropped on the tmux path and the worker would fall back to
756
- // whatever `claude` PATH happens to resolve — exactly the failure mode
757
- // CLAUDE_CONFIG_DIR above was added for. GROK_CLI_COMMAND, its Grok
758
- // counterpart, has always been on this list.
759
- 'CLAUDE_CODE_EXECUTABLE',
760
- 'CLAUDE_RELAY_MODEL',
761
- // The Claude Cloud worker's model. Its login is not listed here: the
762
- // worker reads the CLI's credentials file (CLAUDE_CONFIG_DIR above tells
763
- // it where), and a relay-level CLAUDE_CODE_OAUTH_TOKEN is a secret
764
- // (workerSecretEnvVarsFor), so it comes through the secret file.
765
- 'CLAUDE_CLOUD_RELAY_MODEL',
766
- // Tests point the relay's cloud client at a fake API on this machine;
767
- // the worker's client has to follow, or the relay would talk to the fake
768
- // and its worker to the real API. Not a secret, and harmless as a value:
769
- // shared/claude-cloud/base-url.mjs ignores everything but a loopback URL.
770
- 'OAR_CLAUDE_CLOUD_API_BASE_URL',
771
- 'CURSOR_RELAY_MODEL',
772
- 'CURSOR_AGENT_STORE_DIR',
773
- 'GROK_RELAY_MODEL',
774
- 'GROK_CLI_COMMAND',
775
- 'GROK_ALWAYS_APPROVE',
776
- // The OAR MCP server kill switch (the Grok worker reads it too), and the
777
- // extension engine's "it is attached" marker.
778
- 'OAR_MCP_SERVER',
779
- 'OAR_MCP_SERVER_ATTACHED',
780
- 'SESSION_ID',
781
- 'COPILOT_WORKSPACE_ROOT',
782
- 'INIT_CWD',
783
- ]) {
900
+ const exported = new Set();
901
+ for (const key of TMUX_WORKER_EXPORTED_ENV_VARS) {
784
902
  const value = String(launchEnv?.[key] || '').trim();
785
903
  if (!value) continue;
786
904
  exports.push(`${key}=${shellQuote(value)}`);
905
+ exported.add(key);
787
906
  }
907
+ // A new tmux session starts from the server's global environment, which is
908
+ // the environment the server was started with: tmux takes the environment
909
+ // of `new-session` only when it starts the server. So the cleaning of that
910
+ // environment (launchSessionCli) reaches a worker only on the first launch;
911
+ // a server that was started from inside a Claude Code session, or by an
912
+ // older relay, hands every later worker the host session's variables and
913
+ // the first launch's values. The command therefore unsets, before anything
914
+ // else, the host session's variables and every per-launch variable this
915
+ // launch exports no value for (unset of an absent name is a no-op in sh).
916
+ // With them the worker log file a relay started from inside a worker
917
+ // session inherited, and every secret this launch carries no value for —
918
+ // other kinds' and its own alike — which a tmux server started from a shell
919
+ // that exports them keeps: names only. The worker's own secrets come
920
+ // through the secret file alone, sourced after the unset, so a key the
921
+ // launch does carry is restored from there. A second statement then unsets
922
+ // every variable of the host session's families the shell holds, names a
923
+ // fixed list would miss included.
924
+ const ownSecrets = new Set(workerSecretEnvVarsFor(launchEnv));
925
+ const unset = [
926
+ ...HOST_CLAUDE_SESSION_ENV_VARS,
927
+ WORKER_LOG_FILE_ENV,
928
+ ...TMUX_WORKER_EXPORTED_ENV_VARS.filter((key) => !exported.has(key)),
929
+ ...WORKER_SECRET_ENV_VARS.filter((key) => !ownSecrets.has(key) || !String(launchEnv?.[key] ?? '').trim()),
930
+ ];
788
931
  // `sh -lc` builds PATH from the login profile, not from the relay's; the
789
932
  // relay's Node folder goes at its end when it is not on it (see
790
933
  // appendNodeDirectoryToPath).
791
- const statements = exports.map((entry) => `export ${entry};`);
934
+ const statements = [`unset ${unset.join(' ')};`, hostClaudeSessionUnsetStatement(), ...exports.map((entry) => `export ${entry};`)];
792
935
  const nodeDirectory = resolveWorkerNodeDirectory(launchEnv);
793
936
  if (nodeDirectory) {
794
937
  const quotedDir = shellQuote(nodeDirectory);
@@ -971,10 +1114,13 @@ export async function launchSessionCli({
971
1114
  workspaceRoot: launchWorkspaceRoot,
972
1115
  env,
973
1116
  });
974
- const launchSessionEnv = {
1117
+ // Every path below starts from this environment, so a secret that is not
1118
+ // this kind's, and the host Claude Code session's variables, are gone
1119
+ // before any of them sees it.
1120
+ const launchSessionEnv = withoutHostClaudeSessionVariables(withoutForeignWorkerSecrets({
975
1121
  ...appendNodeDirectoryToPath(launchEnv, resolveWorkerNodeDirectory(launchEnv, { platform }), { platform }),
976
1122
  SESSION_ID: target,
977
- };
1123
+ }));
978
1124
  const explain = (message) => {
979
1125
  try { log?.(`worker launcher: session=${target.slice(0, 8)} ${message}`); } catch {}
980
1126
  };
@@ -1007,9 +1153,14 @@ export async function launchSessionCli({
1007
1153
  const tmuxEnv = { ...launchSessionEnv };
1008
1154
  delete tmuxEnv.TMUX;
1009
1155
  delete tmuxEnv.TMUX_PANE;
1010
- // What the secret file carries is in no environment tmux is started with
1011
- // (a tmux server started by this call would keep it as its global one).
1012
- for (const key of workerSecretEnvVarsFor(launchSessionEnv)) delete tmuxEnv[key];
1156
+ // No secret is in the environment tmux is started with (a tmux server
1157
+ // started by this call would keep it as its global one): this kind's go
1158
+ // through the secret file, the others are already gone. Nor is anything
1159
+ // of this launch alone (worker kind, model, folder): the shell command
1160
+ // exports those per session.
1161
+ for (const key of WORKER_SECRET_ENV_VARS) delete tmuxEnv[key];
1162
+ for (const key of TMUX_WORKER_EXPORTED_ENV_VARS) delete tmuxEnv[key];
1163
+ delete tmuxEnv.PWD;
1013
1164
  if (allowProcessReuse) {
1014
1165
  const existingPanePid = getTmuxPanePid(sessionName, { execFileSyncImpl });
1015
1166
  if (existingPanePid && isPidAlive(existingPanePid)) {